v0.7.0: add XMP sidecars
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled

This commit is contained in:
Ein Anderssono
2026-06-15 00:57:13 +02:00
parent 36832060d0
commit 4fe4c15adf
8 changed files with 413 additions and 23 deletions
+158 -4
View File
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"encoding/xml"
"fmt"
"io"
"os"
@@ -22,6 +23,8 @@ var (
configValues map[string]string
configLoaded bool
mkdirTempFunc = os.MkdirTemp
createTempFunc = os.CreateTemp
writeFileFunc = os.WriteFile
renameFunc = os.Rename
openFileFunc = os.OpenFile
removeFunc = os.Remove
@@ -35,6 +38,7 @@ type exportOptions struct {
jsonOut bool
verify bool
format string
sidecar string
minSize int64
maxSize int64
dateTemplate string
@@ -208,6 +212,10 @@ COMMON EXPORT FLAGS
--verify
Run manifest/file verification after export or backup-all.
--sidecar none|xmp
Write opt-in XMP sidecar metadata next to each exported file. Default:
none. If XMP writing fails, the asset is counted as failed.
FILTERING AND SELECTION
--since <date>
Include only assets on or after a date. Accepts YYYY-MM-DD or RFC3339.
@@ -731,6 +739,133 @@ func addManifestEntry(m manifest.Manifest, pa pendingAsset, result photos.Export
m.AddEntry(manifest.NewEntry(pa.asset.ID, result.Filename, relPath, result.Size, result.Cloud))
}
type xmpSidecarData struct {
AssetID string
OriginalFilename string
ExportedFilename string
Album string
AlbumPath string
ManifestPath string
MediaType string
PixelWidth int
PixelHeight int
IsFavorite bool
Cloud string
ExportMode string
PhotoscliVersion string
ExportedAt string
Size int64
CreateDate string
}
func sidecarPath(exportedPath string) string {
ext := filepath.Ext(exportedPath)
return strings.TrimSuffix(exportedPath, ext) + ".xmp"
}
func renderXMP(d xmpSidecarData) []byte {
attrs := []struct{ key, val string }{
{"photoscli:assetID", d.AssetID},
{"photoscli:originalFilename", d.OriginalFilename},
{"photoscli:exportedFilename", d.ExportedFilename},
{"photoscli:album", d.Album},
{"photoscli:albumPath", d.AlbumPath},
{"photoscli:manifestPath", d.ManifestPath},
{"photoscli:mediaType", d.MediaType},
{"photoscli:pixelWidth", fmt.Sprintf("%d", d.PixelWidth)},
{"photoscli:pixelHeight", fmt.Sprintf("%d", d.PixelHeight)},
{"photoscli:isFavorite", fmt.Sprintf("%t", d.IsFavorite)},
{"photoscli:cloud", d.Cloud},
{"photoscli:exportMode", d.ExportMode},
{"photoscli:photoscliVersion", d.PhotoscliVersion},
{"photoscli:exportedAt", d.ExportedAt},
{"photoscli:size", fmt.Sprintf("%d", d.Size)},
{"dc:title", d.ExportedFilename},
}
if d.CreateDate != "" {
attrs = append(attrs, struct{ key, val string }{"xmp:CreateDate", d.CreateDate})
}
var sb strings.Builder
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/\"")
for _, a := range attrs {
sb.WriteString("\n ")
sb.WriteString(a.key)
sb.WriteString("=\"")
xml.EscapeText(&sb, []byte(a.val))
sb.WriteString("\"")
}
sb.WriteString(" />\n")
sb.WriteString(" </rdf:RDF>\n")
sb.WriteString("</x:xmpmeta>\n")
sb.WriteString("<?xpacket end=\"w\"?>\n")
return []byte(sb.String())
}
func writeXMPSidecar(path string, data xmpSidecarData) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
f, err := createTempFunc(filepath.Dir(path), ".*.xmp.tmp")
if err != nil {
return err
}
tmp := f.Name()
_ = f.Close()
if err := writeFileFunc(tmp, renderXMP(data), 0644); err != nil {
os.Remove(tmp)
return err
}
if err := renameFunc(tmp, path); err != nil {
os.Remove(tmp)
return err
}
return nil
}
func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals bool, opts exportOptions) error {
if opts.sidecar != "xmp" {
return nil
}
mode := "preview"
if originals {
mode = "original"
}
root := pa.root
if root == "" {
root = pa.path
}
fullPath := filepath.Join(pa.path, result.Filename)
relPath, err := filepath.Rel(root, fullPath)
if err != nil || strings.HasPrefix(relPath, "..") {
relPath = result.Filename
}
createDate := ""
if pa.asset.CreationDate != nil {
createDate = *pa.asset.CreationDate
}
return writeXMPSidecar(sidecarPath(fullPath), xmpSidecarData{
AssetID: pa.asset.ID,
OriginalFilename: pa.asset.Filename,
ExportedFilename: result.Filename,
Album: pa.album,
AlbumPath: pa.path,
ManifestPath: relPath,
MediaType: pa.asset.MediaType,
PixelWidth: pa.asset.PixelWidth,
PixelHeight: pa.asset.PixelHeight,
IsFavorite: pa.asset.IsFavorite,
Cloud: result.Cloud,
ExportMode: mode,
PhotoscliVersion: version,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Size: result.Size,
CreateDate: createDate,
})
}
func collectPendingAssets(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, onProgress func(collectProgress), m manifest.Manifest, sortNewest bool, exclude []string, since time.Time, opts exportOptions) ([]pendingAsset, int) {
var items []pendingAsset
var skipped int
@@ -904,8 +1039,15 @@ func exportPendingSerial(pending []pendingAsset, targetSize, quality int, origin
} else if isSkipped {
addManifestEntry(m, pa, result)
} else {
done++
addManifestEntry(m, pa, result)
if sidecarErr := writeSidecarIfNeeded(pa, result, originals, opts); sidecarErr != nil {
failed++
exportErr = sidecarErr
isErr = true
appendFailure(pa.path, pa, sidecarErr)
} else {
done++
addManifestEntry(m, pa, result)
}
}
avgSpeed := float64(0)
if totalDur > 0 {
@@ -1033,8 +1175,15 @@ func exportPendingParallel(pending []pendingAsset, targetSize, quality int, orig
} else if isSkipped {
addManifestEntry(m, entry.pa, entry.result)
} else {
done++
addManifestEntry(m, entry.pa, entry.result)
if sidecarErr := writeSidecarIfNeeded(entry.pa, entry.result, originals, opts); sidecarErr != nil {
failed++
entry.err = sidecarErr
isErr = true
appendFailure(entry.pa.path, entry.pa, sidecarErr)
} else {
done++
addManifestEntry(m, entry.pa, entry.result)
}
}
avgSpeed := float64(0)
if totalDur > 0 {
@@ -1381,6 +1530,7 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
jsonOut: hasFlag(args, "--json"),
verify: hasFlag(args, "--verify"),
format: flagValWithDefault(args, "--format", "jpeg"),
sidecar: flagValWithDefault(args, "--sidecar", "none"),
dateTemplate: flagVal(args, "--date-template"),
}
if opts.media != "photos" && opts.media != "videos" && opts.media != "all" {
@@ -1391,6 +1541,10 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
fmt.Fprintf(stderr, "error: --format must be jpeg, heic, or png, got %q\n", opts.format)
return opts, false
}
if opts.sidecar != "none" && opts.sidecar != "xmp" {
fmt.Fprintf(stderr, "error: --sidecar must be none or xmp, got %q\n", opts.sidecar)
return opts, false
}
if v := flagVal(args, "--retry"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
+193
View File
@@ -4155,6 +4155,199 @@ func TestMoreIntegrityBranches(t *testing.T) {
}
}
func TestXMPSidecarHelpers(t *testing.T) {
if got := sidecarPath("/tmp/IMG_0001.HEIC"); got != "/tmp/IMG_0001.xmp" {
t.Fatalf("sidecar path = %q", got)
}
xmp := string(renderXMP(xmpSidecarData{
AssetID: `id&<>"`,
OriginalFilename: "IMG_0001.HEIC",
ExportedFilename: "IMG_0001.jpg",
Album: "A&B",
AlbumPath: "/tmp/A&B",
ManifestPath: "A&B/IMG_0001.jpg",
MediaType: "image",
PixelWidth: 10,
PixelHeight: 20,
IsFavorite: true,
Cloud: "local",
ExportMode: "preview",
PhotoscliVersion: "test",
ExportedAt: "2026-01-01T00:00:00Z",
Size: 123,
CreateDate: "2024-01-01T00:00:00Z",
}))
for _, want := range []string{"photoscli:assetID=\"id&amp;&lt;&gt;&#34;\"", "photoscli:isFavorite=\"true\"", "xmp:CreateDate=\"2024-01-01T00:00:00Z\""} {
if !strings.Contains(xmp, want) {
t.Fatalf("XMP missing %q in %s", want, xmp)
}
}
}
func TestWriteXMPSidecar(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "photo.xmp")
if err := writeXMPSidecar(path, xmpSidecarData{AssetID: "x1", ExportedFilename: "photo.jpg"}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "photoscli:assetID=\"x1\"") {
t.Fatalf("unexpected xmp: %s", string(data))
}
badParent := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(badParent, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if err := writeXMPSidecar(filepath.Join(badParent, "bad.xmp"), xmpSidecarData{}); err == nil {
t.Fatal("expected mkdir error")
}
}
func TestSidecarExportIntegration(t *testing.T) {
dir := t.TempDir()
date := "2024-01-02T03:04:05Z"
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "orig&.HEIC", MediaType: "image", PixelWidth: 10, PixelHeight: 20, IsFavorite: true, CreationDate: &date}}}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "photo.jpg"), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "photo.jpg", Size: 4, Cloud: "local"}, nil
}
exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "Album", false, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
if exported != 1 || failed != 0 {
t.Fatalf("exported=%d failed=%d", exported, failed)
}
data, err := os.ReadFile(filepath.Join(dir, "photo.xmp"))
if err != nil {
t.Fatal(err)
}
content := string(data)
for _, want := range []string{"photoscli:assetID=\"x1\"", "photoscli:originalFilename=\"orig&amp;.HEIC\"", "photoscli:album=\"Album\"", "xmp:CreateDate=\"2024-01-02T03:04:05Z\""} {
if !strings.Contains(content, want) {
t.Fatalf("sidecar missing %q in %s", want, content)
}
}
if _, err := os.Stat(filepath.Join(dir, "photo.jpg.xmp")); !os.IsNotExist(err) {
t.Fatal("sidecar should use basename, not double extension")
}
}
func TestSidecarConfigAndErrors(t *testing.T) {
oldConfigValues, oldConfigLoaded := configValues, configLoaded
defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }()
dir := t.TempDir()
cfg := filepath.Join(dir, "config.toml")
if err := os.WriteFile(cfg, []byte("sidecar = \"xmp\"\n"), 0644); err != nil {
t.Fatal(err)
}
t.Setenv("PHOTOSCLI_CONFIG", cfg)
configValues, configLoaded = nil, false
opts, ok := parseExportOptions(nil, io.Discard)
if !ok || opts.sidecar != "xmp" {
t.Fatalf("expected sidecar config, opts=%+v ok=%v", opts, ok)
}
var stderr bytes.Buffer
if _, ok := parseExportOptions([]string{"--sidecar", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") {
t.Fatalf("expected sidecar validation error, stderr=%q", stderr.String())
}
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
return photos.ExportResult{Filename: "photo.jpg", Size: 4}, nil
}
oldRename := renameFunc
renameFunc = func(string, string) error { return fmt.Errorf("sidecar rename") }
exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
renameFunc = oldRename
if exported != 0 || failed != 1 {
t.Fatalf("expected sidecar failure, exported=%d failed=%d", exported, failed)
}
}
func TestSidecarAdditionalBranches(t *testing.T) {
dir := t.TempDir()
oldCreateTemp := createTempFunc
createTempFunc = func(string, string) (*os.File, error) { return nil, fmt.Errorf("createtemp") }
if err := writeXMPSidecar(filepath.Join(dir, "bad.xmp"), xmpSidecarData{}); err == nil || !strings.Contains(err.Error(), "createtemp") {
t.Fatalf("expected create temp error, got %v", err)
}
createTempFunc = oldCreateTemp
oldWriteFile := writeFileFunc
writeFileFunc = func(string, []byte, os.FileMode) error { return fmt.Errorf("writefile") }
if err := writeXMPSidecar(filepath.Join(dir, "badwrite.xmp"), xmpSidecarData{}); err == nil || !strings.Contains(err.Error(), "writefile") {
t.Fatalf("expected write file error, got %v", err)
}
writeFileFunc = oldWriteFile
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "orig.HEIC"}, path: dir, album: "Album"}
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "out.jpg", Size: 1, Cloud: "local"}, true, exportOptions{sidecar: "xmp"}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "out.xmp"))
if err != nil {
t.Fatal(err)
}
content := string(data)
if !strings.Contains(content, "photoscli:exportMode=\"original\"") || !strings.Contains(content, "photoscli:manifestPath=\"out.jpg\"") {
t.Fatalf("unexpected sidecar: %s", content)
}
otherRoot := filepath.Join(dir, "other")
if err := writeSidecarIfNeeded(pendingAsset{asset: photos.Asset{ID: "x2"}, root: otherRoot, path: dir}, photos.ExportResult{Filename: "fallback.jpg"}, false, exportOptions{sidecar: "xmp"}); err != nil {
t.Fatal(err)
}
data, err = os.ReadFile(filepath.Join(dir, "fallback.xmp"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "photoscli:manifestPath=\"fallback.jpg\"") {
t.Fatalf("expected fallback manifest path, got %s", string(data))
}
}
func TestParallelSidecarExport(t *testing.T) {
dir := t.TempDir()
assets := []photos.Asset{
{ID: "x1", Filename: "one.jpg"},
{ID: "x2", Filename: "two.jpg"},
{ID: "x3", Filename: "three.jpg"},
{ID: "x4", Filename: "four.jpg"},
}
b := &mockBridge{assets: assets}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
name := fmt.Sprintf("%s.jpg", assetID)
if err := os.WriteFile(filepath.Join(out, name), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: name, Size: 4, Cloud: "local"}, nil
}
exported, failed := exportAssets(assets, dir, 1024, 85, 4, false, len(assets), io.Discard, b, "", true, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
if exported != 4 || failed != 0 {
t.Fatalf("exported=%d failed=%d", exported, failed)
}
if _, err := os.Stat(filepath.Join(dir, "x4.xmp")); err != nil {
t.Fatalf("expected parallel sidecar: %v", err)
}
}
func TestParallelSidecarFailure(t *testing.T) {
dir := t.TempDir()
assets := []photos.Asset{{ID: "x1", Filename: "one.jpg"}, {ID: "x2", Filename: "two.jpg"}, {ID: "x3", Filename: "three.jpg"}, {ID: "x4", Filename: "four.jpg"}}
b := &mockBridge{assets: assets}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
return photos.ExportResult{Filename: assetID + ".jpg", Size: 4, Cloud: "local"}, nil
}
oldWriteFile := writeFileFunc
writeFileFunc = func(string, []byte, os.FileMode) error { return fmt.Errorf("parallel sidecar") }
exported, failed := exportAssets(assets, dir, 1024, 85, 4, false, len(assets), io.Discard, b, "", true, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
writeFileFunc = oldWriteFile
if exported != 0 || failed != 4 {
t.Fatalf("expected sidecar failures, exported=%d failed=%d", exported, failed)
}
}
func TestRetryFailedClearOnSuccess(t *testing.T) {
dir := t.TempDir()
appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, root: dir, path: dir, album: "Album"}, fmt.Errorf("boom"))