Files
rgl-photo-backup/test_rgl_photo_backup.py
T
bobandClaude Fable 5.1 534d68aac0 Mirror the backup directory into an immich album
List what immich already holds by checksum, upload only what is missing,
then bring every matched asset up to date: the marked description block,
coordinates and capture date where absent, the RGL album, and licence and
photographer tags. Album membership and descriptions are read-compare-write;
uploaded assets are re-read from immich after the album and tag stage so a
duplicate answer or a late metadata job never sees a blank slate. Tag
assignments are re-sent each run and immich reports the duplicates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 12:07:46 +01:00

1367 lines
66 KiB
Python

import contextlib
import importlib.machinery
import importlib.util
import io
import json
import os
import re
import sys
import tempfile
import unittest
import urllib.error
import urllib.parse
_here = os.path.dirname(os.path.abspath(__file__))
_path = os.path.join(_here, "rgl-photo-backup")
_spec = importlib.util.spec_from_file_location(
"rgl_photo_backup", _path,
loader=importlib.machinery.SourceFileLoader("rgl_photo_backup", _path))
rb = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(rb)
class FakeResponse:
"""What urlopen would return: a body, a status and headers."""
def __init__(self, body=b"", status=200, headers=None):
self.body = body if isinstance(body, bytes) else json.dumps(body).encode()
self.status = status
self.headers = headers or {}
self._pos = 0
def read(self, size=-1):
if size is None or size < 0:
data, self._pos = self.body[self._pos:], len(self.body)
else:
data = self.body[self._pos:self._pos + size]
self._pos += len(data)
return data
def close(self):
pass
def __enter__(self):
return self
def __exit__(self, *exc):
pass
def http_error(code, body=b"", headers=None):
return urllib.error.HTTPError("http://test", code, "reason %d" % code,
headers or {}, io.BytesIO(body))
class FakeOpen:
"""Replaces rb.OPEN. Serves scripted responses in order and records
every request so a test can assert on method, url, headers and body."""
def __init__(self, *responses):
self.responses = list(responses)
self.requests = []
def __call__(self, request, timeout=None):
self.requests.append(request)
if not self.responses:
raise AssertionError("unexpected request: %s %s"
% (request.get_method(), request.full_url))
answer = self.responses.pop(0)
if isinstance(answer, Exception):
raise answer
return answer
class TestCase(unittest.TestCase):
"""Silences output and restores the module globals tests move around."""
GLOBALS = ("OPEN", "SLEEP", "ATTEMPTS", "BACKOFF_BASE", "PACE", "QUIET",
"DEBUG", "HTTP_TIMEOUT", "USER_AGENT", "STATS", "DOWNLOAD_CAP")
def setUp(self):
self._output = contextlib.ExitStack()
if not os.environ.get("RGL_PHOTO_BACKUP_TEST_VERBOSE"):
self.stdout = self._output.enter_context(
contextlib.redirect_stdout(io.StringIO()))
self.stderr = self._output.enter_context(
contextlib.redirect_stderr(io.StringIO()))
self.addCleanup(self._output.close)
self._saved = {k: getattr(rb, k) for k in self.GLOBALS}
rb.SLEEP = lambda seconds: None
rb.PACE = 0.0
rb.STATS = dict.fromkeys(rb.STATS, 0)
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.dir = self.tmp.name
def tearDown(self):
for k, v in self._saved.items():
setattr(rb, k, v)
def write(self, name, content):
path = os.path.join(self.dir, name)
mode = "wb" if isinstance(content, bytes) else "w"
with open(path, mode) as fh:
fh.write(content)
return path
def index_entry(param, image, page="", copyright="", licence="", lat="51.5", long="-0.1", name=None):
md = {}
if image:
md["node_image"] = [image]
if page:
md["node_image_url"] = [page]
if copyright:
md["node_image_copyright"] = [copyright]
if licence:
md["node_image_licence"] = [licence]
return {"param": param, "name": name or param.replace("_", " "),
"wgs84_lat": lat, "wgs84_long": long, "has_geodata": 1,
"node_data": {"metadata": md}}
class Index(TestCase):
def test_readindex_tolerates_invalid_utf8(self):
entry = index_entry("Café,_N1", "https://farm1.static.flickr.com/1/123_abc_m.jpg")
raw = json.dumps([entry], ensure_ascii=False).encode("utf-8").replace("\xe9".encode(), b"\xc2")
nodes = rb.readindex(raw)
self.assertEqual(len(nodes), 1)
self.assertIn("�", nodes[0]["param"])
def test_readindex_flattens_fields(self):
raw = json.dumps([index_entry("Pub,_E1", "https://farm1.static.flickr.com/1/123_abc_m.jpg",
page="https://www.flickr.com/photos/x/123/",
copyright="Someone", licence="https://creativecommons.org/licenses/by-sa/2.0/"),
index_entry("Nopic,_E2", "")]).encode()
nodes = rb.readindex(raw)
self.assertEqual(len(nodes), 2)
n = nodes[0]
self.assertEqual(n["param"], "Pub,_E1")
self.assertEqual(n["name"], "Pub, E1")
self.assertEqual(n["image"], "https://farm1.static.flickr.com/1/123_abc_m.jpg")
self.assertEqual(n["page"], "https://www.flickr.com/photos/x/123/")
self.assertEqual(n["copyright"], "Someone")
self.assertEqual(n["licence"], "https://creativecommons.org/licenses/by-sa/2.0/")
self.assertEqual((n["lat"], n["long"]), ("51.5", "-0.1"))
self.assertEqual(n["url"], "https://london.randomness.org.uk/wiki.cgi?Pub%2C_E1")
self.assertEqual(nodes[1]["image"], "")
def test_zero_coordinates_are_kept(self):
raw = json.dumps([index_entry("Null,_Island", "https://x/1_a_m.jpg", lat=0, long=0)]).encode()
node = rb.readindex(raw)[0]
self.assertEqual((node["lat"], node["long"]), ("0", "0"))
def test_missing_coordinates_are_empty(self):
raw = json.dumps([index_entry("Nowhere", "https://x/1_a_m.jpg", lat=None, long=None)]).encode()
node = rb.readindex(raw)[0]
self.assertEqual((node["lat"], node["long"]), ("", ""))
def test_loadindex_fetches(self):
rb.OPEN = FakeOpen(FakeResponse(json.dumps([index_entry("A", "")]).encode()))
self.assertEqual(len(rb.loadindex("http://t/index")), 1)
class PhotoKeys(TestCase):
def key(self, image, page=""):
return rb.photokey({"image": image, "page": page, "param": "X"})
def test_id_from_thumbnail_filename(self):
self.assertEqual(self.key("https://farm4.static.flickr.com/3188/3002966579_97596aea2e_m.jpg"),
("flickr", "3002966579"))
self.assertEqual(self.key("https://live.staticflickr.com/65535/52123_abc_m.jpg"),
("flickr", "52123"))
def test_id_from_page_when_thumbnail_is_odd(self):
self.assertEqual(self.key("https://www.flickr.com/some/other.jpg",
"https://www.flickr.com/photos/kake/3002966579/in/set-1/"),
("flickr", "3002966579"))
self.assertEqual(self.key("https://c1.staticflickr.com/x.jpg",
"https://secure.flickr.com/photos/12345678@N00/98765"),
("flickr", "98765"))
def test_short_link_is_base58(self):
self.assertEqual(rb.base58decode("5zmZkR"), 3002966579)
self.assertEqual(self.key("https://c1.staticflickr.com/x.jpg", "https://flic.kr/p/5zmZkR"),
("flickr", "3002966579"))
def test_non_flickr_hosts_key_on_url_hash(self):
source, key = self.key("https://www.geograph.org.uk/photos/12/34/1234.jpg",
"https://www.geograph.org.uk/photo/1234")
self.assertEqual(source, "url")
self.assertRegex(key, r"^url-[0-9a-f]{16}$")
def test_flickr_cdn_without_id_falls_back_to_url_key(self):
source, key = self.key("https://c1.staticflickr.com/nodigits.jpg")
self.assertEqual(source, "url")
def test_not_a_url_is_none(self):
self.assertIsNone(self.key("not a url"))
self.assertIsNone(self.key(""))
class Grouping(TestCase):
def test_nodes_sharing_a_photo_group_together(self):
nodes = rb.readindex(json.dumps([
index_entry("A,_E1", "https://farm1.static.flickr.com/1/111_a_m.jpg",
page="https://www.flickr.com/photos/x/111/", copyright="P", licence="L"),
index_entry("B,_E2", "https://farm1.static.flickr.com/1/111_a_m.jpg",
page="https://www.flickr.com/photos/x/111/"),
index_entry("C,_E3", "https://farm1.static.flickr.com/1/222_b_m.jpg"),
index_entry("D,_E4", ""),
index_entry("E,_E5", "junk"),
]).encode())
groups, rejected = rb.groupphotos(nodes)
self.assertEqual(sorted(groups), ["111", "222"])
g = groups["111"]
self.assertEqual(g["source"], "flickr")
self.assertEqual(g["id"], "111")
self.assertEqual([n["param"] for n in g["nodes"]], ["A,_E1", "B,_E2"])
self.assertEqual(g["copyright"], "P")
self.assertEqual(g["licence"], "L")
self.assertEqual(g["page"], "https://www.flickr.com/photos/x/111/")
self.assertEqual(len(rejected), 1)
self.assertEqual(rejected[0][0]["param"], "E,_E5")
def test_first_non_empty_copyright_wins(self):
nodes = rb.readindex(json.dumps([
index_entry("A", "https://farm1.static.flickr.com/1/111_a_m.jpg"),
index_entry("B", "https://farm1.static.flickr.com/1/111_a_m.jpg", copyright="Later"),
]).encode())
groups, _ = rb.groupphotos(nodes)
self.assertEqual(groups["111"]["copyright"], "Later")
class Output(TestCase):
def test_say_prints_unless_quiet(self):
rb.QUIET = False
rb.say("hello")
self.assertEqual(self.stdout.getvalue(), "hello\n")
rb.QUIET = True
rb.say("again")
self.assertEqual(self.stdout.getvalue(), "hello\n")
def test_warn_always_reaches_stderr(self):
rb.QUIET = True
rb.warn("careful")
self.assertEqual(self.stderr.getvalue(), "careful\n")
def test_debug_only_when_enabled(self):
rb.DEBUG = False
rb.debug("x")
self.assertEqual(self.stderr.getvalue(), "")
rb.DEBUG = True
rb.debug("y")
self.assertEqual(self.stderr.getvalue(), "debug: y\n")
def test_errors_share_a_base(self):
self.assertTrue(issubclass(rb.PhotoGone, rb.BackupError))
e = rb.HTTPFailure("ctx: HTTP 404", code=404)
self.assertEqual(e.code, 404)
self.assertIsInstance(e, rb.BackupError)
class Config(TestCase):
def test_readyaml_reads_flat_pairs_and_ignores_comments(self):
path = self.write("a.yml", "# note\nkey: 'abc'\nurl: https://immich.example/api \n")
self.assertEqual(rb.readyaml(path), {"key": "abc", "url": "https://immich.example/api"})
def test_readyaml_missing_file_is_empty(self):
self.assertEqual(rb.readyaml(os.path.join(self.dir, "none.yml")), {})
def test_readyaml_unreadable_path_names_it(self):
with self.assertRaises(rb.BackupError) as cm:
rb.readyaml(self.dir) # a directory, not a file
self.assertIn(self.dir, str(cm.exception))
def test_loadflickr_needs_key(self):
path = self.write("f.yml", "secret: s\n")
with self.assertRaises(rb.BackupError):
rb.loadflickr(path)
path = self.write("f2.yml", "key: k\nsecret: s\n")
self.assertEqual(rb.loadflickr(path), "k")
def test_loadimmich_needs_url_and_key_and_strips_slash(self):
path = self.write("i.yml", "url: https://immich.example/api/\nkey: abc\n")
self.assertEqual(rb.loadimmich(path), ("https://immich.example/api", "abc"))
with self.assertRaises(rb.BackupError):
rb.loadimmich(self.write("i2.yml", "url: x\n"))
class Settings(TestCase):
def test_flag_beats_env_beats_file_beats_default(self):
path = self.write("s.yml", "directory: /from/file\npace: 3\nquiet: true\n")
args = rb.parseargs(["--settings", path])
conf = rb.settings(args, environ={})
self.assertEqual(conf["directory"], "/from/file")
self.assertEqual(conf["pace"], 3.0)
self.assertTrue(conf["quiet"])
conf = rb.settings(args, environ={rb.ENV + "PACE": "7"})
self.assertEqual(conf["pace"], 7.0)
args = rb.parseargs(["--settings", path, "--pace", "9", "/cli"])
conf = rb.settings(args, environ={rb.ENV + "PACE": "7"})
self.assertEqual(conf["pace"], 9.0)
self.assertEqual(conf["directory"], "/cli")
def test_defaults(self):
args = rb.parseargs(["--settings", os.path.join(self.dir, "none"), "/d"])
conf = rb.settings(args, environ={})
self.assertEqual(conf["index_url"], rb.INDEX_URL)
self.assertTrue(conf["fetch"] and conf["sync"])
self.assertFalse(conf["dry_run"])
self.assertIsNone(conf["limit"])
self.assertEqual(conf["attempts"], 6)
self.assertEqual(conf["flickr_auth"], os.path.expanduser(rb.FLICKR_CONFIG))
def test_defaults_are_literals_not_moving_constants(self):
rb.PACE, rb.ATTEMPTS, rb.USER_AGENT = 99.0, 99, "mangled"
args = rb.parseargs(["--settings", os.path.join(self.dir, "none"), "/d"])
conf = rb.settings(args, environ={})
self.assertEqual(conf["pace"], rb.DEFAULTS["pace"])
self.assertEqual(conf["attempts"], rb.DEFAULTS["attempts"])
self.assertEqual(conf["user_agent"], rb.DEFAULTS["user_agent"])
self.assertEqual((rb.DEFAULTS["pace"], rb.DEFAULTS["attempts"]), (1.0, 6))
def test_debug_is_a_setting(self):
path = self.write("s.yml", "directory: /d\ndebug: true\n")
self.assertEqual(self.stderr.getvalue(), "") # no "unknown setting"
args = rb.parseargs(["--settings", path])
self.assertTrue(rb.settings(args, environ={})["debug"])
args = rb.parseargs(["--settings", os.path.join(self.dir, "none"), "/d"])
self.assertFalse(rb.settings(args, environ={})["debug"])
self.assertTrue(rb.settings(args, environ={rb.ENV + "DEBUG": "yes"})["debug"])
args = rb.parseargs(["--settings", os.path.join(self.dir, "none"), "--debug", "/d"])
self.assertTrue(rb.settings(args, environ={})["debug"])
def test_a_bad_environment_value_is_a_configuration_error(self):
args = rb.parseargs(["--settings", os.path.join(self.dir, "none"), "/d"])
with self.assertRaises(rb.BackupError) as cm:
rb.settings(args, environ={rb.ENV + "ATTEMPTS": "lots"})
self.assertIn("ATTEMPTS", str(cm.exception))
def test_phase_flags(self):
base = ["--settings", os.path.join(self.dir, "none"), "/d"]
self.assertFalse(rb.settings(rb.parseargs(base + ["--sync-only"]), environ={})["fetch"])
self.assertFalse(rb.settings(rb.parseargs(base + ["--fetch-only"]), environ={})["sync"])
with self.assertRaises(rb.BackupError):
rb.settings(rb.parseargs(base + ["--fetch-only", "--sync-only"]), environ={})
def test_missing_directory_is_an_error(self):
args = rb.parseargs(["--settings", os.path.join(self.dir, "none")])
with self.assertRaises(rb.BackupError):
rb.settings(args, environ={})
def test_unknown_key_warns_and_wrong_type_fails(self):
path = self.write("s.yml", "directory: /d\nbogus: 1\n")
rb.loadsettings(path)
self.assertIn("bogus", self.stderr.getvalue())
path = self.write("s2.yml", "directory: /d\nlimit: many\n")
with self.assertRaises(rb.BackupError) as cm:
rb.loadsettings(path)
self.assertIn("limit", str(cm.exception))
def test_tilde_expanded_in_paths(self):
path = self.write("s.yml", "directory: ~/photos\n")
self.assertEqual(rb.loadsettings(path)["directory"], os.path.expanduser("~/photos"))
class Retrying(TestCase):
def test_success_returns_body(self):
rb.OPEN = FakeOpen(FakeResponse(b"ok"))
self.assertEqual(rb.fetch("http://t/x", "ctx"), b"ok")
self.assertEqual(rb.OPEN.requests[0].get_header("User-agent"), rb.USER_AGENT)
def test_404_is_not_retried(self):
rb.OPEN = FakeOpen(http_error(404, b"gone"))
with self.assertRaises(rb.HTTPFailure) as cm:
rb.fetch("http://t/x", "ctx")
self.assertEqual(cm.exception.code, 404)
self.assertIn("gone", str(cm.exception))
self.assertEqual(len(rb.OPEN.requests), 1)
def test_5xx_and_429_retry_then_succeed(self):
rb.OPEN = FakeOpen(http_error(503), http_error(429), FakeResponse(b"ok"))
self.assertEqual(rb.fetch("http://t/x", "ctx"), b"ok")
self.assertEqual(len(rb.OPEN.requests), 3)
self.assertEqual(rb.STATS["retries"], 2)
def test_connection_errors_retry(self):
rb.OPEN = FakeOpen(OSError("reset"), FakeResponse(b"ok"))
self.assertEqual(rb.fetch("http://t/x", "ctx"), b"ok")
def test_gives_up_after_attempts(self):
rb.ATTEMPTS = 3
rb.OPEN = FakeOpen(http_error(500), http_error(500), http_error(500))
with self.assertRaises(rb.HTTPFailure) as cm:
rb.fetch("http://t/x", "ctx")
self.assertIn("3 attempts", str(cm.exception))
def test_retry_after_header_is_honoured(self):
waits = []
rb.SLEEP = waits.append
rb.OPEN = FakeOpen(http_error(429, headers={"Retry-After": "12"}), FakeResponse(b"ok"))
rb.fetch("http://t/x", "ctx")
self.assertIn(12.0, waits)
def test_backoff_grows_and_caps(self):
rb.BACKOFF_BASE = 5
self.assertLessEqual(rb.backoff(1), 7.5)
self.assertGreaterEqual(rb.backoff(1), 2.5)
self.assertLessEqual(rb.backoff(20), rb.BACKOFF_CAP * 1.5)
def test_post_sends_body_and_headers(self):
rb.OPEN = FakeOpen(FakeResponse(b"{}"))
rb.fetch("http://t/x", "ctx", headers={"x-api-key": "k"}, data=b"body", method="POST")
req = rb.OPEN.requests[0]
self.assertEqual(req.get_method(), "POST")
self.assertEqual(req.data, b"body")
self.assertEqual(req.get_header("X-api-key"), "k")
def test_download_streams_to_path(self):
rb.OPEN = FakeOpen(FakeResponse(b"x" * 1000, headers={"Content-Type": "image/jpeg"}))
path = os.path.join(self.dir, "p.part")
self.assertEqual(rb.download("http://t/p.jpg", "ctx", path), ("image/jpeg", 1000))
self.assertEqual(os.path.getsize(path), 1000)
def test_pace_sleeps_before_a_flickr_call(self):
waits = []
rb.SLEEP = waits.append
rb.PACE = 2.0
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))
path = os.path.join(self.dir, "p.part")
with self.assertRaises(rb.BackupError) as cm:
rb.download("http://t/p.jpg", "ctx", path)
self.assertIn("10", str(cm.exception))
self.assertEqual(len(rb.OPEN.requests), 1) # not a retryable failure
def test_debug_redacts_the_flickr_key(self):
rb.DEBUG = True
rb.OPEN = FakeOpen(FakeResponse({"stat": "ok"}))
rb.flickrcall("flickr.test.echo", {}, "SECRETKEY")
logged = self.stderr.getvalue()
self.assertIn("api_key=<redacted>", logged)
self.assertNotIn("SECRETKEY", logged)
def flickr_info(photoid="111", licence=5, realname="Real Name"):
return {"stat": "ok", "photo": {
"id": photoid, "license": str(licence), "originalformat": "jpg",
"owner": {"nsid": "12345678@N00", "username": "handle", "realname": realname},
"title": {"_content": "A title"}, "description": {"_content": "Words"},
"dates": {"taken": "2009-07-22 14:03:11", "posted": "1248345600"},
"urls": {"url": [{"type": "photopage",
"_content": "https://www.flickr.com/photos/12345678@N00/%s/" % photoid}]},
}}
def flickr_sizes(*sizes):
return {"stat": "ok", "sizes": {"size": [
{"label": l, "width": w, "height": h, "source": s, "media": "photo"}
for (l, w, h, s) in sizes]}}
class Flickr(TestCase):
def test_call_builds_unsigned_json_request(self):
rb.OPEN = FakeOpen(FakeResponse({"stat": "ok", "x": 1}))
self.assertEqual(rb.flickrcall("flickr.test.echo", {"a": "b"}, "KEY")["x"], 1)
url = rb.OPEN.requests[0].full_url
q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
self.assertEqual(q["method"], ["flickr.test.echo"])
self.assertEqual(q["api_key"], ["KEY"])
self.assertEqual(q["format"], ["json"])
self.assertEqual(q["nojsoncallback"], ["1"])
self.assertEqual(q["a"], ["b"])
def test_api_failure_raises(self):
rb.OPEN = FakeOpen(FakeResponse({"stat": "fail", "code": 100, "message": "Invalid API Key"}))
with self.assertRaises(rb.BackupError) as cm:
rb.flickrcall("flickr.photos.getInfo", {"photo_id": "1"}, "k")
self.assertIn("Invalid API Key", str(cm.exception))
def test_transient_service_error_is_retried(self):
busy = {"stat": "fail", "code": 105, "message": "Service currently unavailable"}
rb.OPEN = FakeOpen(FakeResponse(busy), FakeResponse(busy),
FakeResponse({"stat": "ok", "x": 1}))
self.assertEqual(rb.flickrcall("flickr.test.echo", {}, "k")["x"], 1)
self.assertEqual(len(rb.OPEN.requests), 3)
self.assertEqual(rb.STATS["retries"], 2)
def test_transient_service_error_gives_up_after_attempts(self):
rb.ATTEMPTS = 2
busy = {"stat": "fail", "code": "106", "message": "Write operation failed"}
rb.OPEN = FakeOpen(FakeResponse(busy), FakeResponse(busy))
with self.assertRaises(rb.BackupError) as cm:
rb.flickrcall("flickr.test.echo", {}, "k")
self.assertIn("Write operation failed", str(cm.exception))
self.assertEqual(len(rb.OPEN.requests), 2)
def test_missing_or_private_photo_is_gone(self):
for code in (1, 2):
rb.OPEN = FakeOpen(FakeResponse({"stat": "fail", "code": code, "message": "nope"}))
with self.assertRaises(rb.PhotoGone):
rb.getinfo("1", "k")
def test_getinfo_flattens(self):
rb.OPEN = FakeOpen(FakeResponse(flickr_info()))
info = rb.getinfo("111", "k")
self.assertEqual(info["id"], "111")
self.assertEqual(info["owner"], "12345678@N00")
self.assertEqual(info["owner_name"], "handle")
self.assertEqual(info["owner_realname"], "Real Name")
self.assertEqual(info["title"], "A title")
self.assertEqual(info["taken"], "2009-07-22 14:03:11")
self.assertEqual(info["posted"], "2009-07-23T10:40:00Z")
self.assertEqual(info["licence_id"], 5)
self.assertEqual(info["page"], "https://www.flickr.com/photos/12345678@N00/111/")
self.assertEqual(info["original_format"], "jpg")
def test_getsizes_normalises_numbers(self):
rb.OPEN = FakeOpen(FakeResponse(flickr_sizes(("Small", "240", "180", "https://x/s.jpg"))))
sizes = rb.getsizes("111", "k")
self.assertEqual(sizes[0]["width"], 240)
self.assertEqual(sizes[0]["label"], "Small")
def test_picksizes_prefers_original_then_area(self):
sizes = [
{"label": "Small", "width": 240, "height": 180, "source": "s", "media": "photo"},
{"label": "Large", "width": 1024, "height": 768, "source": "l", "media": "photo"},
{"label": "Original", "width": 800, "height": 600, "source": "o", "media": "photo"},
{"label": "Video Player", "width": 640, "height": 480, "source": "v", "media": "video"},
]
self.assertEqual([s["label"] for s in rb.picksizes(sizes)], ["Original", "Large", "Small"])
def test_picksizes_empty(self):
self.assertEqual(rb.picksizes([]), [])
def test_extensionfor(self):
self.assertEqual(rb.extensionfor("https://x/1_o.png", ""), ".png")
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_sidenodes_survives_any_shape(self):
self.assertEqual(rb.sidenodes({}), [])
self.assertEqual(rb.sidenodes({"rgl": "nonsense"}), [])
self.assertEqual(rb.sidenodes({"rgl": {"nodes": "nonsense"}}), [])
self.assertEqual(rb.sidenodes({"rgl": {"nodes": ["nonsense"]}}), [])
self.assertEqual(rb.sidenodes(sidecar())[0]["name"], "A, E1")
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$")
class Licences(TestCase):
def test_label_from_url(self):
self.assertEqual(rb.licencelabel("https://creativecommons.org/licenses/by-nc-sa/2.0/deed.en_GB"), "CC BY-NC-SA 2.0")
self.assertEqual(rb.licencelabel("https://creativecommons.org/licenses/by/2.0/"), "CC BY 2.0")
self.assertEqual(rb.licencelabel("http://creativecommons.org/publicdomain/zero/1.0/"), "CC0 1.0")
self.assertIsNone(rb.licencelabel(""))
self.assertIsNone(rb.licencelabel("https://example.com/terms"))
def test_slug(self):
self.assertEqual(rb.licenceslug("CC BY-NC-SA 2.0"), "cc-by-nc-sa-2.0")
self.assertEqual(rb.licenceslug("All rights reserved"), "all-rights-reserved")
def test_rgl_licence_wins(self):
side = sidecar()
self.assertEqual(rb.licencefor(side),
("CC BY-SA 2.0", "https://creativecommons.org/licenses/by-sa/2.0/", "rgl"))
def test_flickr_fills_a_gap(self):
side = sidecar(licence="")
label, url, origin = rb.licencefor(side)
self.assertEqual((label, origin), ("CC BY-SA 2.0", "flickr"))
self.assertIn("by-sa/2.0", url)
def test_a_licence_url_we_cannot_name_beats_flickr(self):
side = sidecar(licence="https://example.com/our-own-terms")
self.assertEqual(rb.licencefor(side),
(None, "https://example.com/our-own-terms", "wiki"))
self.assertIn("Licence: https://example.com/our-own-terms (from the wiki)",
rb.describeblock(side))
self.assertNotIn("from Flickr", rb.describeblock(side))
self.assertIn("rgl/licence/unknown", rb.tagsfor(side))
def test_unknown_when_neither(self):
side = sidecar(licence="")
side["flickr"]["licence_id"] = None
self.assertEqual(rb.licencefor(side), (None, None, None))
def test_flickr_table_covers_published_ids(self):
self.assertEqual(rb.FLICKR_LICENCES[0][0], "All rights reserved")
self.assertEqual(rb.FLICKR_LICENCES[4][0], "CC BY 2.0")
self.assertEqual(rb.FLICKR_LICENCES[9][0], "CC0 1.0")
self.assertEqual(rb.FLICKR_LICENCES[10][0], "Public Domain Mark 1.0")
class Describing(TestCase):
def test_block_shape(self):
block = rb.describeblock(sidecar())
self.assertEqual(block.splitlines(), [
rb.MARKER,
"Photo: A title",
"Source: https://www.flickr.com/photos/12345678@N00/111/",
"Photographer: P",
"Licence: CC BY-SA 2.0 https://creativecommons.org/licenses/by-sa/2.0/",
"Used on:",
" A, E1 https://london.randomness.org.uk/wiki.cgi?A%2C_E1",
])
def test_photographer_falls_back_to_flickr(self):
side = sidecar(copyright="")
self.assertEqual(rb.photographer(side), "Real Name")
side["flickr"]["owner_realname"] = ""
self.assertEqual(rb.photographer(side), "handle")
def test_licence_from_flickr_is_marked_and_disagreement_noted(self):
side = sidecar(licence="")
self.assertIn("Licence: CC BY-SA 2.0 https://creativecommons.org/licenses/by-sa/2.0/ (from Flickr)",
rb.describeblock(side))
side = sidecar(licence="https://creativecommons.org/licenses/by-nc-sa/2.0/")
block = rb.describeblock(side)
self.assertIn("Licence: CC BY-NC-SA 2.0 https://creativecommons.org/licenses/by-nc-sa/2.0/", block)
self.assertIn("Note: Flickr lists this photo as CC BY-SA 2.0", block)
def test_no_licence_says_unknown(self):
side = sidecar(licence="")
side["flickr"]["licence_id"] = None
self.assertIn("Licence: unknown", rb.describeblock(side))
def test_non_flickr_photo(self):
side = sidecar(flickr=False)
block = rb.describeblock(side)
self.assertNotIn("Photo:", block)
self.assertIn("Source: https://www.geograph.org.uk/photos/1.jpg", block)
def test_several_pages(self):
side = sidecar(nodes=[
{"param": "A", "name": "A", "url": "https://x/A", "lat": "", "long": ""},
{"param": "B", "name": "B", "url": "https://x/B", "lat": "", "long": ""}])
self.assertTrue(rb.describeblock(side).endswith("Used on:\n A https://x/A\n B https://x/B"))
class Merging(TestCase):
block = "%s\nPhoto: x\nUsed on:\n A https://x/A" % rb.MARKER
def test_empty_or_boilerplate_is_written(self):
self.assertEqual(rb.mergedescription("", self.block), (self.block, "write"))
self.assertEqual(rb.mergedescription("OLYMPUS DIGITAL CAMERA", self.block), (self.block, "write"))
def test_existing_text_is_kept_above(self):
text, action = rb.mergedescription("My caption", self.block)
self.assertEqual(action, "append")
self.assertEqual(text, "My caption\n\n" + self.block)
def test_same_block_is_quiet(self):
held = "My caption\n\n" + self.block
self.assertEqual(rb.mergedescription(held, self.block), (held, "same"))
def test_changed_block_is_replaced_in_place(self):
held = "My caption\n\n" + self.block + "\n\nTrailing note"
newblock = self.block + "\n B https://x/B"
text, action = rb.mergedescription(held, newblock)
self.assertEqual(action, "replace")
self.assertEqual(text, "My caption\n\n" + newblock + "\n\nTrailing note")
def test_isboilerplate(self):
self.assertTrue(rb.isboilerplate("SONY DSC"))
self.assertFalse(rb.isboilerplate("The 100 Club"))
class Tags(TestCase):
def test_tags(self):
self.assertEqual(rb.tagsfor(sidecar()),
["rgl", "rgl/licence/cc-by-sa-2.0", "rgl/photographer/P"])
def test_unknown_licence_and_blank_photographer(self):
side = sidecar(licence="", copyright="")
side["flickr"]["licence_id"] = None
side["flickr"]["owner_realname"] = ""
side["flickr"]["owner_name"] = ""
self.assertEqual(rb.tagsfor(side), ["rgl", "rgl/licence/unknown"])
def test_photographer_whitespace_and_slashes(self):
side = sidecar(copyright=" Some / One ")
self.assertIn("rgl/photographer/Some - One", rb.tagsfor(side))
class FetchPhoto(TestCase):
def group(self, key="111", nodes=None):
nodes = nodes or [{"param": "A,_E1", "name": "A, E1",
"url": "https://london.randomness.org.uk/wiki.cgi?A%2C_E1",
"image": "https://farm1.static.flickr.com/1/111_a_m.jpg",
"page": "https://www.flickr.com/photos/x/111/",
"copyright": "P", "licence": "https://creativecommons.org/licenses/by-sa/2.0/",
"lat": "51.5", "long": "-0.1"}]
return {"key": key, "source": "flickr", "id": key, "image": nodes[0]["image"],
"page": nodes[0]["page"], "copyright": "P",
"licence": "https://creativecommons.org/licenses/by-sa/2.0/", "nodes": nodes}
def test_fetches_largest_and_writes_sidecar(self):
rb.OPEN = FakeOpen(
FakeResponse(flickr_info("111")),
FakeResponse(flickr_sizes(("Small", 240, 180, "https://x/111_s.jpg"),
("Original", 800, 600, "https://x/111_o.png"))),
FakeResponse(b"PNGDATA", headers={"Content-Type": "image/png"}))
side, action = rb.fetchphoto(self.dir, self.group(), "k")
self.assertEqual(action, "fetched")
self.assertEqual(side["file"], "111.png")
self.assertEqual(side["flickr"]["size_label"], "Original")
self.assertEqual(side["flickr"]["width"], 800)
self.assertEqual(side["rgl"]["nodes"][0]["param"], "A,_E1")
self.assertNotIn("image", side["rgl"]["nodes"][0])
self.assertTrue(rb.iscomplete(self.dir, "111"))
self.assertEqual(open(os.path.join(self.dir, "111.png"), "rb").read(), b"PNGDATA")
self.assertFalse(os.path.exists(os.path.join(self.dir, "111.part")))
def test_falls_back_when_largest_404s(self):
rb.OPEN = FakeOpen(
FakeResponse(flickr_info("111")),
FakeResponse(flickr_sizes(("Large", 1024, 768, "https://x/111_b.jpg"),
("Original", 800, 600, "https://x/111_o.jpg"))),
http_error(404),
FakeResponse(b"JPG", headers={"Content-Type": "image/jpeg"}))
side, action = rb.fetchphoto(self.dir, self.group(), "k")
self.assertEqual(side["flickr"]["size_label"], "Large")
self.assertIn("111_o.jpg", rb.OPEN.requests[2].full_url)
def test_gone_photo_raises_and_writes_nothing(self):
rb.OPEN = FakeOpen(FakeResponse({"stat": "fail", "code": 1, "message": "Photo not found"}))
with self.assertRaises(rb.PhotoGone):
rb.fetchphoto(self.dir, self.group(), "k")
self.assertEqual(os.listdir(self.dir), [])
def test_complete_photo_is_skipped_without_calls(self):
rb.writejson(rb.sidecarpath(self.dir, "111"), sidecar())
self.write("111.jpg", b"x")
rb.OPEN = FakeOpen()
side, action = rb.fetchphoto(self.dir, self.group(), "k")
self.assertEqual(action, "skipped")
def test_changed_nodes_rewrite_sidecar_only(self):
rb.writejson(rb.sidecarpath(self.dir, "111"), sidecar())
self.write("111.jpg", b"x")
rb.OPEN = FakeOpen()
g = self.group()
g["nodes"].append(dict(g["nodes"][0], param="B,_E2", name="B, E2", url="https://x/B"))
side, action = rb.fetchphoto(self.dir, g, "k")
self.assertEqual(action, "updated")
self.assertEqual(len(rb.readsidecar(rb.sidecarpath(self.dir, "111"))["rgl"]["nodes"]), 2)
self.assertEqual(rb.STATS["rewritten"], 1)
def test_nodes_are_sorted_by_param(self):
rb.OPEN = FakeOpen(
FakeResponse(flickr_info("111")),
FakeResponse(flickr_sizes(("Original", 800, 600, "https://x/111_o.jpg"))),
FakeResponse(b"J", headers={"Content-Type": "image/jpeg"}))
g = self.group()
g["nodes"].insert(0, dict(g["nodes"][0], param="Z,_E9", name="Z, E9"))
side, action = rb.fetchphoto(self.dir, g, "k")
self.assertEqual([n["param"] for n in side["rgl"]["nodes"]], ["A,_E1", "Z,_E9"])
# and the same order the comparison uses, so a re-run rewrites nothing
self.assertEqual(rb.fetchphoto(self.dir, g, "k")[1], "skipped")
def test_downloadany_skips_an_empty_body(self):
rb.OPEN = FakeOpen(FakeResponse(b""),
FakeResponse(b"JPG", headers={"Content-Type": "image/jpeg"}))
candidates = [{"label": "Original", "width": 8, "height": 6, "source": "https://x/o.jpg",
"media": "photo"},
{"label": "Large", "width": 4, "height": 3, "source": "https://x/l.jpg",
"media": "photo"}]
partial = os.path.join(self.dir, "111.part")
pick, ext = rb.downloadany(candidates, partial, "photo 111")
self.assertEqual((pick["label"], ext), ("Large", ".jpg"))
def test_downloadany_with_nothing_but_empty_bodies_fails(self):
rb.OPEN = FakeOpen(FakeResponse(b""))
candidates = [{"label": "Original", "width": 8, "height": 6, "source": "https://x/o.jpg",
"media": "photo"}]
with self.assertRaises(rb.BackupError) as cm:
rb.downloadany(candidates, os.path.join(self.dir, "111.part"), "photo 111")
self.assertIn("empty", str(cm.exception))
def test_partial_file_is_refetched(self):
rb.writejson(rb.sidecarpath(self.dir, "111"), sidecar())
self.write("111.jpg", b"x")
self.write("111.part", b"half")
rb.OPEN = FakeOpen(FakeResponse(flickr_info("111")),
FakeResponse(flickr_sizes(("Original", 800, 600, "https://x/111_o.jpg"))),
FakeResponse(b"FULL", headers={"Content-Type": "image/jpeg"}))
side, action = rb.fetchphoto(self.dir, self.group(), "k")
self.assertEqual(action, "fetched")
self.assertEqual(open(os.path.join(self.dir, "111.jpg"), "rb").read(), b"FULL")
def test_sidecar_without_an_rgl_block_is_rewritten_not_fatal(self):
rb.writejson(rb.sidecarpath(self.dir, "111"), {"key": "111", "file": "111.jpg"})
self.write("111.jpg", b"x")
rb.OPEN = FakeOpen()
side, action = rb.fetchphoto(self.dir, self.group(), "k")
self.assertEqual(action, "updated")
self.assertEqual(len(rb.readsidecar(rb.sidecarpath(self.dir, "111"))["rgl"]["nodes"]), 1)
def test_dry_run_calls_nothing(self):
rb.OPEN = FakeOpen()
side, action = rb.fetchphoto(self.dir, self.group(), "k", dryrun=True)
self.assertEqual(action, "would fetch")
self.assertEqual(os.listdir(self.dir), [])
def test_non_flickr_photo_downloads_image_url(self):
g = self.group(key="url-0123456789abcdef")
g["source"] = "url"
del g["id"]
g["image"] = g["nodes"][0]["image"] = "https://www.geograph.org.uk/p/1.jpg"
g["page"] = "https://www.geograph.org.uk/photo/1"
rb.OPEN = FakeOpen(FakeResponse(b"JPG", headers={"Content-Type": "image/jpeg"}))
side, action = rb.fetchphoto(self.dir, g, "k")
self.assertEqual(side["source"], "url")
self.assertEqual(side["page"], "https://www.geograph.org.uk/photo/1")
self.assertNotIn("flickr", side)
self.assertEqual(side["file"], "url-0123456789abcdef.jpg")
class FetchPhase(TestCase):
def conf(self, **over):
conf = {"directory": self.dir, "index_url": "http://t/index", "limit": None,
"dry_run": False, "quiet": False}
conf.update(over)
return conf
def test_runs_over_index_and_collects_failures(self):
index = [index_entry("A,_E1", "https://farm1.static.flickr.com/1/111_a_m.jpg",
page="https://www.flickr.com/photos/x/111/"),
index_entry("B,_E2", "https://farm1.static.flickr.com/1/222_b_m.jpg",
page="https://www.flickr.com/photos/x/222/"),
index_entry("C,_E3", "junk")]
rb.OPEN = FakeOpen(
FakeResponse(json.dumps(index).encode()),
FakeResponse(flickr_info("111")),
FakeResponse(flickr_sizes(("Original", 8, 6, "https://x/111_o.jpg"))),
FakeResponse(b"J", headers={"Content-Type": "image/jpeg"}),
FakeResponse({"stat": "fail", "code": 1, "message": "Photo not found"}))
failures = rb.fetchphase(self.conf(), "k")
self.assertTrue(rb.iscomplete(self.dir, "111"))
self.assertEqual(rb.STATS["fetched"], 1)
self.assertEqual(len(failures), 2)
reasons = [f[2] for f in failures.items]
self.assertTrue(any("Photo not found" in r for r in reasons), reasons)
self.assertTrue(any("not a url" in r for r in reasons), reasons)
names = [n for f in failures.items for n in f[1]]
self.assertIn("B, E2", names)
def test_limit_stops_early(self):
index = [index_entry("A", "https://farm1.static.flickr.com/1/111_a_m.jpg"),
index_entry("B", "https://farm1.static.flickr.com/1/222_b_m.jpg")]
rb.OPEN = FakeOpen(
FakeResponse(json.dumps(index).encode()),
FakeResponse(flickr_info("111")),
FakeResponse(flickr_sizes(("Original", 8, 6, "https://x/111_o.jpg"))),
FakeResponse(b"J", headers={"Content-Type": "image/jpeg"}))
rb.fetchphase(self.conf(limit=1), "k")
self.assertEqual(rb.STATS["fetched"], 1)
self.assertFalse(os.path.exists(rb.sidecarpath(self.dir, "222")))
def test_dry_run_creates_no_directory(self):
room = os.path.join(self.dir, "not-yet")
rb.OPEN = FakeOpen(FakeResponse(json.dumps(
[index_entry("A", "https://farm1.static.flickr.com/1/111_a_m.jpg")]).encode()))
rb.fetchphase(self.conf(directory=room, dry_run=True), "k")
self.assertFalse(os.path.exists(room))
def test_index_failure_is_fatal(self):
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)
class PlanAsset(TestCase):
def asset(self, **over):
a = {"id": "a1", "name": "111.jpg", "description": "", "latitude": None,
"longitude": None, "taken": ""}
a.update(over)
return a
def test_fresh_asset_gets_everything(self):
fields = rb.planasset(sidecar(), self.asset())
self.assertEqual(fields["description"], rb.describeblock(sidecar()))
self.assertEqual((fields["latitude"], fields["longitude"]), (51.5, -0.1))
self.assertEqual(fields["dateTimeOriginal"], "2009-07-22T14:03:11Z")
def test_existing_values_are_kept(self):
a = self.asset(description="Mine\n\n" + rb.describeblock(sidecar()),
latitude=1.0, longitude=2.0, taken="2001-01-01T00:00:00.000Z")
self.assertEqual(rb.planasset(sidecar(), a), {})
def test_hand_written_description_is_appended_to(self):
fields = rb.planasset(sidecar(), self.asset(description="Mine", latitude=1.0, longitude=2.0, taken="t"))
self.assertEqual(list(fields), ["description"])
self.assertTrue(fields["description"].startswith("Mine\n\n"))
def test_bad_coordinates_are_skipped(self):
side = sidecar(nodes=[{"param": "A", "name": "A", "url": "u", "lat": "", "long": ""}])
fields = rb.planasset(side, self.asset())
self.assertNotIn("latitude", fields)
def test_flickrtaken(self):
self.assertEqual(rb.flickrtaken(sidecar()), "2009-07-22T14:03:11Z")
self.assertIsNone(rb.flickrtaken(sidecar(flickr=False)))
side = sidecar()
side["flickr"]["taken"] = "garbage"
self.assertIsNone(rb.flickrtaken(side))
def test_takenat(self):
path = self.write("111.jpg", b"x")
os.utime(path, (1000000000, 1000000000))
self.assertEqual(rb.takenat(sidecar(), path), "2009-07-22T14:03:11Z")
self.assertEqual(rb.takenat(sidecar(flickr=False), path), "2001-09-09T01:46:40Z")
side = sidecar()
side["flickr"]["taken"] = "garbage"
self.assertEqual(rb.takenat(side, path), "2001-09-09T01:46:40Z")
class SyncPhase(TestCase):
def setUp(self):
super().setUp()
rb.writejson(rb.sidecarpath(self.dir, "111"), sidecar())
self.write("111.jpg", b"NEW")
rb.writejson(rb.sidecarpath(self.dir, "222"), sidecar(key="222", file="222.jpg", copyright="Q"))
self.write("222.jpg", b"OLD")
self.old = rb.checksum(os.path.join(self.dir, "222.jpg"))
def conf(self, **over):
conf = {"directory": self.dir, "dry_run": False, "quiet": False, "limit": None}
conf.update(over)
return conf
def responses(self):
return [
FakeResponse({"id": "u1"}), # me
FakeResponse({"assets": {"items": [
{"id": "a2", "ownerId": "u1", "checksum": self.old,
"exifInfo": {"description": "Mine", "latitude": 1, "longitude": 2,
"dateTimeOriginal": "t"}}], "nextPage": None}}), # assets
FakeResponse({"id": "a1", "status": "created"}, status=201), # upload 111
FakeResponse(b""), # update a2 (append)
FakeResponse([]), # albums
FakeResponse({"id": "al1"}), # create album
FakeResponse({"assets": []}), # album assets
FakeResponse([{"success": True}, {"success": True}]), # add to album
FakeResponse([{"value": "rgl", "id": "t1"},
{"value": "rgl/licence/cc-by-sa-2.0", "id": "t2"},
{"value": "rgl/photographer/P", "id": "t3"},
{"value": "rgl/photographer/Q", "id": "t4"}]), # tags
FakeResponse([{"success": True}, {"success": True}]), # t1
FakeResponse([{"success": True}, {"success": True}]), # t2
FakeResponse([{"success": True}]), # t3
FakeResponse([{"success": True}]), # t4
FakeResponse({"id": "a1", "originalFileName": "111.jpg",
"exifInfo": {}}), # re-read a1
FakeResponse(b""), # update a1
]
def test_uploads_new_reuses_existing_and_annotates(self):
rb.OPEN = FakeOpen(*self.responses())
failures = rb.syncphase(self.conf(), rb.Immich("https://i/api", "k"))
self.assertEqual(len(failures), 0)
self.assertEqual(rb.STATS["uploaded"], 1)
self.assertEqual(rb.STATS["existing"], 1)
reqs = rb.OPEN.requests
self.assertTrue(reqs[2].full_url.endswith("/assets"))
upd2 = json.loads(reqs[3].data)
self.assertEqual(list(upd2), ["description"])
self.assertTrue(upd2["description"].startswith("Mine\n\n"))
self.assertEqual(json.loads(reqs[5].data)["albumName"], "RGL")
self.assertEqual(sorted(json.loads(reqs[7].data)["ids"]), ["a1", "a2"])
# the uploaded asset is annotated last, against what immich now holds
self.assertEqual((reqs[13].get_method(), reqs[13].full_url),
("GET", "https://i/api/assets/a1"))
upd1 = json.loads(reqs[14].data)
self.assertIn("latitude", upd1)
self.assertEqual(upd1["description"], rb.describeblock(sidecar()))
self.assertTrue(os.path.exists(os.path.join(self.dir, rb.HASHES)))
def test_duplicate_upload_appends_to_what_immich_already_holds(self):
rb.OPEN = FakeOpen(
FakeResponse({"id": "u1"}),
FakeResponse({"assets": {"items": [], "nextPage": None}}),
FakeResponse({"id": "a1", "status": "duplicate"}), # upload 111
FakeResponse([{"albumName": "RGL", "id": "al1", "ownerId": "u1"}]),
FakeResponse({"assets": []}),
FakeResponse([{"success": True}]),
FakeResponse([{"value": "rgl", "id": "t1"},
{"value": "rgl/licence/cc-by-sa-2.0", "id": "t2"},
{"value": "rgl/photographer/P", "id": "t3"}]),
FakeResponse([{"success": True}]),
FakeResponse([{"success": True}]),
FakeResponse([{"success": True}]),
FakeResponse({"id": "a1", "originalFileName": "mine.jpg",
"exifInfo": {"description": "My own words",
"latitude": 1, "longitude": 2,
"dateTimeOriginal": "2001-01-01T00:00:00.000Z"}}),
FakeResponse(b""))
failures = rb.syncphase(self.conf(limit=1), rb.Immich("https://i/api", "k"))
self.assertEqual(len(failures), 0)
self.assertEqual(rb.STATS["uploaded"], 0)
self.assertEqual(rb.STATS["existing"], 1)
last = rb.OPEN.requests[-1]
self.assertEqual((last.get_method(), last.full_url),
("PUT", "https://i/api/assets/a1"))
body = json.loads(last.data)
self.assertEqual(list(body), ["description"])
self.assertTrue(body["description"].startswith("My own words\n\n"))
def test_a_repeated_checksum_is_uploaded_once(self):
self.write("222.jpg", b"NEW") # byte-identical to 111.jpg
rb.OPEN = FakeOpen(
FakeResponse({"id": "u1"}),
FakeResponse({"assets": {"items": [], "nextPage": None}}),
FakeResponse({"id": "a1", "status": "created"}, status=201), # upload 111
FakeResponse([{"albumName": "RGL", "id": "al1", "ownerId": "u1"}]),
FakeResponse({"assets": [{"id": "a1"}]}),
FakeResponse([{"value": "rgl", "id": "t1"},
{"value": "rgl/licence/cc-by-sa-2.0", "id": "t2"},
{"value": "rgl/photographer/Q", "id": "t3"}]),
FakeResponse([{"success": True}]),
FakeResponse([{"success": True}]),
FakeResponse([{"success": True}]),
FakeResponse({"id": "a1", "exifInfo": {}}), # re-read a1
FakeResponse(b""))
failures = rb.syncphase(self.conf(), rb.Immich("https://i/api", "k"))
self.assertEqual(len(failures), 0)
uploads = [r for r in rb.OPEN.requests if r.full_url.endswith("/api/assets")]
self.assertEqual(len(uploads), 1)
self.assertEqual(rb.STATS["uploaded"], 1)
self.assertEqual(rb.STATS["existing"], 1)
def test_second_run_is_quiet(self):
block1 = rb.describeblock(sidecar())
block2 = rb.describeblock(sidecar(key="222", file="222.jpg", copyright="Q"))
new = rb.checksum(os.path.join(self.dir, "111.jpg"))
rb.OPEN = FakeOpen(
FakeResponse({"id": "u1"}),
FakeResponse({"assets": {"items": [
{"id": "a1", "ownerId": "u1", "checksum": new,
"exifInfo": {"description": block1, "latitude": 51.5, "longitude": -0.1, "dateTimeOriginal": "t"}},
{"id": "a2", "ownerId": "u1", "checksum": self.old,
"exifInfo": {"description": "Mine\n\n" + block2, "latitude": 1, "longitude": 2, "dateTimeOriginal": "t"}}],
"nextPage": None}}),
FakeResponse([{"albumName": "RGL", "id": "al1", "ownerId": "u1"}]),
FakeResponse({"assets": [{"id": "a1"}, {"id": "a2"}]}),
FakeResponse([{"value": "rgl", "id": "t1"}, {"value": "rgl/licence/cc-by-sa-2.0", "id": "t2"},
{"value": "rgl/photographer/P", "id": "t3"}, {"value": "rgl/photographer/Q", "id": "t4"}]),
FakeResponse([{"error": "duplicate"}, {"error": "duplicate"}]),
FakeResponse([{"error": "duplicate"}, {"error": "duplicate"}]),
FakeResponse([{"error": "duplicate"}]),
FakeResponse([{"error": "duplicate"}]))
failures = rb.syncphase(self.conf(), rb.Immich("https://i/api", "k"))
self.assertEqual(len(failures), 0)
self.assertEqual(rb.STATS["uploaded"], 0)
self.assertEqual(rb.STATS["updated"], 0)
def test_dry_run_only_reads(self):
rb.OPEN = FakeOpen(FakeResponse({"id": "u1"}),
FakeResponse({"assets": {"items": [], "nextPage": None}}),
FakeResponse([]))
rb.syncphase(self.conf(dry_run=True), rb.Immich("https://i/api", "k"))
self.assertEqual([r.get_method() for r in rb.OPEN.requests], ["GET", "POST", "GET"])
self.assertIn("would upload", self.stdout.getvalue())
self.assertFalse(os.path.exists(os.path.join(self.dir, rb.HASHES)))
def test_dry_run_limit_counts_what_it_looked_at(self):
rb.OPEN = FakeOpen(FakeResponse({"id": "u1"}),
FakeResponse({"assets": {"items": [], "nextPage": None}}),
FakeResponse([]))
rb.syncphase(self.conf(dry_run=True, limit=1), rb.Immich("https://i/api", "k"))
self.assertEqual(self.stdout.getvalue().count("would upload"), 1)
def test_unsupported_file_is_a_failure_not_a_stop(self):
rb.OPEN = FakeOpen(
FakeResponse({"id": "u1"}),
FakeResponse({"assets": {"items": [], "nextPage": None}}),
http_error(400, b'{"message":"unsupported file type"}'), # upload 111
FakeResponse({"id": "a2", "status": "created"}, status=201), # upload 222
FakeResponse([]), FakeResponse({"id": "al1"}), FakeResponse({"assets": []}),
FakeResponse([{"success": True}]),
FakeResponse([{"value": "rgl", "id": "t1"}, {"value": "rgl/licence/cc-by-sa-2.0", "id": "t2"},
{"value": "rgl/photographer/Q", "id": "t4"}]),
FakeResponse([{"success": True}]), FakeResponse([{"success": True}]), FakeResponse([{"success": True}]),
FakeResponse({"id": "a2", "exifInfo": {}}), # re-read a2
FakeResponse(b"")) # update a2
failures = rb.syncphase(self.conf(), rb.Immich("https://i/api", "k"))
self.assertEqual(len(failures), 1)
self.assertIn("unsupported", failures.items[0][2])
self.assertEqual(rb.STATS["uploaded"], 1)
def test_malformed_sidecar_is_a_failure_not_a_stop(self):
room = os.path.join(self.dir, "other")
os.mkdir(room)
rb.writejson(os.path.join(room, "333.json"), {"key": "333"})
rb.OPEN = FakeOpen(
FakeResponse({"id": "u1"}),
FakeResponse({"assets": {"items": [], "nextPage": None}}),
FakeResponse([]), FakeResponse({"id": "al1"}), FakeResponse({"assets": []}))
failures = rb.syncphase(self.conf(directory=room), rb.Immich("https://i/api", "k"))
self.assertEqual(len(failures), 1)
self.assertIn("no file", failures.items[0][2])
def test_bad_key_is_fatal_before_writes(self):
rb.OPEN = FakeOpen(http_error(401))
with self.assertRaises(rb.BackupError):
rb.syncphase(self.conf(), rb.Immich("https://i/api", "k"))
def test_album_stage_failure_is_recorded(self):
rb.ATTEMPTS = 1
rb.OPEN = FakeOpen(
FakeResponse({"id": "u1"}),
FakeResponse({"assets": {"items": [], "nextPage": None}}),
FakeResponse({"id": "a1", "status": "created"}, status=201), # upload 111
http_error(500), # albums
FakeResponse({"id": "a1", "exifInfo": {}}), # re-read a1
FakeResponse(b"")) # update a1
failures = rb.syncphase(self.conf(limit=1), rb.Immich("https://i/api", "k"))
self.assertEqual(len(failures), 1)
reason = failures.items[0][2]
self.assertTrue("albums" in reason or "HTTP 500" in reason)