immich-export 0.0.2__tar.gz
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-0.0.2/.env.example +4 -0
- immich_export-0.0.2/.github/CODEOWNERS +1 -0
- immich_export-0.0.2/.github/workflows/ci.yml +24 -0
- immich_export-0.0.2/.github/workflows/release.yml +48 -0
- immich_export-0.0.2/.gitignore +10 -0
- immich_export-0.0.2/CHANGELOG.md +66 -0
- immich_export-0.0.2/LICENSE +21 -0
- immich_export-0.0.2/PKG-INFO +143 -0
- immich_export-0.0.2/README.md +120 -0
- immich_export-0.0.2/pyproject.toml +80 -0
- immich_export-0.0.2/renovate.json +37 -0
- immich_export-0.0.2/scripts/refresh_api_spec.py +66 -0
- immich_export-0.0.2/src/immich_export/__init__.py +3 -0
- immich_export-0.0.2/src/immich_export/__main__.py +5 -0
- immich_export-0.0.2/src/immich_export/api_contract.py +72 -0
- immich_export-0.0.2/src/immich_export/cli.py +149 -0
- immich_export-0.0.2/src/immich_export/client.py +189 -0
- immich_export-0.0.2/src/immich_export/config.py +61 -0
- immich_export-0.0.2/src/immich_export/errors.py +38 -0
- immich_export-0.0.2/src/immich_export/exporter.py +279 -0
- immich_export-0.0.2/src/immich_export/layout.py +52 -0
- immich_export-0.0.2/src/immich_export/manifest.py +123 -0
- immich_export-0.0.2/src/immich_export/models.py +108 -0
- immich_export-0.0.2/src/immich_export/py.typed +0 -0
- immich_export-0.0.2/src/immich_export/report.py +54 -0
- immich_export-0.0.2/src/immich_export/sidecar.py +97 -0
- immich_export-0.0.2/src/immich_export/views.py +61 -0
- immich_export-0.0.2/tests/__init__.py +0 -0
- immich_export-0.0.2/tests/conftest.py +27 -0
- immich_export-0.0.2/tests/data/immich-openapi.pruned.json +2742 -0
- immich_export-0.0.2/tests/fake_immich.py +204 -0
- immich_export-0.0.2/tests/test_cli.py +58 -0
- immich_export-0.0.2/tests/test_contract.py +42 -0
- immich_export-0.0.2/tests/test_errors.py +70 -0
- immich_export-0.0.2/tests/test_exporter.py +196 -0
- immich_export-0.0.2/tests/test_layout.py +80 -0
- immich_export-0.0.2/tests/test_manifest.py +61 -0
- immich_export-0.0.2/tests/test_sidecar.py +75 -0
- immich_export-0.0.2/uv.lock +573 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* @gykonik
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
on: [push, pull_request, workflow_dispatch]
|
|
3
|
+
|
|
4
|
+
jobs:
|
|
5
|
+
quality:
|
|
6
|
+
runs-on: ubuntu-latest
|
|
7
|
+
steps:
|
|
8
|
+
- uses: actions/checkout@v4
|
|
9
|
+
- uses: astral-sh/setup-uv@v5
|
|
10
|
+
- run: uv sync --all-extras --dev
|
|
11
|
+
|
|
12
|
+
- name: lint
|
|
13
|
+
run: |
|
|
14
|
+
uv run ruff check .
|
|
15
|
+
uv run ruff format --check .
|
|
16
|
+
|
|
17
|
+
- name: typecheck
|
|
18
|
+
run: uv run mypy
|
|
19
|
+
|
|
20
|
+
- name: test
|
|
21
|
+
run: uv run pytest -q
|
|
22
|
+
|
|
23
|
+
- name: build
|
|
24
|
+
run: uv build
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches: [main]
|
|
5
|
+
|
|
6
|
+
permissions:
|
|
7
|
+
contents: write # tag + GitHub Release
|
|
8
|
+
id-token: write # PyPI Trusted Publishing (OIDC, no token)
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
release:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
concurrency: release
|
|
14
|
+
steps:
|
|
15
|
+
# `git push` authenticates with the credentials checkout persists into the
|
|
16
|
+
# remote's extraheader — NOT with the token handed to the semantic-release
|
|
17
|
+
# action below (that one only signs GitHub API calls). The version commit
|
|
18
|
+
# lands on protected `main`, so the pushing identity needs a ruleset bypass:
|
|
19
|
+
# SEMANTIC_RELEASE_TOKEN is a PAT acting as an org owner, who has one.
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
with:
|
|
22
|
+
fetch-depth: 0
|
|
23
|
+
persist-credentials: true
|
|
24
|
+
token: ${{ secrets.SEMANTIC_RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
|
25
|
+
|
|
26
|
+
- name: Semantic release (version, tag, changelog, GitHub Release)
|
|
27
|
+
id: semrel
|
|
28
|
+
uses: python-semantic-release/python-semantic-release@v9
|
|
29
|
+
with:
|
|
30
|
+
github_token: ${{ secrets.SEMANTIC_RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
|
31
|
+
|
|
32
|
+
# No build step here on purpose. python-semantic-release already builds the
|
|
33
|
+
# sdist + wheel into dist/ via its `build_command` in pyproject.toml. It is
|
|
34
|
+
# a Docker action running as root, so those files are root-owned; a second
|
|
35
|
+
# `uv build` as the unprivileged runner user cannot overwrite them and dies
|
|
36
|
+
# with PermissionError, taking publish and the brew bump down with it.
|
|
37
|
+
- name: Publish to PyPI (OIDC)
|
|
38
|
+
if: steps.semrel.outputs.released == 'true'
|
|
39
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
40
|
+
|
|
41
|
+
- name: Bump Homebrew formula
|
|
42
|
+
if: steps.semrel.outputs.released == 'true'
|
|
43
|
+
run: |
|
|
44
|
+
gh workflow run bump.yml -R fileworks/homebrew-tap \
|
|
45
|
+
-f formula=immich-export \
|
|
46
|
+
-f version=${{ steps.semrel.outputs.version }}
|
|
47
|
+
env:
|
|
48
|
+
GH_TOKEN: ${{ secrets.TAP_DISPATCH_TOKEN }}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
## v0.0.2 (2026-07-13)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- **release**: Drop the duplicate build that broke publishing
|
|
9
|
+
([#3](https://github.com/fileworks/immich-export/pull/3),
|
|
10
|
+
[`2f8683b`](https://github.com/fileworks/immich-export/commit/2f8683b21f8b0b75ffa0e6805547e450af394896))
|
|
11
|
+
|
|
12
|
+
The release job died at 'Build sdist + wheel' with
|
|
13
|
+
|
|
14
|
+
PermissionError: [Errno 13] Permission denied: dist/<pkg>-0.0.1.tar.gz
|
|
15
|
+
|
|
16
|
+
python-semantic-release already builds the sdist and wheel into dist/ via the build_command in
|
|
17
|
+
pyproject.toml. It is a Docker action running as root, so those artefacts are root-owned. The
|
|
18
|
+
workflow then ran a second, redundant 'uv build' as the unprivileged runner user, which cannot
|
|
19
|
+
overwrite them.
|
|
20
|
+
|
|
21
|
+
Publish to PyPI and the Homebrew bump are both gated on that step, so a permission error on a build
|
|
22
|
+
that never needed to happen silently took out the entire release.
|
|
23
|
+
|
|
24
|
+
Remove the duplicate build and the setup-uv step that only served it, and let the publish action
|
|
25
|
+
consume the dist/ that semantic-release produced.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## v0.0.1 (2026-07-12)
|
|
29
|
+
|
|
30
|
+
### Bug Fixes
|
|
31
|
+
|
|
32
|
+
- **release**: Give checkout the release token so the version push is allowed
|
|
33
|
+
([#2](https://github.com/fileworks/immich-export/pull/2),
|
|
34
|
+
[`6ae635e`](https://github.com/fileworks/immich-export/commit/6ae635e0f7d3a0a1e65a40e76d470d5bbd5122a3))
|
|
35
|
+
|
|
36
|
+
The release job fails with GH013 — the chore(release) version commit cannot be pushed to protected
|
|
37
|
+
main.
|
|
38
|
+
|
|
39
|
+
Handing SEMANTIC_RELEASE_TOKEN to the python-semantic-release action is not enough: that input only
|
|
40
|
+
authenticates GitHub API calls. The actual git push uses the credentials actions/checkout persists
|
|
41
|
+
into the remote's extraheader, which were still the default GITHUB_TOKEN — an identity with no
|
|
42
|
+
ruleset bypass.
|
|
43
|
+
|
|
44
|
+
Set the token on checkout as well, mirroring media-sorter's semantic-release workflow, so the push
|
|
45
|
+
authenticates as an org owner and the always-bypass applies.
|
|
46
|
+
|
|
47
|
+
### Continuous Integration
|
|
48
|
+
|
|
49
|
+
- Allow CI to be triggered manually ([#1](https://github.com/fileworks/immich-export/pull/1),
|
|
50
|
+
[`0e64d69`](https://github.com/fileworks/immich-export/commit/0e64d694c92fcd85992101ce249aaab9dd45c63e))
|
|
51
|
+
|
|
52
|
+
* ci: allow CI to be triggered manually
|
|
53
|
+
|
|
54
|
+
workflow_dispatch makes it possible to re-run checks without pushing a commit — needed while
|
|
55
|
+
verifying the org's Actions allow-list, and useful any time a run fails for reasons outside the
|
|
56
|
+
code.
|
|
57
|
+
|
|
58
|
+
* fix(release): push the version commit with a token that can bypass the ruleset
|
|
59
|
+
|
|
60
|
+
semantic-release commits the version bump to main. The branch ruleset requires a PR, and the default
|
|
61
|
+
GITHUB_TOKEN has no bypass — GitHub rejects the Actions app as a bypass actor ("must be part of
|
|
62
|
+
the ruleset source or owner organization"), so the release job could never land its commit.
|
|
63
|
+
|
|
64
|
+
SEMANTIC_RELEASE_TOKEN is a fine-grained PAT that acts as an org owner, who already holds an
|
|
65
|
+
always-bypass on the ruleset. Falls back to GITHUB_TOKEN so nothing breaks where the secret is
|
|
66
|
+
absent.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Niklas Büchel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: immich-export
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Export everything out of Immich — files plus albums, people, tags, descriptions — into a redundant, human-readable local folder tree.
|
|
5
|
+
Project-URL: Repository, https://github.com/fileworks/immich-export
|
|
6
|
+
Project-URL: Issues, https://github.com/fileworks/immich-export/issues
|
|
7
|
+
Author: gykonik
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: backup,export,immich,photos,xmp
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: System :: Archiving :: Backup
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Requires-Dist: httpx>=0.28
|
|
20
|
+
Requires-Dist: pydantic>=2.9
|
|
21
|
+
Requires-Dist: typer>=0.15
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# immich-export
|
|
25
|
+
|
|
26
|
+
Pull **everything** out of [Immich](https://immich.app) — the files **and** the
|
|
27
|
+
metadata that only lives in its database (albums, people, tags, descriptions,
|
|
28
|
+
favorites, geo) — into a redundant, human-readable local folder tree.
|
|
29
|
+
|
|
30
|
+
This is the media escape hatch that makes "Immich = source of truth"
|
|
31
|
+
reversible: if Immich vanished tomorrow, the export is a plain tree you can
|
|
32
|
+
browse, grep, or import anywhere else.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
immich-export/
|
|
36
|
+
library/2024/03/IMG_1234.jpg # primary tree (self-contained mode)
|
|
37
|
+
library/2024/03/IMG_1234.jpg.xmp # sidecar: tags, people, albums, description, geo, favorite
|
|
38
|
+
albums/Japan-2019/IMG_1234.jpg # → symlink into library/
|
|
39
|
+
people/Anna/IMG_1234.jpg # → symlink into library/
|
|
40
|
+
manifest.jsonl # one line per asset: id, checksum, path, all metadata
|
|
41
|
+
manifest.csv # the same, human-readable
|
|
42
|
+
export-report.txt # counts, warnings, errors, timing
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
pipx install immich-export
|
|
49
|
+
# or
|
|
50
|
+
brew install fileworks/tap/immich-export
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
*(Not yet published — first release pending; until then: `uv run immich-export` from a checkout.)*
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
export IMMICH_SERVER=https://immich.local:2283
|
|
59
|
+
export IMMICH_API_KEY=... # Immich → Account Settings → API Keys
|
|
60
|
+
|
|
61
|
+
# full portable export (copies originals)
|
|
62
|
+
immich-export --out ./immich-export
|
|
63
|
+
|
|
64
|
+
# incremental re-run: only new/changed assets are downloaded
|
|
65
|
+
immich-export --out ./immich-export
|
|
66
|
+
|
|
67
|
+
# sidecar mode: you already have the Storage-Template tree mounted —
|
|
68
|
+
# only write .xmp sidecars + album/people views next to it
|
|
69
|
+
immich-export --mode sidecar --library-root /volume1/photos --out /volume1/photos
|
|
70
|
+
|
|
71
|
+
# custom primary tree
|
|
72
|
+
immich-export --layout "{year}/{album}" --out ./export
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Key flags (see `immich-export --help` for all):
|
|
76
|
+
|
|
77
|
+
| Flag | Default | Meaning |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `--mode` | `self-contained` | `self-contained` copies originals; `sidecar` only writes XMP + views next to an existing tree |
|
|
80
|
+
| `--layout` | `{year}/{month}` | primary tree; tokens `{year} {month} {day} {album} {type}`; `{album}` falls back to `Unsorted` |
|
|
81
|
+
| `--album-view` / `--people-view` | on | build `albums/` and `people/` symlink views |
|
|
82
|
+
| `--sidecars` | `xmp` | `xmp` or `none` |
|
|
83
|
+
| `--since` | — | only assets taken on/after this date |
|
|
84
|
+
| `--resume` | on | skip assets already exported with an unchanged checksum (from `manifest.jsonl`) |
|
|
85
|
+
| `--include-hidden` | off | also export hidden + locked-folder assets |
|
|
86
|
+
|
|
87
|
+
## Guarantees
|
|
88
|
+
|
|
89
|
+
- **Read-only against Immich.** Never writes back.
|
|
90
|
+
- **Best-effort.** One bad asset logs an error and the run continues; the report lists every failure.
|
|
91
|
+
- **Idempotent / resumable.** The manifest records each asset's checksum; re-runs download only new or changed files, and refresh sidecars when only metadata (albums/tags/people) changed.
|
|
92
|
+
- **Verifiable.** Every download is checked against Immich's SHA-1; `manifest.jsonl` lets you diff export-vs-server (or vs. a restore) any time.
|
|
93
|
+
- **Streams.** Assets are paged and downloaded with bounded concurrency — a 100k-asset library never sits in memory.
|
|
94
|
+
|
|
95
|
+
## Exit codes
|
|
96
|
+
|
|
97
|
+
| Code | Meaning |
|
|
98
|
+
|---|---|
|
|
99
|
+
| 0 | success (including an empty library) |
|
|
100
|
+
| 2 | bad configuration or authentication failure |
|
|
101
|
+
| 3 | server unreachable |
|
|
102
|
+
| 4 | output directory unwritable / out of space |
|
|
103
|
+
| 1 | unexpected error (re-run with `--verbose` for the traceback) |
|
|
104
|
+
|
|
105
|
+
## Sidecar format
|
|
106
|
+
|
|
107
|
+
Standard XMP wherever a standard slot exists — `dc:subject` (tags),
|
|
108
|
+
`Iptc4xmpExt:PersonInImage` (people), `dc:description`, `photoshop:DateCreated`,
|
|
109
|
+
`exif:GPSLatitude/Longitude`, `xmp:Rating` (favorite → 5) — so digiKam,
|
|
110
|
+
Lightroom and exiftool can read them. Album membership and Immich ids live in a
|
|
111
|
+
custom `immich:` namespace in the same file.
|
|
112
|
+
|
|
113
|
+
## Immich API compatibility
|
|
114
|
+
|
|
115
|
+
Built against the **Immich v3 API** (spec version 3.0.1). Instead of a
|
|
116
|
+
generated client, the exact API slice used is declared in
|
|
117
|
+
`src/immich_export/api_contract.py` and checked in CI against a vendored,
|
|
118
|
+
pruned copy of the official OpenAPI spec. To check a new Immich release:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
uv run python scripts/refresh_api_spec.py --ref v3.1.0
|
|
122
|
+
uv run pytest tests/test_contract.py
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A removed endpoint or field fails the tests *before* it breaks at runtime.
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
uv sync --all-extras --dev
|
|
131
|
+
uv run ruff check . && uv run ruff format --check . # lint
|
|
132
|
+
uv run mypy # strict types
|
|
133
|
+
uv run pytest # tests (mock Immich API)
|
|
134
|
+
uv build # sdist + wheel
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Conventional Commits drive releases (`python-semantic-release`): merge to
|
|
138
|
+
`main` → version bump + changelog + GitHub Release + PyPI publish (OIDC) +
|
|
139
|
+
Homebrew formula bump.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# immich-export
|
|
2
|
+
|
|
3
|
+
Pull **everything** out of [Immich](https://immich.app) — the files **and** the
|
|
4
|
+
metadata that only lives in its database (albums, people, tags, descriptions,
|
|
5
|
+
favorites, geo) — into a redundant, human-readable local folder tree.
|
|
6
|
+
|
|
7
|
+
This is the media escape hatch that makes "Immich = source of truth"
|
|
8
|
+
reversible: if Immich vanished tomorrow, the export is a plain tree you can
|
|
9
|
+
browse, grep, or import anywhere else.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
immich-export/
|
|
13
|
+
library/2024/03/IMG_1234.jpg # primary tree (self-contained mode)
|
|
14
|
+
library/2024/03/IMG_1234.jpg.xmp # sidecar: tags, people, albums, description, geo, favorite
|
|
15
|
+
albums/Japan-2019/IMG_1234.jpg # → symlink into library/
|
|
16
|
+
people/Anna/IMG_1234.jpg # → symlink into library/
|
|
17
|
+
manifest.jsonl # one line per asset: id, checksum, path, all metadata
|
|
18
|
+
manifest.csv # the same, human-readable
|
|
19
|
+
export-report.txt # counts, warnings, errors, timing
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pipx install immich-export
|
|
26
|
+
# or
|
|
27
|
+
brew install fileworks/tap/immich-export
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
*(Not yet published — first release pending; until then: `uv run immich-export` from a checkout.)*
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
export IMMICH_SERVER=https://immich.local:2283
|
|
36
|
+
export IMMICH_API_KEY=... # Immich → Account Settings → API Keys
|
|
37
|
+
|
|
38
|
+
# full portable export (copies originals)
|
|
39
|
+
immich-export --out ./immich-export
|
|
40
|
+
|
|
41
|
+
# incremental re-run: only new/changed assets are downloaded
|
|
42
|
+
immich-export --out ./immich-export
|
|
43
|
+
|
|
44
|
+
# sidecar mode: you already have the Storage-Template tree mounted —
|
|
45
|
+
# only write .xmp sidecars + album/people views next to it
|
|
46
|
+
immich-export --mode sidecar --library-root /volume1/photos --out /volume1/photos
|
|
47
|
+
|
|
48
|
+
# custom primary tree
|
|
49
|
+
immich-export --layout "{year}/{album}" --out ./export
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Key flags (see `immich-export --help` for all):
|
|
53
|
+
|
|
54
|
+
| Flag | Default | Meaning |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `--mode` | `self-contained` | `self-contained` copies originals; `sidecar` only writes XMP + views next to an existing tree |
|
|
57
|
+
| `--layout` | `{year}/{month}` | primary tree; tokens `{year} {month} {day} {album} {type}`; `{album}` falls back to `Unsorted` |
|
|
58
|
+
| `--album-view` / `--people-view` | on | build `albums/` and `people/` symlink views |
|
|
59
|
+
| `--sidecars` | `xmp` | `xmp` or `none` |
|
|
60
|
+
| `--since` | — | only assets taken on/after this date |
|
|
61
|
+
| `--resume` | on | skip assets already exported with an unchanged checksum (from `manifest.jsonl`) |
|
|
62
|
+
| `--include-hidden` | off | also export hidden + locked-folder assets |
|
|
63
|
+
|
|
64
|
+
## Guarantees
|
|
65
|
+
|
|
66
|
+
- **Read-only against Immich.** Never writes back.
|
|
67
|
+
- **Best-effort.** One bad asset logs an error and the run continues; the report lists every failure.
|
|
68
|
+
- **Idempotent / resumable.** The manifest records each asset's checksum; re-runs download only new or changed files, and refresh sidecars when only metadata (albums/tags/people) changed.
|
|
69
|
+
- **Verifiable.** Every download is checked against Immich's SHA-1; `manifest.jsonl` lets you diff export-vs-server (or vs. a restore) any time.
|
|
70
|
+
- **Streams.** Assets are paged and downloaded with bounded concurrency — a 100k-asset library never sits in memory.
|
|
71
|
+
|
|
72
|
+
## Exit codes
|
|
73
|
+
|
|
74
|
+
| Code | Meaning |
|
|
75
|
+
|---|---|
|
|
76
|
+
| 0 | success (including an empty library) |
|
|
77
|
+
| 2 | bad configuration or authentication failure |
|
|
78
|
+
| 3 | server unreachable |
|
|
79
|
+
| 4 | output directory unwritable / out of space |
|
|
80
|
+
| 1 | unexpected error (re-run with `--verbose` for the traceback) |
|
|
81
|
+
|
|
82
|
+
## Sidecar format
|
|
83
|
+
|
|
84
|
+
Standard XMP wherever a standard slot exists — `dc:subject` (tags),
|
|
85
|
+
`Iptc4xmpExt:PersonInImage` (people), `dc:description`, `photoshop:DateCreated`,
|
|
86
|
+
`exif:GPSLatitude/Longitude`, `xmp:Rating` (favorite → 5) — so digiKam,
|
|
87
|
+
Lightroom and exiftool can read them. Album membership and Immich ids live in a
|
|
88
|
+
custom `immich:` namespace in the same file.
|
|
89
|
+
|
|
90
|
+
## Immich API compatibility
|
|
91
|
+
|
|
92
|
+
Built against the **Immich v3 API** (spec version 3.0.1). Instead of a
|
|
93
|
+
generated client, the exact API slice used is declared in
|
|
94
|
+
`src/immich_export/api_contract.py` and checked in CI against a vendored,
|
|
95
|
+
pruned copy of the official OpenAPI spec. To check a new Immich release:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
uv run python scripts/refresh_api_spec.py --ref v3.1.0
|
|
99
|
+
uv run pytest tests/test_contract.py
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
A removed endpoint or field fails the tests *before* it breaks at runtime.
|
|
103
|
+
|
|
104
|
+
## Development
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
uv sync --all-extras --dev
|
|
108
|
+
uv run ruff check . && uv run ruff format --check . # lint
|
|
109
|
+
uv run mypy # strict types
|
|
110
|
+
uv run pytest # tests (mock Immich API)
|
|
111
|
+
uv build # sdist + wheel
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Conventional Commits drive releases (`python-semantic-release`): merge to
|
|
115
|
+
`main` → version bump + changelog + GitHub Release + PyPI publish (OIDC) +
|
|
116
|
+
Homebrew formula bump.
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "immich-export"
|
|
3
|
+
version = "0.0.2"
|
|
4
|
+
description = "Export everything out of Immich — files plus albums, people, tags, descriptions — into a redundant, human-readable local folder tree."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "gykonik" }]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
keywords = ["immich", "export", "backup", "photos", "xmp"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: End Users/Desktop",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Topic :: System :: Archiving :: Backup",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"httpx>=0.28",
|
|
22
|
+
"pydantic>=2.9",
|
|
23
|
+
"typer>=0.15",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Repository = "https://github.com/fileworks/immich-export"
|
|
28
|
+
Issues = "https://github.com/fileworks/immich-export/issues"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
immich-export = "immich_export.cli:app"
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = [
|
|
35
|
+
"mypy>=1.13",
|
|
36
|
+
"pytest>=8.3",
|
|
37
|
+
"pytest-asyncio>=0.24",
|
|
38
|
+
"respx>=0.22",
|
|
39
|
+
"ruff>=0.8",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["hatchling"]
|
|
44
|
+
build-backend = "hatchling.build"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/immich_export"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 100
|
|
51
|
+
target-version = "py312"
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = ["E", "F", "I", "N", "UP", "B", "A", "C4", "SIM", "RUF", "PTH", "RET", "ARG"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint.per-file-ignores]
|
|
57
|
+
# pytest fixtures are frequently used only for their side effect
|
|
58
|
+
"tests/**" = ["ARG001"]
|
|
59
|
+
|
|
60
|
+
[tool.mypy]
|
|
61
|
+
python_version = "3.12"
|
|
62
|
+
strict = true
|
|
63
|
+
files = ["src", "tests", "scripts"]
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
testpaths = ["tests"]
|
|
67
|
+
asyncio_mode = "auto"
|
|
68
|
+
|
|
69
|
+
[tool.semantic_release]
|
|
70
|
+
version_toml = ["pyproject.toml:project.version"]
|
|
71
|
+
version_variables = ["src/immich_export/__init__.py:__version__"]
|
|
72
|
+
commit_message = "chore(release): {version} [skip ci]"
|
|
73
|
+
build_command = "python -m pip install uv && uv build"
|
|
74
|
+
allow_zero_version = true
|
|
75
|
+
|
|
76
|
+
[tool.semantic_release.branches.main]
|
|
77
|
+
match = "main"
|
|
78
|
+
|
|
79
|
+
[tool.semantic_release.changelog.default_templates]
|
|
80
|
+
changelog_file = "CHANGELOG.md"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
3
|
+
"extends": ["config:recommended", ":semanticCommits", ":dependencyDashboard"],
|
|
4
|
+
"timezone": "Europe/Berlin",
|
|
5
|
+
"schedule": ["before 6am on monday"],
|
|
6
|
+
"prConcurrentLimit": 5,
|
|
7
|
+
"lockFileMaintenance": {
|
|
8
|
+
"enabled": true,
|
|
9
|
+
"schedule": ["before 6am on the first day of the month"]
|
|
10
|
+
},
|
|
11
|
+
"vulnerabilityAlerts": {
|
|
12
|
+
"labels": ["security"],
|
|
13
|
+
"schedule": ["at any time"],
|
|
14
|
+
"automerge": true
|
|
15
|
+
},
|
|
16
|
+
"packageRules": [
|
|
17
|
+
{
|
|
18
|
+
"description": "Auto-merge patch/minor once CI (lint/typecheck/test/build) is green — low risk, keeps the tree current without manual review.",
|
|
19
|
+
"matchUpdateTypes": ["patch", "minor", "digest"],
|
|
20
|
+
"automerge": true,
|
|
21
|
+
"automergeType": "pr",
|
|
22
|
+
"platformAutomerge": true
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"description": "Major bumps always get a PR, never auto-merged — API/behaviour changes need a look.",
|
|
26
|
+
"matchUpdateTypes": ["major"],
|
|
27
|
+
"automerge": false,
|
|
28
|
+
"labels": ["major-update"]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"description": "Group all GitHub Actions bumps into one PR.",
|
|
32
|
+
"matchManagers": ["github-actions"],
|
|
33
|
+
"groupName": "GitHub Actions",
|
|
34
|
+
"automerge": true
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Refresh the vendored, pruned Immich OpenAPI spec used by the contract tests.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
uv run python scripts/refresh_api_spec.py # fetch from GitHub main
|
|
5
|
+
uv run python scripts/refresh_api_spec.py --ref v3.0.1
|
|
6
|
+
uv run python scripts/refresh_api_spec.py --from-file /path/to/spec.json
|
|
7
|
+
|
|
8
|
+
The pruned file keeps only what the contract tests need: every path with its
|
|
9
|
+
methods, and every schema with its property names. Re-running this against a
|
|
10
|
+
new Immich release and then running `pytest tests/test_contract.py` tells you
|
|
11
|
+
whether this tool still matches the API.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
SPEC_URL = (
|
|
24
|
+
"https://raw.githubusercontent.com/immich-app/immich/{ref}/open-api/immich-openapi-specs.json"
|
|
25
|
+
)
|
|
26
|
+
OUTPUT = Path(__file__).resolve().parent.parent / "tests" / "data" / "immich-openapi.pruned.json"
|
|
27
|
+
HTTP_METHODS = {"get", "put", "post", "delete", "patch", "head", "options"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def prune(spec: dict[str, Any]) -> dict[str, Any]:
|
|
31
|
+
paths = {
|
|
32
|
+
path: sorted(method for method in operations if method in HTTP_METHODS)
|
|
33
|
+
for path, operations in spec["paths"].items()
|
|
34
|
+
}
|
|
35
|
+
schemas = {
|
|
36
|
+
name: sorted(schema.get("properties", {}))
|
|
37
|
+
for name, schema in spec["components"]["schemas"].items()
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
"immich_version": spec["info"]["version"],
|
|
41
|
+
"paths": paths,
|
|
42
|
+
"schemas": schemas,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main() -> None:
|
|
47
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
48
|
+
parser.add_argument("--ref", default="main", help="Immich git ref to fetch the spec from.")
|
|
49
|
+
parser.add_argument("--from-file", type=Path, help="Use a local spec file instead of fetching.")
|
|
50
|
+
args = parser.parse_args()
|
|
51
|
+
|
|
52
|
+
if args.from_file:
|
|
53
|
+
spec = json.loads(args.from_file.read_text(encoding="utf-8"))
|
|
54
|
+
else:
|
|
55
|
+
response = httpx.get(SPEC_URL.format(ref=args.ref), follow_redirects=True, timeout=60)
|
|
56
|
+
response.raise_for_status()
|
|
57
|
+
spec = response.json()
|
|
58
|
+
|
|
59
|
+
pruned = prune(spec)
|
|
60
|
+
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
OUTPUT.write_text(json.dumps(pruned, indent=1, sort_keys=True) + "\n", encoding="utf-8")
|
|
62
|
+
print(f"Wrote {OUTPUT} (Immich {pruned['immich_version']})")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
main()
|