Store each photo's metadata in a json sidecar beside it
The sidecar is what makes the directory a backup on its own and what the immich phase reads, so it is written atomically and a photo only counts as done when the sidecar, its file and no .part remain. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2 files changed
+115
No files matched your search
@@ -541,3 +541,63 @@ class Flickr(TestCase):
|
||||
self.assertEqual(rb.extensionfor("https://x/1_o.jpeg", ""), ".jpg")
|
||||
self.assertEqual(rb.extensionfor("https://x/1_o", "image/gif"), ".gif")
|
||||
self.assertEqual(rb.extensionfor("https://x/1_o.weird", "text/html"), ".jpg")
|
||||
|
||||
|
||||
def sidecar(key="111", file="111.jpg", nodes=None, copyright="P", licence="https://creativecommons.org/licenses/by-sa/2.0/", flickr=True):
|
||||
data = {"key": key, "file": file, "source": "flickr" if flickr else "url",
|
||||
"rgl": {"copyright": copyright, "licence": licence,
|
||||
"nodes": nodes or [{"param": "A,_E1", "name": "A, E1",
|
||||
"url": "https://london.randomness.org.uk/wiki.cgi?A%2C_E1",
|
||||
"lat": "51.5", "long": "-0.1"}]},
|
||||
"fetched": "2026-09-09T21:14:00Z"}
|
||||
if flickr:
|
||||
data["flickr"] = {"id": key, "owner": "12345678@N00", "owner_name": "handle",
|
||||
"owner_realname": "Real Name", "title": "A title",
|
||||
"taken": "2009-07-22 14:03:11", "posted": "2009-07-23T10:40:00Z",
|
||||
"licence_id": 5, "page": "https://www.flickr.com/photos/12345678@N00/%s/" % key,
|
||||
"size_label": "Original", "width": 800, "height": 600}
|
||||
else:
|
||||
data["page"] = "https://www.geograph.org.uk/photos/1.jpg"
|
||||
return data
|
||||
|
||||
|
||||
class Sidecars(TestCase):
|
||||
def test_round_trip_is_atomic(self):
|
||||
path = rb.sidecarpath(self.dir, "111")
|
||||
self.assertEqual(path, os.path.join(self.dir, "111.json"))
|
||||
rb.writejson(path, sidecar())
|
||||
self.assertEqual(rb.readsidecar(path)["key"], "111")
|
||||
self.assertFalse(os.path.exists(path + ".part"))
|
||||
|
||||
def test_unreadable_sidecar_is_none(self):
|
||||
self.assertIsNone(rb.readsidecar(os.path.join(self.dir, "none.json")))
|
||||
self.write("bad.json", "{not json")
|
||||
self.assertIsNone(rb.readsidecar(os.path.join(self.dir, "bad.json")))
|
||||
|
||||
def test_mediapath_finds_the_photo_not_the_sidecar(self):
|
||||
self.write("111.json", "{}")
|
||||
self.write("111.part", b"x")
|
||||
self.assertIsNone(rb.mediapath(self.dir, "111"))
|
||||
self.write("111.png", b"x")
|
||||
self.assertEqual(rb.mediapath(self.dir, "111"), os.path.join(self.dir, "111.png"))
|
||||
|
||||
def test_iscomplete(self):
|
||||
self.assertFalse(rb.iscomplete(self.dir, "111"))
|
||||
rb.writejson(rb.sidecarpath(self.dir, "111"), sidecar())
|
||||
self.assertFalse(rb.iscomplete(self.dir, "111")) # no file yet
|
||||
self.write("111.jpg", b"x")
|
||||
self.assertTrue(rb.iscomplete(self.dir, "111"))
|
||||
self.write("111.part", b"x")
|
||||
self.assertFalse(rb.iscomplete(self.dir, "111")) # interrupted refetch
|
||||
def test_listsidecars_unreadable_directory_is_an_error(self):
|
||||
with self.assertRaises(rb.BackupError):
|
||||
rb.listsidecars(os.path.join(self.dir, "nowhere"))
|
||||
|
||||
def test_listsidecars_sorted_and_skips_hash_cache(self):
|
||||
rb.writejson(rb.sidecarpath(self.dir, "222"), sidecar(key="222", file="222.jpg"))
|
||||
rb.writejson(rb.sidecarpath(self.dir, "111"), sidecar())
|
||||
rb.writejson(os.path.join(self.dir, rb.HASHES), {})
|
||||
self.assertEqual([k for k, _ in rb.listsidecars(self.dir)], ["111", "222"])
|
||||
|
||||
def test_utcnow_shape(self):
|
||||
self.assertRegex(rb.utcnow(), r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$")
|
||||
Reference in new issue
Block a user