Add an immich client that can upload as well as annotate

Carry over immich-sync's client, which lists assets by checksum and drives
albums and tags, and add a hand-rolled multipart upload with the checksum
header so immich can answer duplicate without receiving the bytes twice,
plus a per-asset update for description, date and coordinates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bobandClaude Fable 5.1 committed 2026-09-10 12:07:45 +01:00
1 parent 5549217db5
commit a0a5d7c92b
2 files changed
+392

No files matched your search

+182
View File
@@ -429,6 +429,17 @@ class Retrying(TestCase):
rb.OPEN = FakeOpen(FakeResponse({"stat": "ok"}))
rb.flickrcall("flickr.test.echo", {}, "KEY")
self.assertEqual(waits, [2.0])
def test_pace_leaves_immich_and_the_index_alone(self):
waits = []
rb.SLEEP = waits.append
rb.PACE = 2.0
rb.OPEN = FakeOpen(FakeResponse({"id": "u1"}),
FakeResponse(json.dumps([index_entry("A", "")]).encode()))
rb.Immich("https://i/api", "k").me()
rb.loadindex("http://t/index")
self.assertEqual(waits, [])
def test_download_refuses_an_oversized_body(self):
rb.DOWNLOAD_CAP = 10
rb.OPEN = FakeOpen(FakeResponse(b"x" * 100))
@@ -932,3 +943,174 @@ class FetchPhase(TestCase):
rb.OPEN = FakeOpen(http_error(404))
with self.assertRaises(rb.BackupError):
rb.fetchphase(self.conf(), "k")
def multipart_fields(request):
"""Parse the multipart body FakeOpen captured into {name: (filename, bytes)}."""
ctype = request.get_header("Content-type")
boundary = ctype.split("boundary=")[1].encode()
fields = {}
for part in request.data.split(b"--" + boundary)[1:-1]:
head, _, body = part.partition(b"\r\n\r\n")
name = re.search(rb'name="([^"]+)"', head).group(1).decode()
fname = re.search(rb'filename="([^"]+)"', head)
fields[name] = (fname.group(1).decode() if fname else None, body.rstrip(b"\r\n"))
return fields
class ImmichClient(TestCase):
def client(self, *responses):
rb.OPEN = FakeOpen(*responses)
return rb.Immich("https://immich.example/api", "KEY")
def test_call_sets_headers_and_parses(self):
im = self.client(FakeResponse({"id": "u1"}))
self.assertEqual(im.me(), {"id": "u1"})
req = rb.OPEN.requests[0]
self.assertEqual(req.full_url, "https://immich.example/api/users/me")
self.assertEqual(req.get_header("X-api-key"), "KEY")
def test_http_error_becomes_backuperror(self):
im = self.client(http_error(401, b"unauthorized"))
with self.assertRaises(rb.BackupError) as cm:
im.me()
self.assertIn("401", str(cm.exception))
def test_a_body_that_is_not_json_becomes_backuperror(self):
im = self.client(FakeResponse(b"<html>proxy says no</html>"))
with self.assertRaises(rb.BackupError) as cm:
im.me()
self.assertIn("/users/me", str(cm.exception))
def test_me_without_an_id_raises(self):
im = self.client(FakeResponse({"email": "someone@example.invalid"}))
with self.assertRaises(rb.BackupError):
im.me()
def test_createalbum_without_an_id_raises(self):
im = self.client(FakeResponse({"albumName": "RGL"}))
with self.assertRaises(rb.BackupError):
im.createalbum("RGL")
def test_assets_pages_and_filters_owner(self):
page1 = {"assets": {"items": [
{"id": "a1", "ownerId": "u1", "checksum": "c1", "originalFileName": "1.jpg",
"exifInfo": {"description": "d", "latitude": 51.5, "longitude": -0.1,
"dateTimeOriginal": "2009-07-22T14:03:11.000Z"}},
{"id": "a2", "ownerId": "other", "checksum": "c2"}], "nextPage": "2"}}
page2 = {"assets": {"items": [
{"id": "a3", "ownerId": "u1", "checksum": "c3", "exifInfo": None}], "nextPage": None}}
im = self.client(FakeResponse(page1), FakeResponse(page2))
assets = im.assets("u1")
self.assertEqual(sorted(assets), ["c1", "c3"])
self.assertEqual(assets["c1"]["latitude"], 51.5)
self.assertEqual(assets["c1"]["taken"], "2009-07-22T14:03:11.000Z")
self.assertEqual(assets["c3"]["description"], "")
self.assertIsNone(assets["c3"]["latitude"])
body = json.loads(rb.OPEN.requests[1].data)
self.assertEqual(body["page"], 2)
self.assertTrue(body["withExif"])
self.assertTrue(body["withArchived"])
def test_asset_reads_one_in_the_same_shape(self):
im = self.client(FakeResponse({
"id": "a1", "originalFileName": "111.jpg",
"exifInfo": {"description": "Mine", "latitude": 51.5, "longitude": -0.1,
"dateTimeOriginal": "2009-07-22T14:03:11.000Z"}}))
self.assertEqual(im.asset("a1"), {
"id": "a1", "name": "111.jpg", "description": "Mine",
"latitude": 51.5, "longitude": -0.1,
"taken": "2009-07-22T14:03:11.000Z"})
req = rb.OPEN.requests[0]
self.assertEqual(req.get_method(), "GET")
self.assertTrue(req.full_url.endswith("/assets/a1"))
def test_asset_without_exif_is_blank_not_missing(self):
im = self.client(FakeResponse({"id": "a1", "exifInfo": None}))
asset = im.asset("a1")
self.assertEqual((asset["description"], asset["taken"]), ("", ""))
self.assertIsNone(asset["latitude"])
def test_upload_is_multipart_with_checksum_header(self):
path = self.write("111.jpg", b"JPEGDATA")
im = self.client(FakeResponse({"id": "a9", "status": "created"}, status=201))
result = im.upload(path, "Q0hFQ0s=", "111", "2009-07-22T14:03:11Z", "2026-09-09T00:00:00Z")
self.assertEqual(result, ("a9", "created"))
req = rb.OPEN.requests[0]
self.assertEqual(req.get_method(), "POST")
self.assertTrue(req.full_url.endswith("/assets"))
self.assertEqual(req.get_header("X-immich-checksum"), "Q0hFQ0s=")
fields = multipart_fields(req)
self.assertEqual(fields["assetData"], ("111.jpg", b"JPEGDATA"))
self.assertEqual(fields["deviceAssetId"][1], b"111")
self.assertEqual(fields["deviceId"][1], rb.DEVICE_ID.encode())
self.assertEqual(fields["fileCreatedAt"][1], b"2009-07-22T14:03:11Z")
self.assertEqual(fields["fileModifiedAt"][1], b"2026-09-09T00:00:00Z")
def test_upload_duplicate(self):
path = self.write("111.jpg", b"J")
im = self.client(FakeResponse({"id": "a1", "status": "duplicate"}))
self.assertEqual(im.upload(path, "x", "111", "t", "t"), ("a1", "duplicate"))
def test_upload_empty_response_raises(self):
path = self.write("111.jpg", b"J")
im = self.client(FakeResponse(b""))
with self.assertRaises(rb.BackupError):
im.upload(path, "x", "111", "t", "t")
def test_update_sends_only_given_fields(self):
im = self.client(FakeResponse(b""))
im.update("a1", description="d", latitude=51.5, longitude=-0.1)
req = rb.OPEN.requests[0]
self.assertEqual(req.get_method(), "PUT")
self.assertTrue(req.full_url.endswith("/assets/a1"))
self.assertEqual(json.loads(req.data), {"description": "d", "latitude": 51.5, "longitude": -0.1})
def test_albums(self):
im = self.client(FakeResponse([{"albumName": "RGL", "id": "al1", "ownerId": "u1"},
{"albumName": "RGL", "id": "shared", "ownerId": "u2"},
{"albumName": "Nameless"}]),
FakeResponse({"id": "al2"}),
FakeResponse({"assets": [{"id": "a1"}, {"id": "a2"}]}),
FakeResponse([{"id": "a3", "success": True}, {"id": "a1", "success": False, "error": "duplicate"}]))
self.assertEqual(im.albums("u1"), {"RGL": "al1"})
self.assertEqual(im.createalbum("X"), "al2")
self.assertEqual(im.albumassets("al1"), {"a1", "a2"})
self.assertEqual(im.addtoalbum("al1", ["a3", "a1"]), 1)
def test_tags(self):
im = self.client(FakeResponse([{"value": "rgl", "id": "t1"}, {"value": "rgl/licence/x", "id": "t2"}]),
FakeResponse([{"success": True}, {"success": False, "error": "duplicate"}]))
self.assertEqual(im.tagids(["rgl", "rgl/licence/x"]), {"rgl": "t1", "rgl/licence/x": "t2"})
self.assertEqual(im.tagassets("t1", ["a1", "a2"]), (1, 1))
def test_tagids_skips_malformed_entries(self):
im = self.client(FakeResponse([{"value": "rgl", "id": "t1"}, {"value": "rgl/broken"}]))
self.assertEqual(im.tagids(["rgl", "rgl/broken"]), {"rgl": "t1"})
def test_batches_of_500(self):
ids = ["a%d" % i for i in range(1200)]
im = self.client(FakeResponse([{"success": True}] * 500),
FakeResponse([{"success": True}] * 500),
FakeResponse([{"success": True}] * 200))
self.assertEqual(im.addtoalbum("al", ids), 1200)
self.assertEqual(len(rb.OPEN.requests), 3)
class Hashing(TestCase):
def test_checksum_is_base64_sha1(self):
path = self.write("f", b"hello")
self.assertEqual(rb.checksum(path), "qvTGHdzF6KLavt4PO0gs2a6pQ00=")
def test_cache_reuses_until_file_changes(self):
path = self.write("f", b"hello")
cache = rb.Hashes(os.path.join(self.dir, rb.HASHES))
first = cache.of(path, "f", os.stat(path))
cache.save()
again = rb.Hashes(os.path.join(self.dir, rb.HASHES))
self.assertEqual(again.of(path, "f", os.stat(path)), first)
self.assertFalse(again.dirty)
self.write("f", b"changed")
os.utime(path, (1, 1))
self.assertNotEqual(again.of(path, "f", os.stat(path)), first)
self.assertTrue(again.dirty)