Build the immich description block and tags from a sidecar

Immich has no copyright or source fields, so the photographer, licence,
flickr page and every wiki page using the photo go into a marked block in
the description. The marker lets a later run find and replace only that
block, leaving anything written by hand alone. Tags mirror licence and
photographer for filtering.

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 f7884b943e
commit 2774267425
2 files changed
+259

No files matched your search

+124
View File
@@ -655,5 +655,129 @@ def listsidecars(directory):
return found return found
# flickr's published licence ids
FLICKR_LICENCES = {
0: ("All rights reserved", ""),
1: ("CC BY-NC-SA 2.0", "https://creativecommons.org/licenses/by-nc-sa/2.0/"),
2: ("CC BY-NC 2.0", "https://creativecommons.org/licenses/by-nc/2.0/"),
3: ("CC BY-NC-ND 2.0", "https://creativecommons.org/licenses/by-nc-nd/2.0/"),
4: ("CC BY 2.0", "https://creativecommons.org/licenses/by/2.0/"),
5: ("CC BY-SA 2.0", "https://creativecommons.org/licenses/by-sa/2.0/"),
6: ("CC BY-ND 2.0", "https://creativecommons.org/licenses/by-nd/2.0/"),
7: ("No known copyright restrictions", "https://www.flickr.com/commons/usage/"),
8: ("United States Government Work", "https://www.usa.gov/government-works"),
9: ("CC0 1.0", "https://creativecommons.org/publicdomain/zero/1.0/"),
10: ("Public Domain Mark 1.0", "https://creativecommons.org/publicdomain/mark/1.0/"),
}
CC_PATH = re.compile(r"creativecommons\.org/(licenses/([a-z-]+)|publicdomain/(zero|mark))/(\d+\.\d+)")
def licencelabel(url):
found = CC_PATH.search(url or "")
if not found:
return None
if found.group(2):
return "CC %s %s" % (found.group(2).upper(), found.group(4))
if found.group(3) == "zero":
return "CC0 %s" % found.group(4)
return "Public Domain Mark %s" % found.group(4)
def licenceslug(label):
return re.sub(r"[^a-z0-9.]+", "-", label.lower()).strip("-")
#
# The wiki's licence url states the terms it uses the photo under, so it wins,
# named or not: quoting a url we cannot label is honest, while falling through
# to flickr's licence would state terms the wiki never claimed. Flickr's id
# fills the gap only when the wiki has nothing at all.
#
def licencefor(side):
url = (side.get("rgl") or {}).get("licence") or ""
label = licencelabel(url)
if label:
return (label, url, "rgl")
if url:
return (None, url, "wiki")
ident = (side.get("flickr") or {}).get("licence_id")
if ident in FLICKR_LICENCES:
label, url = FLICKR_LICENCES[ident]
return (label, url, "flickr")
return (None, None, None)
def photographer(side):
name = (side.get("rgl") or {}).get("copyright") or ""
flickr = side.get("flickr") or {}
return re.sub(r"\s+", " ", name or flickr.get("owner_realname") or flickr.get("owner_name") or "").strip()
def describeblock(side):
flickr = side.get("flickr") or {}
lines = [MARKER]
if flickr.get("title"):
lines.append("Photo: %s" % flickr["title"])
source = flickr.get("page") or side.get("page") or ""
if source:
lines.append("Source: %s" % source)
who = photographer(side)
if who:
lines.append("Photographer: %s" % who)
label, url, origin = licencefor(side)
if label:
line = "Licence: %s %s" % (label, url) if url else "Licence: %s" % label
if origin == "flickr":
line += " (from Flickr)"
lines.append(line)
if origin == "rgl" and flickr.get("licence_id") in FLICKR_LICENCES:
flabel = FLICKR_LICENCES[flickr["licence_id"]][0]
if flabel != label:
lines.append("Note: Flickr lists this photo as %s" % flabel)
elif origin == "wiki":
lines.append("Licence: %s (from the wiki)" % url)
else:
lines.append("Licence: unknown")
lines.append("Used on:")
for node in (side.get("rgl") or {}).get("nodes") or []:
lines.append(" %s %s" % (node.get("name", ""), node.get("url", "")))
return "\n".join(lines)
# Descriptions cameras and old software write by themselves; these count as
# empty rather than as something worth protecting. Same list as immich-sync.
BOILERPLATE = re.compile(r"""^\s*(?:
olympus\ digital\ camera | sony\ dsc | digital\ camera | exif_?jpeg
| picasa | dcim | camera | photo | image | untitled | default
| no\ description | ola
)\s*$""", re.I | re.X)
def isboilerplate(text):
return bool(BOILERPLATE.match(text or ""))
#
# Where the block sits in a description: from the marker line to the end of
# the "Used on" list, which is every following line that starts with two
# spaces. Anything after that was written by someone else and is kept.
#
BLOCK = re.compile(r"(?ms)^" + re.escape(MARKER) + r"$.*?^Used on:$(?:\n [^\n]*)*")
def mergedescription(held, block):
held = (held or "").strip()
if not held or isboilerplate(held):
return (block, "write")
found = BLOCK.search(held)
if not found:
return (held + "\n\n" + block, "append")
if found.group(0) == block:
return (held, "same")
return (held[:found.start()] + block + held[found.end():], "replace")
def tagsfor(side):
tags = [TAG_ROOT]
label = licencefor(side)[0]
tags.append("%s/licence/%s" % (TAG_ROOT, licenceslug(label) if label else "unknown"))
who = photographer(side).replace("/", " - ")
who = re.sub(r"\s+", " ", who).strip()
if who:
tags.append("%s/photographer/%s" % (TAG_ROOT, who))
return tags
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(0) sys.exit(0)
+135
View File
@@ -601,3 +601,138 @@ class Sidecars(TestCase):
def test_utcnow_shape(self): def test_utcnow_shape(self):
self.assertRegex(rb.utcnow(), r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$") 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))