v0.8.1: improve XMP sidecars
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled

This commit is contained in:
Ein Anderssono
2026-06-15 01:36:04 +02:00
parent fffb30023b
commit 9cd702628d
8 changed files with 188 additions and 15 deletions
+75 -2
View File
@@ -25,6 +25,8 @@ var (
mkdirTempFunc = os.MkdirTemp
createTempFunc = os.CreateTemp
writeFileFunc = os.WriteFile
readFileFunc = os.ReadFile
statFunc = os.Stat
renameFunc = os.Rename
openFileFunc = os.OpenFile
removeFunc = os.Remove
@@ -165,6 +167,7 @@ COMMANDS
verify --out <dir> [--manifest jsonl|sqlite]
Verify that manifest entries point to files that exist on disk. Missing
files are printed as <asset-id><TAB><filename>. Exits 2 on missing files.
Add --sidecar to verify expected XMP sidecars too.
retry-failed --out <dir>
Retry assets previously written to failures.jsonl.
@@ -752,6 +755,7 @@ type xmpSidecarData struct {
ExportedFilename string
Album string
AlbumPath string
Keywords []string
ManifestPath string
MediaType string
MediaSubtypes []string
@@ -843,6 +847,7 @@ func sidecarPath(exportedPath string) string {
func renderXMP(d xmpSidecarData) []byte {
attrs := []struct{ key, val string }{
{"photoscli:xmpSchemaVersion", "2"},
{"photoscli:assetID", d.AssetID},
{"photoscli:originalFilename", d.OriginalFilename},
{"photoscli:exportedFilename", d.ExportedFilename},
@@ -866,9 +871,18 @@ func renderXMP(d xmpSidecarData) []byte {
{"photoscli:exportedAt", d.ExportedAt},
{"photoscli:size", fmt.Sprintf("%d", d.Size)},
{"dc:title", d.ExportedFilename},
{"xmp:MetadataDate", d.ExportedAt},
{"photoscli:sidecarGeneratedAt", d.ExportedAt},
{"photoscli:sidecarGenerator", "photoscli " + d.PhotoscliVersion},
}
if d.IsFavorite {
attrs = append(attrs, struct{ key, val string }{"xmp:Rating", "5"})
}
if d.CreateDate != "" {
attrs = append(attrs, struct{ key, val string }{"xmp:CreateDate", d.CreateDate})
attrs = append(attrs,
struct{ key, val string }{"xmp:CreateDate", d.CreateDate},
struct{ key, val string }{"photoshop:DateCreated", d.CreateDate},
)
}
if d.ModifyDate != "" {
attrs = append(attrs, struct{ key, val string }{"xmp:ModifyDate", d.ModifyDate})
@@ -879,6 +893,9 @@ func renderXMP(d xmpSidecarData) []byte {
struct{ key, val string }{"photoscli:longitude", fmt.Sprintf("%.8f", d.Location.Longitude)},
struct{ key, val string }{"photoscli:altitude", fmt.Sprintf("%.3f", d.Location.Altitude)},
struct{ key, val string }{"photoscli:horizontalAccuracy", fmt.Sprintf("%.3f", d.Location.HorizontalAccuracy)},
struct{ key, val string }{"exif:GPSLatitude", fmt.Sprintf("%.8f", d.Location.Latitude)},
struct{ key, val string }{"exif:GPSLongitude", fmt.Sprintf("%.8f", d.Location.Longitude)},
struct{ key, val string }{"exif:GPSAltitude", fmt.Sprintf("%.3f", d.Location.Altitude)},
)
}
if d.Placemark != nil {
@@ -906,7 +923,7 @@ func renderXMP(d xmpSidecarData) []byte {
sb.WriteString("<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n")
sb.WriteString("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n")
sb.WriteString(" <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n")
sb.WriteString(" <rdf:Description xmlns:photoscli=\"https://gitea.k3s.k0.nu/tools/photocli/ns/1.0/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\"")
sb.WriteString(" <rdf:Description xmlns:photoscli=\"https://gitea.k3s.k0.nu/tools/photocli/ns/1.0/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" xmlns:exif=\"http://ns.adobe.com/exif/1.0/\" xmlns:photoshop=\"http://ns.adobe.com/photoshop/1.0/\"")
for _, a := range attrs {
sb.WriteString("\n ")
sb.WriteString(a.key)
@@ -915,6 +932,7 @@ func renderXMP(d xmpSidecarData) []byte {
sb.WriteString("\"")
}
sb.WriteString(" >\n")
writeStringSeq(&sb, "dc:subject", d.Keywords)
writeStringSeq(&sb, "photoscli:mediaSubtypes", d.MediaSubtypes)
writeStringSeq(&sb, "photoscli:burstSelectionTypes", d.BurstSelectionTypes)
writeStringSeq(&sb, "photoscli:areasOfInterest", func() []string {
@@ -931,6 +949,24 @@ func renderXMP(d xmpSidecarData) []byte {
return []byte(sb.String())
}
func keywordsFromAlbumPath(album, albumPath string) []string {
seen := map[string]bool{}
var out []string
add := func(v string) {
v = strings.TrimSpace(v)
if v == "" || v == "." || seen[v] {
return
}
seen[v] = true
out = append(out, v)
}
add(album)
for _, part := range strings.Split(filepath.ToSlash(albumPath), "/") {
add(part)
}
return out
}
func writeStringSeq(sb *strings.Builder, name string, vals []string) {
if len(vals) == 0 {
return
@@ -999,6 +1035,10 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
if err != nil || strings.HasPrefix(relPath, "..") {
relPath = result.Filename
}
relDir, err := filepath.Rel(root, pa.path)
if err != nil || strings.HasPrefix(relDir, "..") || relDir == "." {
relDir = ""
}
createDate := ""
if pa.asset.CreationDate != nil {
createDate = *pa.asset.CreationDate
@@ -1017,6 +1057,7 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
ExportedFilename: result.Filename,
Album: pa.album,
AlbumPath: pa.path,
Keywords: keywordsFromAlbumPath(pa.album, relDir),
ManifestPath: relPath,
MediaType: pa.asset.MediaType,
MediaSubtypes: pa.asset.MediaSubtypes,
@@ -1963,6 +2004,7 @@ func cmdDiff(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int
func cmdVerify(args []string, stdout, stderr io.Writer) int {
outDir := flagVal(args, "--out")
checkSidecar := hasFlag(args, "--sidecar")
if outDir == "" {
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
@@ -2001,6 +2043,9 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int {
bad++
fmt.Fprintf(stdout, "%s\t%s\tsize-mismatch\tmanifest=%d\tdisk=%d\n", id, checkPath, e.Size, info.Size())
}
if checkSidecar {
bad += verifySidecar(stdout, outDir, id, checkPath)
}
}
if bad > 0 {
return exitPartial
@@ -2009,6 +2054,34 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int {
return exitOK
}
func verifySidecar(stdout io.Writer, outDir, id, checkPath string) int {
xmpPath := sidecarPath(filepath.Join(outDir, checkPath))
rel, err := filepath.Rel(outDir, xmpPath)
if err != nil || strings.HasPrefix(rel, "..") {
rel = xmpPath
}
info, err := statFunc(xmpPath)
if err != nil {
fmt.Fprintf(stdout, "%s\t%s\tsidecar-missing\n", id, rel)
return 1
}
if info.Size() == 0 {
fmt.Fprintf(stdout, "%s\t%s\tsidecar-zero-byte\n", id, rel)
return 1
}
data, err := readFileFunc(xmpPath)
if err != nil {
fmt.Fprintf(stdout, "%s\t%s\tsidecar-unreadable\n", id, rel)
return 1
}
needle := `photoscli:assetID="` + id + `"`
if !strings.Contains(string(data), needle) {
fmt.Fprintf(stdout, "%s\t%s\tsidecar-asset-mismatch\n", id, rel)
return 1
}
return 0
}
func cmdRetryFailed(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
outDir := flagVal(args, "--out")
clearOnSuccess := hasFlag(args, "--clear-on-success")
+66 -1
View File
@@ -3835,6 +3835,13 @@ func TestReportDiffVerifyRetryFailedAndConfig(t *testing.T) {
if rc != exitOK || !strings.Contains(out, "verified") || stderr != "" {
t.Fatalf("verify rc=%d out=%q stderr=%q", rc, out, stderr)
}
if err := os.WriteFile(filepath.Join(dir, "file.xmp"), renderXMP(xmpSidecarData{AssetID: "x1", ExportedFilename: "file.jpg"}), 0644); err != nil {
t.Fatal(err)
}
out, stderr, rc = runWith([]string{"verify", "--out", dir, "--sidecar"}, &mockBridge{})
if rc != exitOK || !strings.Contains(out, "verified") || stderr != "" {
t.Fatalf("verify sidecar rc=%d out=%q stderr=%q", rc, out, stderr)
}
b := &mockBridge{albums: []photos.Album{{ID: "a1", Title: "Album"}}, assets: []photos.Asset{{ID: "x1", Filename: "file.jpg"}, {ID: "x2", Filename: "missing.jpg"}}}
out, _, rc = runWith([]string{"diff", "--album-id", "Album", "--out", dir}, b)
@@ -4133,6 +4140,8 @@ func TestMoreIntegrityBranches(t *testing.T) {
}
m.AddEntry(manifest.Entry{ID: "zero", Filename: "zero.jpg", Path: "zero.jpg", Size: 1, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "mismatch", Filename: "mismatch.jpg", Path: "mismatch.jpg", Size: 10, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "nosidecar", Filename: "nosidecar.jpg", Path: "nosidecar.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "zerosidecar", Filename: "zerosidecar.jpg", Path: "zerosidecar.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.Close()
if err := os.WriteFile(filepath.Join(dir, "zero.jpg"), nil, 0644); err != nil {
t.Fatal(err)
@@ -4140,10 +4149,30 @@ func TestMoreIntegrityBranches(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "mismatch.jpg"), []byte("bad"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "nosidecar.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "zerosidecar.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
out, _, rc := runWith([]string{"verify", "--out", dir}, &mockBridge{})
if rc != exitPartial || !strings.Contains(out, "zero-byte") || !strings.Contains(out, "size-mismatch") {
t.Fatalf("verify rc=%d out=%q", rc, out)
}
if err := os.WriteFile(filepath.Join(dir, "mismatch.xmp"), []byte("wrong asset"), 0644); err != nil {
t.Fatal(err)
}
out, _, rc = runWith([]string{"verify", "--out", dir, "--sidecar"}, &mockBridge{})
if rc != exitPartial || !strings.Contains(out, "sidecar-missing") || !strings.Contains(out, "sidecar-asset-mismatch") {
t.Fatalf("verify sidecar failures rc=%d out=%q", rc, out)
}
if err := os.WriteFile(filepath.Join(dir, "zerosidecar.xmp"), nil, 0644); err != nil {
t.Fatal(err)
}
out, _, rc = runWith([]string{"verify", "--out", dir, "--sidecar"}, &mockBridge{})
if rc != exitPartial || !strings.Contains(out, "sidecar-zero-byte") {
t.Fatalf("verify zero sidecar rc=%d out=%q", rc, out)
}
_, stderr, rc := runWith([]string{"status"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "--out") {
t.Fatalf("status missing out rc=%d stderr=%q", rc, stderr)
@@ -4162,6 +4191,25 @@ func TestMoreIntegrityBranches(t *testing.T) {
}
}
func TestVerifySidecarBranches(t *testing.T) {
dir := t.TempDir()
subdir := filepath.Join(dir, "sub")
if err := os.Mkdir(subdir, 0755); err != nil {
t.Fatal(err)
}
xmp := filepath.Join(dir, "asset.xmp")
if err := os.WriteFile(xmp, []byte(`photoscli:assetID="x1"`), 0644); err != nil {
t.Fatal(err)
}
oldRead := readFileFunc
readFileFunc = func(string) ([]byte, error) { return nil, fmt.Errorf("read") }
var out bytes.Buffer
if got := verifySidecar(&out, subdir, "x1", "../asset.jpg"); got != 1 || !strings.Contains(out.String(), "sidecar-unreadable") {
t.Fatalf("expected unreadable with rel fallback, got=%d out=%q", got, out.String())
}
readFileFunc = oldRead
}
func TestXMPSidecarHelpers(t *testing.T) {
if got := sidecarPath("/tmp/IMG_0001.HEIC"); got != "/tmp/IMG_0001.xmp" {
t.Fatalf("sidecar path = %q", got)
@@ -4172,6 +4220,7 @@ func TestXMPSidecarHelpers(t *testing.T) {
ExportedFilename: "IMG_0001.jpg",
Album: "A&B",
AlbumPath: "/tmp/A&B",
Keywords: []string{"A&B", "Trips"},
ManifestPath: "A&B/IMG_0001.jpg",
MediaType: "image",
MediaSubtypes: []string{"photoLive", `hdr&<>"`},
@@ -4198,13 +4247,29 @@ func TestXMPSidecarHelpers(t *testing.T) {
AdjustmentInfo: &photos.AdjustmentInfo{FormatIdentifier: "com.apple", FormatVersion: "1.0", BaseFilename: "base.heic"},
Resources: []photos.AssetResource{{Type: "photo", Filename: `res&.heic`, UTI: "public.heic", Local: true, Size: 99}},
}))
for _, want := range []string{"photoscli:assetID=\"id&amp;&lt;&gt;&#34;\"", "photoscli:isFavorite=\"true\"", "photoscli:hasAdjustments=\"true\"", "xmp:ModifyDate=\"2024-02-01T00:00:00Z\"", "photoscli:latitude=\"59.32930000\"", "photoscli:addressCountry=\"Sweden\"", "photoscli:adjustmentFormatIdentifier=\"com.apple\"", "<photoscli:resources><rdf:Seq>", "photoscli:resourceFilename=\"res&amp;.heic\"", "<photoscli:mediaSubtypes><rdf:Seq>"} {
for _, want := range []string{"photoscli:xmpSchemaVersion=\"2\"", "photoscli:assetID=\"id&amp;&lt;&gt;&#34;\"", "photoscli:isFavorite=\"true\"", "xmp:Rating=\"5\"", "photoscli:hasAdjustments=\"true\"", "xmp:ModifyDate=\"2024-02-01T00:00:00Z\"", "photoshop:DateCreated=\"2024-01-01T00:00:00Z\"", "exif:GPSLatitude=\"59.32930000\"", "photoscli:latitude=\"59.32930000\"", "photoscli:addressCountry=\"Sweden\"", "photoscli:adjustmentFormatIdentifier=\"com.apple\"", "<dc:subject><rdf:Seq>", "<rdf:li>A&amp;B</rdf:li>", "<photoscli:resources><rdf:Seq>", "photoscli:resourceFilename=\"res&amp;.heic\"", "<photoscli:mediaSubtypes><rdf:Seq>"} {
if !strings.Contains(xmp, want) {
t.Fatalf("XMP missing %q in %s", want, xmp)
}
}
}
func TestKeywordsFromAlbumPath(t *testing.T) {
got := keywordsFromAlbumPath("Album", "Trips/Album/2024")
want := []string{"Album", "Trips", "2024"}
if len(got) != len(want) {
t.Fatalf("keywords len=%d want=%d: %#v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("keywords[%d]=%q want %q in %#v", i, got[i], want[i], got)
}
}
if got := keywordsFromAlbumPath("", "."); len(got) != 0 {
t.Fatalf("expected no dot keyword, got %#v", got)
}
}
func TestWriteXMPSidecar(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "photo.xmp")