Changelog
A simple photo gallery browser. The bleeding edge currently supports loading image sidecars, in cases where one prefers not to make direct file edits.
Main metadata loader is found here. These generally follow the Dublin Core namespace for XMP - see RDF/XML specification (base format) and Adobe's XMP specifications for a nice table of expected tags (<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 4.4.0-Exiv2">
).
These are the highest priority tags processed by pigallery2:
metadata.keywords = exif.dc.subject; metadata.title = exif.dc?.title?.value; metadata.caption = exif.dc?.description?.value; metadata.creationDate = exif.DateTimeOriginalmetadata.creationDate; metadata.rating = exif.xmp.Rating;
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:dc="http://purl.org/dc/elements/1.1/"> <rdf:Description rdf:about=""> <dc:title> <rdf:Alt> <rdf:li xml:lang="x-default">TITLE</rdf:li> </rdf:Alt> </dc:title> <dc:description> <rdf:Alt> <rdf:li xml:lang="x-default">CAPTION</rdf:li> </rdf:Alt> </dc:description> <dc:subject> <rdf:Bag> <rdf:li>TAG1</rdf:li> <rdf:li>TAG2</rdf:li> </rdf:Bag> </dc:subject> <xmp:Rating>5</xmp:Rating> </rdf:Description> </rdf:RDF>
This can be programmatically generated, e.g.
#!/usr/bin/env python3 # Create pigallery2-compatible XMP metadata files # Justin, 2025-08-23 from dataclasses import dataclass, field from lxml import etree from lxml.builder import ElementMaker # Define namespaces and element builders XML = "http://www.w3.org/XML/1998/namespace" RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#" DC = "http://purl.org/dc/elements/1.1/" XMP = "http://ns.adobe.com/xap/1.0/" nsmap = {"xml": XML, "rdf": RDF, "dc": DC, "xmp": XMP} lang = {"{%s}lang" % XML: "x-default"} rdf = ElementMaker(namespace=RDF, nsmap=nsmap) xmp = ElementMaker(namespace=XMP, nsmap=nsmap) dc = ElementMaker(namespace=DC, nsmap=nsmap) @dataclass class Metadata: title: str = None caption: str | None = None rating: int | None = None keywords: list[str] = field(default_factory=list) def write(self, filename="metadata.xmp"): ast = [] if self.title: ast.append(dc.title(rdf.Alt(rdf.li(self.title, **lang)))) if self.caption: ast.append(dc.description(rdf.Alt(rdf.li(self.caption, **lang)))) if self.keywords: nodes = [rdf.li(kw) for kw in self.keywords if kw.strip() != ""] ast.append(dc.subject(rdf.Bag(*nodes))) if self.rating and 0 <= self.rating <= 5: ast.append(xmp.Rating(str(self.rating))) # Commit file if AST not empty if ast: tree = rdf.RDF(rdf.Description(*ast, **{"{%s}about" % RDF: ""})) with open(filename, "wb") as f: f.write(etree.tostring(tree, pretty_print=True)) @staticmethod def read(filename): """ References: [1]: <https://stackoverflow.com/a/40796315> """ with open(filename, "rb") as f: tree = etree.parse(f) ns = {"namespaces": nsmap} title = None e = tree.xpath("//dc:title/rdf:Alt/rdf:li/text()", **ns) if len(e) > 0 and (_title := e[0].strip()) != "": title = _title caption = None e = tree.xpath("//dc:description/rdf:Alt/rdf:li/text()", **ns) if len(e) > 0 and (_caption := e[0].strip()) != "": caption = _caption keywords = tree.xpath("//dc:subject/rdf:Bag/rdf:li/text()", **ns) rating = None e = tree.xpath("//xmp:Rating/text()", **ns) try: if len(e) > 0 and 0 <= (_rating := int(e[0])) <= 5: rating = _rating except ValueError: pass return Metadata(title, caption, rating, keywords) Metadata("TITLE", "CAPTION", 5, ["TAG1", "TAG2"]).write()
Searches for FILE.xmp
, followed by any .xmp
file found in any parent directory.
--- /app/src/backend/model/fileaccess/MetadataLoader.js.55a61ed +++ /app/src/backend/model/fileaccess/MetadataLoader.js @@ -101,20 +101,20 @@ metadata.creationDate = metadata.creationDate || 0; try { // search for sidecar and merge metadata - const fullPathWithoutExt = path.join(path.parse(fullPath).dir, path.parse(fullPath).name); - const sidecarPaths = [ - fullPath + '.xmp', - fullPath + '.XMP', - fullPathWithoutExt + '.xmp', - fullPathWithoutExt + '.XMP', - ]; - for (const sidecarPath of sidecarPaths) { + var sidecarPath = fullPath + '.xmp'; + for (let i = 0; i < 4; i++) { if (fs.existsSync(sidecarPath)) { const sidecarData = await exifr.sidecar(sidecarPath); if (sidecarData !== undefined) { MetadataLoader.mapMetadata(metadata, sidecarData); + break; } } + if (fullPath == '/app/data/images') { + break; + } + fullPath = path.dirname(fullPath); + sidecarPath = fullPath + '/metadata.xmp'; } } catch (err) { @@ -205,14 +205,9 @@ } try { // search for sidecar and merge metadata - const fullPathWithoutExt = path.join(path.parse(fullPath).dir, path.parse(fullPath).name); - const sidecarPaths = [ - fullPath + '.xmp', - fullPath + '.XMP', - fullPathWithoutExt + '.xmp', - fullPathWithoutExt + '.XMP', - ]; - for (const sidecarPath of sidecarPaths) { + var sidecarPath = fullPath + '.xmp'; + for (let i = 0; i < 4; i++) { if (fs.existsSync(sidecarPath)) { const sidecarData = await exifr.sidecar(sidecarPath, exifrOptions); if (sidecarData !== undefined) { @@ -221,6 +216,10 @@ break; } } + if (fullPath == '/app/data/images') { + break; + } + fullPath = path.dirname(fullPath); + sidecarPath = fullPath + '/metadata.xmp'; } } catch (err)
Note that this is not the directory metadata feature request.