v0.8.1: improve XMP sidecars
This commit is contained in:
+75
-2
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user