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
+1
View File
@@ -11,4 +11,5 @@
- Keep README concise; put practical user workflows in `USERGUIDE.md`.
- Manifest backends: JSONL default, SQLite optional via `modernc.org/sqlite`.
- Preserve manifest compatibility and migration behavior.
- XMP sidecars are opt-in via `--sidecar xmp`; default must remain `none`.
- Do not commit generated artifacts from `bin/` or coverage files.
+11
View File
@@ -2,6 +2,17 @@
This changelog is maintained from git history plus the published Gitea release series. Future releases should update this file and publish matching release notes on the release page.
## v0.7.0
XMP sidecar metadata release.
- Add `--sidecar none|xmp` with default `none`.
- Add config support for `sidecar = "xmp"`.
- Write XMP sidecars next to exported files using basename `.xmp` filenames.
- Include asset ID, filenames, album, manifest path, media type, dimensions, favorite state, cloud state, export mode, photoscli version, exported timestamp, size, and creation date in XMP.
- Write XMP files atomically.
- Treat sidecar write failure as asset failure when XMP sidecars are explicitly requested.
## v0.6.0
Backup integrity and recovery release.
+1 -1
View File
@@ -1,6 +1,6 @@
BINARY := ./bin/photoscli
MODULE := gitea.k3s.k0.nu/tools/photocli
VERSION := 0.6.0
VERSION := 0.7.0
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
RELEASE_NOTES := RELEASE_NOTES.md
BRIDGE_DIR := bridge
+19
View File
@@ -21,6 +21,7 @@ For a practical step-by-step manual with recommended backup workflows, recovery
- Deduplicated failure management with `failures list`, `failures clear`, and `retry-failed --clear-on-success`.
- Verification, reporting, and diff commands for backup integrity.
- Status command for quick backup summaries.
- Opt-in XMP sidecar metadata with `--sidecar xmp`.
- Script-friendly exit codes and optional JSON summaries.
- 100% test coverage for the Go CLI and parsing layers.
@@ -276,6 +277,7 @@ Common flags for `export` and `backup-all`:
- `--min-size <n>`: filter by estimated pixel count.
- `--max-size <n>`: filter by estimated pixel count.
- `--format jpeg|heic|png`: preview format hint. Current bridge output is still the existing preview path; non-JPEG bridge output is future work.
- `--sidecar none|xmp`: write opt-in XMP metadata sidecars next to exported files.
- `--date-template <template>`: append date folders based on creation date, for example `YYYY/MM/DD`.
`backup-all` also supports:
@@ -328,6 +330,23 @@ With SQLite manifest mode, logs are written to the `logs` table in `downloads.db
Logged events include session start/end, completed exports, skipped assets, and failures. Each event includes timestamp, level, event name, asset ID, album, filename, size, cloud state, duration, and message where available.
## XMP Sidecars
Write archival metadata sidecars with:
```bash
photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp
```
Sidecars are opt-in and use the exported file basename:
```text
IMG_0001.jpg -> IMG_0001.xmp
IMG_0001.HEIC -> IMG_0001.xmp
```
The XMP contains photoscli metadata such as asset ID, filenames, album, manifest path, media type, dimensions, favorite state, cloud state, export mode, version, exported timestamp, size, and creation date when available. If `--sidecar xmp` is explicitly selected and the sidecar cannot be written, that asset is treated as failed.
## Failure Tracking
Failed exports are deduplicated by asset ID and stored in:
+9 -18
View File
@@ -1,29 +1,20 @@
# v0.6.0
# v0.7.0
This release focuses on backup integrity, recovery workflows, and clearer operational status.
This release adds opt-in XMP sidecar metadata for archival exports.
## Highlights
- Manifest entries now include relative paths, so tree backups can be verified accurately.
- SQLite manifests migrate automatically to include the new path column.
- `verify` now detects missing files, zero-byte files, and size mismatches by default.
- Exports use per-asset staging directories and rename completed files into place when possible.
- Failure tracking is deduplicated by asset ID and records attempts and latest error details.
- New `failures list` and `failures clear` commands.
- `retry-failed --clear-on-success` removes successfully retried failures.
- New `status` command with text and JSON output.
- Release workflow now publishes these release notes to the release page.
## Recommended Upgrade Notes
- Existing JSONL manifests continue to load; entries without `path` fall back to `filename`.
- Existing SQLite manifests are migrated with `ALTER TABLE downloads ADD COLUMN path ...` during open.
- `verify` is stricter now and may report problems that older versions ignored.
- Add `--sidecar none|xmp` with default `none`.
- Write XMP sidecars next to exported files when `--sidecar xmp` is selected.
- XMP files use the exported file basename, for example `IMG_0001.jpg` -> `IMG_0001.xmp`.
- Sidecars include photoscli metadata such as asset ID, filenames, album, manifest path, media type, dimensions, favorite state, cloud state, export mode, version, exported timestamp, size, and creation date when available.
- XMP writes are atomic and fail the asset when explicitly requested sidecar output cannot be written.
- Config files can set `sidecar = "xmp"`.
## Assets
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
- `photoscli-0.6.0-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
- `photoscli-0.7.0-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
- `USERGUIDE.md`: standalone user guide.
Intel Macs are not currently a supported release target.
+21
View File
@@ -22,6 +22,7 @@ It is especially useful when you want to:
- Verify that files referenced by a manifest exist on disk.
- Detect missing, zero-byte, and size-mismatched manifest files.
- Inspect or clear deduplicated failure records.
- Write optional XMP sidecar metadata for archival workflows.
- Script Photos exports with stable exit codes.
It is not intended to replace Apple Photos, iCloud Photos, or Time Machine. Think of it as an additional file-based export and backup tool.
@@ -538,6 +539,25 @@ Supported tokens:
Assets without parseable creation dates stay in the base output path.
## XMP Sidecars
Use XMP sidecars when you want portable metadata next to exported files:
```bash
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp
```
Sidecars are disabled by default. When enabled, they use the exported file basename:
```text
IMG_0001.jpg -> IMG_0001.xmp
IMG_0001.HEIC -> IMG_0001.xmp
```
The XMP includes photoscli archive metadata such as asset ID, original filename, exported filename, album, manifest path, media type, dimensions, favorite state, cloud state, export mode, version, exported time, size, and creation date when available.
If you explicitly request `--sidecar xmp` and the XMP file cannot be written, the asset is counted as failed.
## Configuration File
You can store default values in:
@@ -557,6 +577,7 @@ sort = "newest"
media = "photos"
retry = 3
log = true
sidecar = "xmp"
```
Use a custom config path:
+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"))