feat(sidecar): port Node prototype to Go + Gin + GORM + MariaDB
Replace the Node prototype (server.mjs) with the stack the merge plan calls for: Go 1.25, Gin for routing, GORM + MariaDB for persistence. Same wire contract on /api/sidecar/* so the SvelteKit client doesn't change. - Marks move from a JSON file on disk to mule_sidecar.marks (auto- migrated by GORM on first boot). The Node prototype's marks.json was dev-only; not migrated. - Folder/rename/heap-convert/duplicates handlers reproduce the prototype's behaviour, including the path-traversal defence (resolveUnderRoot + EvalSymlinks), the size-bucket prefilter for the duplicate hasher, and the background reindex fire-and-forget pattern. - Auth model unchanged: requireSession middleware proxies the caller's X-Auth-Token to PhotoPrism's /api/v1/photos?count=1 before any destructive op. - Expose pp-mariadb on 127.0.0.1:3306 in docker-compose so the host Go process can reach mule_sidecar.* without joining the container network. - Archive the Node prototype under sidecar/legacy/server.mjs for one cycle as reference. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -72,6 +72,9 @@ docker-compose.override.yml
|
||||
# Sidecar runtime state (per-user marks etc.) — generated, not seed data.
|
||||
/sidecar/data/
|
||||
|
||||
# Sidecar Go build output.
|
||||
/sidecar/mule-sidecar
|
||||
|
||||
# Photos (for development)
|
||||
/photos/
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ services:
|
||||
MARIADB_USER: ${PP_DB_USER:-photoprism}
|
||||
MARIADB_PASSWORD: ${PP_DB_PASSWORD:?set PP_DB_PASSWORD in .env.photoprism}
|
||||
MARIADB_ROOT_PASSWORD: ${PP_DB_ROOT_PASSWORD:?set PP_DB_ROOT_PASSWORD in .env.photoprism}
|
||||
# Loopback-only host port so the mule-sidecar (running as a host process
|
||||
# in M4) can reach `mule_sidecar.*` over TCP. Not exposed beyond
|
||||
# 127.0.0.1; the photoprism container still resolves mariadb by service
|
||||
# name on the photoprism-network bridge.
|
||||
ports:
|
||||
- "127.0.0.1:${PP_DB_PORT:-3306}:3306"
|
||||
volumes:
|
||||
- pp_mariadb_data:/var/lib/mysql
|
||||
# The init script creates the mule_sidecar database + user that the Go
|
||||
|
||||
@@ -1,43 +1,96 @@
|
||||
# mule-sidecar
|
||||
|
||||
Auxiliary service that handles operations PhotoPrism's REST API does not expose.
|
||||
Go + Gin + GORM service for the endpoints PhotoPrism's REST API does not
|
||||
expose. Same wire contract as the M3 Node prototype it replaces; the
|
||||
SvelteKit client at [web/](../web/) talks to it transparently through
|
||||
Vite's `/api/sidecar/*` proxy.
|
||||
|
||||
## Why this exists
|
||||
## What it owns
|
||||
|
||||
Per the merge plan at `/home/dtoro/.claude/plans/i-want-you-to-twinkly-galaxy.md`,
|
||||
a Go + Gin + GORM service (matching PhotoPrism's stack) will eventually own:
|
||||
| Method | Path | Purpose |
|
||||
| ------ | --------------------------------------- | --------------------------------------------- |
|
||||
| GET | `/api/sidecar/healthz` | Unauthenticated liveness probe. |
|
||||
| GET | `/api/sidecar/photos/marks` | Every per-photo `{rating, color}` mark. |
|
||||
| GET | `/api/sidecar/photos/:uid/marks` | One photo's mark (or `{}` if none). |
|
||||
| PUT | `/api/sidecar/photos/:uid/marks` | Patch one photo's mark. |
|
||||
| POST | `/api/sidecar/photos/marks/bulk` | Stamp the same mark onto many photos. |
|
||||
| POST | `/api/sidecar/files/:uid/rename` | Rename the primary file on disk + reindex. |
|
||||
| POST | `/api/sidecar/folders` | Create a folder under `${ORIGINALS_ROOT}`. |
|
||||
| POST | `/api/sidecar/folders/:rel/rename` | Rename a folder (rel path URL-encoded). |
|
||||
| DELETE | `/api/sidecar/folders/:rel` | Delete an **empty** folder. |
|
||||
| POST | `/api/sidecar/albums/:uid/convert` | Move/copy every photo in a heap into folder X. |
|
||||
| GET | `/api/sidecar/duplicates/scan` | Walk originals, return same-hash groups. |
|
||||
| POST | `/api/sidecar/duplicates/archive` | Move duplicate paths into `.duplicates/<ts>/`. |
|
||||
|
||||
- Per-user heap sharing with pending invitations
|
||||
- Folder mutations under `originals/` (create / rename / delete / move)
|
||||
- **File rename** on disk (PhotoPrism's `OriginalName` is a display-only rename)
|
||||
Auth: every endpoint except `healthz` requires the caller's
|
||||
`X-Auth-Token` header. The sidecar holds no service credentials — it
|
||||
proxies the token straight back to PhotoPrism's `/api/v1/photos?count=1`
|
||||
to confirm the session is live before doing anything destructive.
|
||||
|
||||
The plan picks Go for stack consistency and the option to upstream features.
|
||||
Marks persist to **MariaDB** (`mule_sidecar.marks`); everything else
|
||||
operates on the filesystem under `${ORIGINALS_ROOT}` and triggers a
|
||||
PhotoPrism reindex of the affected parent in the background.
|
||||
|
||||
## What ships today
|
||||
## Build & run
|
||||
|
||||
A **Node.js prototype** (`server.mjs`) covering only the **file rename** endpoint.
|
||||
```sh
|
||||
cd sidecar
|
||||
go build -o mule-sidecar .
|
||||
|
||||
The decision to ship Node first is pragmatic — Go isn't installed on this dev
|
||||
box and `sudo dnf install golang` needs a password. Node is already on PATH for
|
||||
the SvelteKit dev server, so a single-file Node service unblocks the feature
|
||||
without changing the host setup.
|
||||
|
||||
The endpoint contract is stable: when M4 lands the proper Go service, the
|
||||
SvelteKit client keeps calling the same paths.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `POST /api/sidecar/files/:photoUid/rename` `{ "newName": "newfile.png" }`
|
||||
Renames the primary file of the photo on disk under `${ORIGINALS_ROOT}`,
|
||||
then triggers a PhotoPrism reindex of the parent path.
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
ORIGINALS_ROOT=/home/dtoro/projects/mule-image/photos-sample \
|
||||
ORIGINALS_ROOT=/path/to/photoprism/originals \
|
||||
PHOTOPRISM_BASE_URL=http://localhost:2342 \
|
||||
SIDECAR_PORT=8000 \
|
||||
node server.mjs
|
||||
./mule-sidecar
|
||||
```
|
||||
|
||||
The SvelteKit dev server proxies `/api/sidecar/*` to `http://localhost:8000`.
|
||||
The SvelteKit dev server proxies `/api/sidecar/*` to
|
||||
`http://localhost:8000`.
|
||||
|
||||
## Env
|
||||
|
||||
| Var | Default | Notes |
|
||||
| --------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `ORIGINALS_ROOT` | `/photoprism/originals` | Absolute path; must match PhotoPrism's mount. |
|
||||
| `PHOTOPRISM_BASE_URL` | `http://localhost:2342` | Where to reach PhotoPrism for session validation + reindex calls. |
|
||||
| `SIDECAR_PORT` | `8000` | Loopback-only; reverse-proxy fronts it in production. |
|
||||
| `SIDECAR_DSN` | _(built from the vars below)_ | Set this to override the assembled MySQL DSN entirely. |
|
||||
| `SIDECAR_DB_HOST` | `127.0.0.1` | Host of the MariaDB the compose stack publishes on `127.0.0.1:3306`. |
|
||||
| `SIDECAR_DB_PORT` | `3306` | |
|
||||
| `SIDECAR_DB_USER` | `sidecar` | Provisioned by [`mariadb/init/01-sidecar.sql`](../mariadb/init/01-sidecar.sql) on first boot. |
|
||||
| `SIDECAR_DB_PASSWORD` | `replace-at-m4-bringup` | Literal placeholder — **rotate before any non-local deployment**. |
|
||||
| `SIDECAR_DB_NAME` | `mule_sidecar` | |
|
||||
|
||||
## Schema
|
||||
|
||||
GORM `AutoMigrate` creates the only table the service owns:
|
||||
|
||||
```sql
|
||||
CREATE TABLE marks (
|
||||
photo_uid VARCHAR(64) PRIMARY KEY,
|
||||
rating BIGINT NULL,
|
||||
color VARCHAR(16) NULL,
|
||||
updated_at DATETIME(3)
|
||||
);
|
||||
```
|
||||
|
||||
The M3 Node prototype kept the same data in `sidecar/data/marks.json`.
|
||||
There is no migration path — the prototype's marks file was dev-only
|
||||
state. Heap-sharing tables (M4) will land in subsequent migrations.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
sidecar/
|
||||
├── main.go entrypoint, route wiring, graceful shutdown
|
||||
├── config.go env-driven Config
|
||||
├── db.go GORM open + Mark model + AutoMigrate
|
||||
├── auth.go requireSession middleware + ctxToken
|
||||
├── fs.go path safety, walk, sha1
|
||||
├── pp.go PhotoPrism HTTP client (validateSession, reindex)
|
||||
├── handlers_rename.go
|
||||
├── handlers_folders.go
|
||||
├── handlers_marks.go
|
||||
├── handlers_heap.go
|
||||
├── handlers_dups.go
|
||||
└── legacy/server.mjs Node prototype, retained for one cycle as a reference.
|
||||
```
|
||||
|
||||
44
sidecar/auth.go
Normal file
44
sidecar/auth.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// requireSession is the standard auth shim every mutating handler wears.
|
||||
// We don't store a shared service credential — the caller's X-Auth-Token
|
||||
// is the only authority, and we probe PhotoPrism with it before doing any
|
||||
// destructive work. The handler reads the validated token off the context
|
||||
// via ctxToken so it can keep forwarding it to PhotoPrism for the actual
|
||||
// operation.
|
||||
func requireSession(pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.GetHeader("X-Auth-Token")
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no token"})
|
||||
return
|
||||
}
|
||||
if !pp.validateSession(c.Request.Context(), token) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
|
||||
return
|
||||
}
|
||||
c.Set("token", token)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ctxToken returns the validated X-Auth-Token a previous requireSession
|
||||
// middleware stored on the request. Handlers MUST run behind that
|
||||
// middleware; otherwise this returns the empty string.
|
||||
func ctxToken(c *gin.Context) string {
|
||||
v, ok := c.Get("token")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
60
sidecar/config.go
Normal file
60
sidecar/config.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Config aggregates every runtime knob the sidecar reads from the
|
||||
// environment. Held in one struct so the rest of the package can take a
|
||||
// pointer instead of poking os.Getenv at use-sites.
|
||||
type Config struct {
|
||||
OriginalsRoot string // absolute path to PhotoPrism's originals dir
|
||||
PhotoprismBaseURL string // e.g. http://localhost:2342
|
||||
Port int // HTTP listen port (loopback only)
|
||||
DSN string // GORM/MySQL connection string for mule_sidecar
|
||||
}
|
||||
|
||||
func loadConfig() (*Config, error) {
|
||||
root := envOr("ORIGINALS_ROOT", "/photoprism/originals")
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
portStr := envOr("SIDECAR_PORT", "8000")
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dsn := os.Getenv("SIDECAR_DSN")
|
||||
if dsn == "" {
|
||||
// Default matches the user that mariadb/init/01-sidecar.sql provisions
|
||||
// on first boot. The literal placeholder password is intentional: the
|
||||
// SQL ships with it and an env-templating step is left for whoever
|
||||
// runs this in a non-local context.
|
||||
user := envOr("SIDECAR_DB_USER", "sidecar")
|
||||
pass := envOr("SIDECAR_DB_PASSWORD", "replace-at-m4-bringup")
|
||||
host := envOr("SIDECAR_DB_HOST", "127.0.0.1")
|
||||
dbPort := envOr("SIDECAR_DB_PORT", "3306")
|
||||
name := envOr("SIDECAR_DB_NAME", "mule_sidecar")
|
||||
dsn = user + ":" + pass + "@tcp(" + host + ":" + dbPort + ")/" + name +
|
||||
"?charset=utf8mb4&parseTime=true&loc=Local"
|
||||
}
|
||||
|
||||
return &Config{
|
||||
OriginalsRoot: abs,
|
||||
PhotoprismBaseURL: envOr("PHOTOPRISM_BASE_URL", "http://localhost:2342"),
|
||||
Port: port,
|
||||
DSN: dsn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
63
sidecar/db.go
Normal file
63
sidecar/db.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// Mark mirrors the per-photo extras the web client stores via the marks
|
||||
// endpoints — rating + four-colour label. PhotoUID is the row key; both
|
||||
// payload fields are nullable so the sparse "no rating / no colour" state
|
||||
// round-trips cleanly. The Node prototype kept this in a JSON file; we
|
||||
// migrate to MariaDB here so the M4 sharing work has a real table to
|
||||
// extend.
|
||||
type Mark struct {
|
||||
PhotoUID string `gorm:"primaryKey;size:64;column:photo_uid" json:"-"`
|
||||
Rating *int `gorm:"column:rating" json:"rating,omitempty"`
|
||||
Color *string `gorm:"size:16;column:color" json:"color,omitempty"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
// TableName pins the GORM-pluralised default to a name that matches the
|
||||
// other tables the M4 plan calls out (`marks`, `heap_shares`, …) so
|
||||
// nothing surprising lands in the schema.
|
||||
func (Mark) TableName() string { return "marks" }
|
||||
|
||||
// asJSON returns the wire shape clients expect — same flat object the
|
||||
// Node prototype emitted. An empty Mark (rating=nil, color=nil) renders
|
||||
// as `{}` which the client treats as "no mark on this photo".
|
||||
func (m *Mark) asJSON() map[string]any {
|
||||
out := map[string]any{}
|
||||
if m == nil {
|
||||
return out
|
||||
}
|
||||
if m.Rating != nil {
|
||||
out["rating"] = *m.Rating
|
||||
}
|
||||
if m.Color != nil && *m.Color != "" {
|
||||
out["color"] = *m.Color
|
||||
}
|
||||
if !m.UpdatedAt.IsZero() {
|
||||
// ISO-8601 with millisecond precision, UTC — matches the Node
|
||||
// prototype's `new Date().toISOString()` so clients written against
|
||||
// the old endpoint stay happy.
|
||||
out["updatedAt"] = m.UpdatedAt.UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func openDB(dsn string) (*gorm.DB, error) {
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.AutoMigrate(&Mark{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
213
sidecar/fs.go
Normal file
213
sidecar/fs.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sanitizeFilename trims a user-supplied filename and rejects anything
|
||||
// dangerous: path separators, leading dots, NUL bytes, the `.`/`..`
|
||||
// pseudo-names, anything absurdly long. PhotoPrism's indexer is happy
|
||||
// with most filename shapes; we lock down the ones a malicious or
|
||||
// careless caller might use to escape the folder.
|
||||
func sanitizeFilename(name string) (string, bool) {
|
||||
s := strings.TrimSpace(name)
|
||||
if s == "" || len(s) > 240 {
|
||||
return "", false
|
||||
}
|
||||
if strings.HasPrefix(s, ".") {
|
||||
return "", false
|
||||
}
|
||||
if s == "." || s == ".." {
|
||||
return "", false
|
||||
}
|
||||
if strings.ContainsAny(s, "/\\\x00") {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
// resolveUnderRoot takes a user-supplied relative path and returns its
|
||||
// absolute form, but only when the resolved location lives under the
|
||||
// configured originals root. Symlink escapes are caught by resolving the
|
||||
// parent through filepath.EvalSymlinks first.
|
||||
//
|
||||
// `mustExist=false` is for the *target* of a rename/create where the
|
||||
// terminal path isn't on disk yet; the parent still has to exist and
|
||||
// still has to be inside the root.
|
||||
func resolveUnderRoot(root, rel string, mustExist bool) (string, error) {
|
||||
if rel == "" {
|
||||
return "", errors.New("empty path")
|
||||
}
|
||||
clean := strings.TrimLeft(rel, "/")
|
||||
if clean == "" || clean == "." {
|
||||
return "", errors.New("empty path")
|
||||
}
|
||||
for _, seg := range strings.Split(clean, "/") {
|
||||
if seg == "" || seg == ".." {
|
||||
return "", errors.New("path traversal")
|
||||
}
|
||||
}
|
||||
abs := filepath.Join(root, clean)
|
||||
parent := filepath.Dir(abs)
|
||||
|
||||
parentReal, err := filepath.EvalSymlinks(parent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !sameOrUnder(parentReal, root) {
|
||||
return "", errors.New("parent escapes originals root")
|
||||
}
|
||||
if mustExist {
|
||||
if _, err := os.Stat(abs); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// ensureWithinOriginals checks that an absolute path's parent resolves to
|
||||
// somewhere inside the root after symlink evaluation. Used for the
|
||||
// already-resolved-on-disk paths returned by PhotoPrism's Files[].
|
||||
func ensureWithinOriginals(root, absPath string) bool {
|
||||
real, err := filepath.EvalSymlinks(filepath.Dir(absPath))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return sameOrUnder(real, root)
|
||||
}
|
||||
|
||||
func sameOrUnder(p, root string) bool {
|
||||
if p == root {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(p, root+string(os.PathSeparator))
|
||||
}
|
||||
|
||||
// uniqueName resolves "destDir/basename" against collisions by appending
|
||||
// `-1`, `-2`, … to the stem. Caps at 1000 attempts so a runaway loop
|
||||
// can't pin the goroutine forever.
|
||||
func uniqueName(destDir, basename string) (abs, name string, ok bool) {
|
||||
ext := filepath.Ext(basename)
|
||||
stem := strings.TrimSuffix(basename, ext)
|
||||
for i := 0; i < 1000; i++ {
|
||||
candidate := basename
|
||||
if i > 0 {
|
||||
candidate = stem + "-" + itoa(i) + ext
|
||||
}
|
||||
p := filepath.Join(destDir, candidate)
|
||||
if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
|
||||
return p, candidate, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// itoa is the tiny stdlib-free formatter we use inside hot loops.
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// fileEntry is the per-file row walkFiles emits. relPath stays root-
|
||||
// relative so it can land in API responses unchanged.
|
||||
type fileEntry struct {
|
||||
RelPath string
|
||||
AbsPath string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// supportedExts mirrors the Node prototype's whitelist. PhotoPrism
|
||||
// itself walks the same set; we keep the list in lock-step so callers
|
||||
// don't see "duplicate" warnings about files PhotoPrism would ignore.
|
||||
var supportedExts = map[string]struct{}{
|
||||
".jpg": {}, ".jpeg": {}, ".png": {}, ".heic": {}, ".heif": {},
|
||||
".tiff": {}, ".tif": {}, ".gif": {}, ".bmp": {}, ".webp": {}, ".avif": {},
|
||||
".mov": {}, ".mp4": {}, ".m4v": {}, ".avi": {}, ".mkv": {}, ".webm": {},
|
||||
".dng": {}, ".cr2": {}, ".cr3": {}, ".nef": {}, ".arw": {},
|
||||
".orf": {}, ".rw2": {}, ".raw": {},
|
||||
}
|
||||
|
||||
// walkFiles enumerates every supported media file under root, skipping
|
||||
// dotfiles/dotdirs (matches PhotoPrism's indexer and our own quarantine
|
||||
// folder). Errors on individual entries are swallowed so a single
|
||||
// permission-denied dir doesn't abort the whole scan.
|
||||
func walkFiles(root string) ([]fileEntry, error) {
|
||||
var out []fileEntry
|
||||
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// Permission errors etc. — skip the offending subtree but
|
||||
// keep walking. The dup-scan endpoint is best-effort.
|
||||
if d != nil && d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
if p != root && strings.HasPrefix(name, ".") {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if _, ok := supportedExts[ext]; !ok {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out = append(out, fileEntry{
|
||||
RelPath: rel,
|
||||
AbsPath: p,
|
||||
Size: info.Size(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// sha1File streams the file through a SHA1 hasher so a 4GB ProRes clip
|
||||
// doesn't blow the process's RAM. Returns the hex digest.
|
||||
func sha1File(absPath string) (string, error) {
|
||||
f, err := os.Open(absPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha1.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
45
sidecar/go.mod
Normal file
45
sidecar/go.mod
Normal file
@@ -0,0 +1,45 @@
|
||||
module mule-sidecar
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
101
sidecar/go.sum
Normal file
101
sidecar/go.sum
Normal file
@@ -0,0 +1,101 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
230
sidecar/handlers_dups.go
Normal file
230
sidecar/handlers_dups.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const quarantineDir = ".duplicates"
|
||||
|
||||
type dupFileLite struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type dupGroup struct {
|
||||
Hash string `json:"hash"`
|
||||
Size int64 `json:"size"`
|
||||
IndexedPath *string `json:"indexedPath"`
|
||||
Files []dupFileLite `json:"files"`
|
||||
}
|
||||
|
||||
// dupListPhoto is the partial photo shape we pull from PhotoPrism when
|
||||
// looking up "which file path has this hash already indexed", used to
|
||||
// hint the UI which copy to keep.
|
||||
type dupListPhoto struct {
|
||||
Files []ppFile `json:"Files"`
|
||||
}
|
||||
|
||||
func handleDupScan(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
start := time.Now()
|
||||
slog.Info("dup.scan starting", "root", cfg.OriginalsRoot)
|
||||
|
||||
all, err := walkFiles(cfg.OriginalsRoot)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Group by size first: byte-identical files necessarily share size,
|
||||
// so size-collision is a cheap O(N) prefilter that lets us skip
|
||||
// hashing >95% of a typical library.
|
||||
bySize := map[int64][]fileEntry{}
|
||||
for _, f := range all {
|
||||
bySize[f.Size] = append(bySize[f.Size], f)
|
||||
}
|
||||
|
||||
// Hash size-collision buckets concurrently. Cap fan-out to GOMAXPROCS
|
||||
// so we don't drown the disk with parallel reads on a spinning HDD.
|
||||
type hashOut struct {
|
||||
hash string
|
||||
f fileEntry
|
||||
}
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
sem = make(chan struct{}, 4)
|
||||
outMu sync.Mutex
|
||||
byHash = map[string][]fileEntry{}
|
||||
hashSize = map[string]int64{}
|
||||
)
|
||||
for size, group := range bySize {
|
||||
if len(group) < 2 {
|
||||
continue
|
||||
}
|
||||
for _, f := range group {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(f fileEntry, sz int64) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
h, err := sha1File(f.AbsPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
outMu.Lock()
|
||||
byHash[h] = append(byHash[h], f)
|
||||
hashSize[h] = sz
|
||||
outMu.Unlock()
|
||||
}(f, size)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Drop singletons (size collision but different hashes), then ask
|
||||
// PhotoPrism which of the duplicates it has indexed so the UI can
|
||||
// default the "keep" selection to that one.
|
||||
groups := make([]dupGroup, 0)
|
||||
for h, files := range byHash {
|
||||
if len(files) < 2 {
|
||||
continue
|
||||
}
|
||||
g := dupGroup{Hash: h, Size: hashSize[h]}
|
||||
for _, f := range files {
|
||||
g.Files = append(g.Files, dupFileLite{Path: f.RelPath, Size: f.Size})
|
||||
}
|
||||
// Best-effort lookup; swallow errors. The hash query is cheap on
|
||||
// PhotoPrism's side (indexed column).
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet,
|
||||
"/api/v1/photos?q=hash:"+h+"&count=1&merged=true", token, nil)
|
||||
if err == nil && resp.OK {
|
||||
var photos []dupListPhoto
|
||||
if err := json.Unmarshal(resp.Body, &photos); err == nil && len(photos) > 0 {
|
||||
if pf, ok := primaryFileOf(&ppPhoto{Files: photos[0].Files}); ok && pf.Name != "" {
|
||||
p := pf.Name
|
||||
g.IndexedPath = &p
|
||||
}
|
||||
}
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
// Sort by reclaimable bytes descending (size × duplicate-count) so
|
||||
// the biggest wins float to the top of the UI.
|
||||
sort.Slice(groups, func(i, j int) bool {
|
||||
return groups[i].Size*int64(len(groups[i].Files)-1) >
|
||||
groups[j].Size*int64(len(groups[j].Files)-1)
|
||||
})
|
||||
|
||||
ms := time.Since(start).Milliseconds()
|
||||
slog.Info("dup.scan done", "groups", len(groups), "ms", ms)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"groups": groups,
|
||||
"scannedMs": ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type dupArchiveBody struct {
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
|
||||
type dupMoved struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
type dupArchiveErr struct {
|
||||
Path string `json:"path"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
var body dupArchiveBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil || len(body.Paths) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "paths[] required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Each archive batch lands in its own timestamped subdir so the
|
||||
// user can browse what was quarantined when (and recover by hand
|
||||
// if they change their mind).
|
||||
stamp := time.Now().UTC().Format("2006-01-02T15-04-05.000Z")
|
||||
targetDir := filepath.Join(cfg.OriginalsRoot, quarantineDir, stamp)
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// archiveOne moves a single file into the quarantine batch dir
|
||||
// and returns the new relative path. Disambiguates same-basename
|
||||
// collisions within the batch so two `IMG_0001.jpg` from
|
||||
// different folders don't clobber each other.
|
||||
archiveOne := func(rel string) (string, error) {
|
||||
abs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid path")
|
||||
}
|
||||
base := filepath.Base(abs)
|
||||
dest := filepath.Join(targetDir, base)
|
||||
for i := 1; ; i++ {
|
||||
if _, err := os.Stat(dest); errors.Is(err, os.ErrNotExist) {
|
||||
break
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
stem := base[:len(base)-len(filepath.Ext(base))]
|
||||
dest = filepath.Join(targetDir, stem+"__"+itoa(i)+filepath.Ext(base))
|
||||
}
|
||||
if err := os.Rename(abs, dest); err != nil {
|
||||
// EXDEV fallback — copy+remove for libraries that span
|
||||
// filesystems (e.g. originals on a different mount).
|
||||
if err2 := copyFile(abs, dest); err2 != nil {
|
||||
return "", err
|
||||
}
|
||||
if err2 := os.Remove(abs); err2 != nil {
|
||||
return "", errors.New("moved but source remove failed: " + err2.Error())
|
||||
}
|
||||
}
|
||||
relDest, _ := filepath.Rel(cfg.OriginalsRoot, dest)
|
||||
return relDest, nil
|
||||
}
|
||||
|
||||
moved := []dupMoved{}
|
||||
errs := []dupArchiveErr{}
|
||||
for _, rel := range body.Paths {
|
||||
relDest, err := archiveOne(rel)
|
||||
if err != nil {
|
||||
errs = append(errs, dupArchiveErr{Path: rel, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
moved = append(moved, dupMoved{From: rel, To: relDest})
|
||||
slog.Info("dup.archive", "from", rel, "to", relDest)
|
||||
}
|
||||
|
||||
// Reindex the entire library so PhotoPrism drops rows for the
|
||||
// archived files. cleanup:true is critical — the files still
|
||||
// exist on disk, just under .duplicates/ which the indexer
|
||||
// ignores.
|
||||
if len(moved) > 0 {
|
||||
go func() {
|
||||
if err := pp.reindex(context.Background(), token, "/"); err != nil {
|
||||
slog.Warn("dup.archive reindex failed", "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"moved": moved, "errors": errs})
|
||||
}
|
||||
}
|
||||
178
sidecar/handlers_folders.go
Normal file
178
sidecar/handlers_folders.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// pathParam pulls the URL-encoded :rel out of the Gin context and
|
||||
// unescapes it. UseRawPath is on at the router level (see main.go) so the
|
||||
// raw value still carries `%2F` for nested paths; we decode here.
|
||||
func pathParam(c *gin.Context, key string) (string, bool) {
|
||||
raw := c.Param(key)
|
||||
if raw == "" {
|
||||
return "", false
|
||||
}
|
||||
dec, err := url.PathUnescape(raw)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return dec, true
|
||||
}
|
||||
|
||||
type folderCreateBody struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func handleFolderCreate(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
var body folderCreateBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Path == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
|
||||
return
|
||||
}
|
||||
abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.Path, false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(abs); err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "already exists"})
|
||||
return
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := os.Mkdir(abs, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
rel, _ := filepath.Rel(cfg.OriginalsRoot, abs)
|
||||
slog.Info("folder.create", "path", rel)
|
||||
go fireReindex(cfg, pp, token, "/"+filepath.Dir(rel))
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "path": rel})
|
||||
}
|
||||
}
|
||||
|
||||
type folderRenameBody struct {
|
||||
NewName string `json:"newName"`
|
||||
}
|
||||
|
||||
func handleFolderRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
rel, ok := pathParam(c, "rel")
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
return
|
||||
}
|
||||
var body folderRenameBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
newName, ok := sanitizeFilename(body.NewName)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "newName must be a plain dirname"})
|
||||
return
|
||||
}
|
||||
oldAbs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(oldAbs)
|
||||
if err != nil || !st.IsDir() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||
return
|
||||
}
|
||||
newAbs := filepath.Join(filepath.Dir(oldAbs), newName)
|
||||
if _, err := os.Stat(newAbs); err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "target already exists"})
|
||||
return
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !sameOrUnder(newAbs, cfg.OriginalsRoot) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "target escapes root"})
|
||||
return
|
||||
}
|
||||
if err := os.Rename(oldAbs, newAbs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
oldRel, _ := filepath.Rel(cfg.OriginalsRoot, oldAbs)
|
||||
newRel, _ := filepath.Rel(cfg.OriginalsRoot, newAbs)
|
||||
slog.Info("folder.rename", "from", oldRel, "to", newRel)
|
||||
go fireReindex(cfg, pp, token, "/"+filepath.Dir(oldRel))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"oldPath": oldRel,
|
||||
"newPath": newRel,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleFolderDelete(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
rel, ok := pathParam(c, "rel")
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
return
|
||||
}
|
||||
abs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
return
|
||||
}
|
||||
if abs == cfg.OriginalsRoot {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "refuse to delete root"})
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil || !st.IsDir() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(abs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "directory not empty"})
|
||||
return
|
||||
}
|
||||
if err := os.Remove(abs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
slog.Info("folder.delete", "path", rel)
|
||||
go fireReindex(cfg, pp, token, "/"+filepath.Dir(rel))
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "path": rel})
|
||||
}
|
||||
}
|
||||
|
||||
// fireReindex wraps pp.reindex with logging and a detached context so
|
||||
// it can run in a goroutine after the response has gone out. The Node
|
||||
// prototype kicks reindex with `void reindex(...)` and never awaits;
|
||||
// matching that here keeps the apparent latency of mutating endpoints
|
||||
// low (PhotoPrism's index can take seconds on a big folder).
|
||||
func fireReindex(_ *Config, pp *ppClient, token, parentRel string) {
|
||||
// pp.call's client already enforces a 60s timeout, so the parent
|
||||
// context can be detached from the request — the handler has long
|
||||
// since written its response.
|
||||
if err := pp.reindex(context.Background(), token, parentRel); err != nil {
|
||||
slog.Warn("reindex failed", "path", parentRel, "err", err)
|
||||
}
|
||||
}
|
||||
235
sidecar/handlers_heap.go
Normal file
235
sidecar/handlers_heap.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type heapConvertBody struct {
|
||||
TargetFolder string `json:"targetFolder"`
|
||||
Mode string `json:"mode"` // "move" or "copy"
|
||||
Subfolder string `json:"subfolder"` // optional, sanitized to a single segment
|
||||
DeleteHeap bool `json:"deleteHeap"`
|
||||
}
|
||||
|
||||
type heapPhoto struct {
|
||||
UID string `json:"UID"`
|
||||
Files []ppFile `json:"Files"`
|
||||
}
|
||||
|
||||
type heapErr struct {
|
||||
UID string `json:"uid"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// copyFile is the os.Rename fallback for cross-device moves and the
|
||||
// primary path for "copy" mode. Streams so a 4GB video doesn't pin
|
||||
// memory; preserves mode bits, sets the modification time to now (we're
|
||||
// creating a new inode either way).
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
st, err := in.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, st.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
os.Remove(dst)
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
albumUID := c.Param("uid")
|
||||
|
||||
var body heapConvertBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
mode := body.Mode
|
||||
if mode != "copy" {
|
||||
mode = "move"
|
||||
}
|
||||
deleteHeap := mode == "move" && body.DeleteHeap
|
||||
|
||||
var subfolder string
|
||||
if body.Subfolder != "" {
|
||||
s, ok := sanitizeFilename(body.Subfolder)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subfolder name"})
|
||||
return
|
||||
}
|
||||
subfolder = s
|
||||
}
|
||||
|
||||
// Resolve destination. resolveUnderRoot ensures the target lives
|
||||
// inside ORIGINALS_ROOT and that its parent is a real directory.
|
||||
targetAbs, err := resolveUnderRoot(cfg.OriginalsRoot, body.TargetFolder, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
||||
return
|
||||
}
|
||||
destAbs := targetAbs
|
||||
if subfolder != "" {
|
||||
destAbs = filepath.Join(targetAbs, subfolder)
|
||||
if err := os.MkdirAll(destAbs, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Pull the heap's photos via the q=album:UID query. count=1000 covers
|
||||
// every realistic heap; merged=true expands stacked variants so we
|
||||
// move the JPG/HEIC sibling alongside the primary.
|
||||
q := url.QueryEscape("album:" + albumUID)
|
||||
listURL := "/api/v1/photos?q=" + q + "&count=1000&merged=true"
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !resp.OK {
|
||||
c.JSON(resp.Status, gin.H{"error": "list photos failed"})
|
||||
return
|
||||
}
|
||||
var photos []heapPhoto
|
||||
if err := json.Unmarshal(resp.Body, &photos); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"})
|
||||
return
|
||||
}
|
||||
|
||||
sourceParents := map[string]struct{}{}
|
||||
errs := []heapErr{}
|
||||
moved, copied := 0, 0
|
||||
|
||||
for _, photo := range photos {
|
||||
var file ppFile
|
||||
found := false
|
||||
for _, f := range photo.Files {
|
||||
if f.Primary {
|
||||
file, found = f, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if len(photo.Files) == 0 {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "no primary file"})
|
||||
continue
|
||||
}
|
||||
file = photo.Files[0]
|
||||
}
|
||||
srcRel := file.Name
|
||||
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
|
||||
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "path escapes originals"})
|
||||
continue
|
||||
}
|
||||
st, err := os.Stat(srcAbs)
|
||||
if err != nil || !st.Mode().IsRegular() {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"})
|
||||
continue
|
||||
}
|
||||
if filepath.Dir(srcAbs) == destAbs {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
|
||||
continue
|
||||
}
|
||||
_, name, ok := uniqueName(destAbs, filepath.Base(srcAbs))
|
||||
if !ok {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
|
||||
continue
|
||||
}
|
||||
dstAbs := filepath.Join(destAbs, name)
|
||||
if mode == "move" {
|
||||
if err := os.Rename(srcAbs, dstAbs); err != nil {
|
||||
// Cross-device renames fail with EXDEV — fall back to
|
||||
// copy+remove so a library that spans filesystems still
|
||||
// works.
|
||||
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
|
||||
continue
|
||||
}
|
||||
if err2 := os.Remove(srcAbs); err2 != nil {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "rename ok, source remove failed: " + err2.Error()})
|
||||
continue
|
||||
}
|
||||
}
|
||||
moved++
|
||||
} else {
|
||||
if err := copyFile(srcAbs, dstAbs); err != nil {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
|
||||
continue
|
||||
}
|
||||
copied++
|
||||
}
|
||||
sourceParents[filepath.Dir(srcRel)] = struct{}{}
|
||||
}
|
||||
|
||||
// Reindex the destination + every source parent so PhotoPrism's
|
||||
// DB catches up. We do this in the background — the user gets
|
||||
// their counts immediately; PhotoPrism's timeline updates as the
|
||||
// reindex lands.
|
||||
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
|
||||
paths := map[string]struct{}{destRel: {}}
|
||||
for p := range sourceParents {
|
||||
paths[p] = struct{}{}
|
||||
}
|
||||
if subfolder != "" {
|
||||
parent, _ := filepath.Rel(cfg.OriginalsRoot, targetAbs)
|
||||
paths[parent] = struct{}{}
|
||||
}
|
||||
for p := range paths {
|
||||
reindex := "/"
|
||||
if p != "" && p != "." {
|
||||
reindex = "/" + p
|
||||
}
|
||||
go fireReindex(cfg, pp, token, reindex)
|
||||
}
|
||||
|
||||
heapDeleted := false
|
||||
if deleteHeap {
|
||||
r, err := pp.call(context.Background(), http.MethodDelete, "/api/v1/albums/"+albumUID, token, nil)
|
||||
if err == nil && r.OK {
|
||||
heapDeleted = true
|
||||
} else if err != nil {
|
||||
errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: " + err.Error()})
|
||||
} else {
|
||||
errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: HTTP " + itoa(r.Status)})
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("heap.convert",
|
||||
"album", albumUID,
|
||||
"mode", mode,
|
||||
"moved", moved,
|
||||
"copied", copied,
|
||||
"errors", len(errs),
|
||||
"heap_deleted", heapDeleted,
|
||||
)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"moved": moved,
|
||||
"copied": copied,
|
||||
"errors": errs,
|
||||
"heap_deleted": heapDeleted,
|
||||
})
|
||||
}
|
||||
}
|
||||
206
sidecar/handlers_marks.go
Normal file
206
sidecar/handlers_marks.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// validColors is the four-color palette mule-image always shipped. The
|
||||
// empty string is the explicit "clear color" sentinel.
|
||||
var validColors = map[string]struct{}{
|
||||
"red": {},
|
||||
"orange": {},
|
||||
"yellow": {},
|
||||
"green": {},
|
||||
}
|
||||
|
||||
// markPatch is the request body for all three mutating mark endpoints.
|
||||
// Pointers distinguish "field omitted" from "field set to zero" — a PUT
|
||||
// with `{"rating": 0}` clears the rating, but a PUT with `{"color": "red"}`
|
||||
// alone must NOT wipe an existing rating.
|
||||
type markPatch struct {
|
||||
Rating *int `json:"rating,omitempty"`
|
||||
Color *string `json:"color,omitempty"`
|
||||
}
|
||||
|
||||
func (p *markPatch) sanitize() error {
|
||||
if p.Rating != nil {
|
||||
r := *p.Rating
|
||||
if r < 0 || r > 5 {
|
||||
return errors.New("rating out of range")
|
||||
}
|
||||
}
|
||||
if p.Color != nil {
|
||||
c := strings.ToLower(strings.TrimSpace(*p.Color))
|
||||
if c != "" {
|
||||
if _, ok := validColors[c]; !ok {
|
||||
return errors.New("invalid color")
|
||||
}
|
||||
}
|
||||
*p.Color = c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// apply merges the patch onto an existing row (or a fresh zero-value
|
||||
// Mark for an upsert). Returns true if anything in the row still has a
|
||||
// non-empty value — false signals "delete the row" to the caller.
|
||||
func (p *markPatch) apply(m *Mark) bool {
|
||||
if p.Rating != nil {
|
||||
if *p.Rating > 0 {
|
||||
r := *p.Rating
|
||||
m.Rating = &r
|
||||
} else {
|
||||
m.Rating = nil
|
||||
}
|
||||
}
|
||||
if p.Color != nil {
|
||||
if *p.Color != "" {
|
||||
c := *p.Color
|
||||
m.Color = &c
|
||||
} else {
|
||||
m.Color = nil
|
||||
}
|
||||
}
|
||||
return m.Rating != nil || (m.Color != nil && *m.Color != "")
|
||||
}
|
||||
|
||||
// allMarksJSON renders the entire `marks` table as the wire shape
|
||||
// `{"<uid>": {"rating": …, "color": …, "updatedAt": …}, …}`. Used by
|
||||
// GET /photos/marks which the web client calls once on session start.
|
||||
func allMarksJSON(db *gorm.DB) (map[string]map[string]any, error) {
|
||||
var rows []Mark
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]map[string]any, len(rows))
|
||||
for i := range rows {
|
||||
out[rows[i].PhotoUID] = rows[i].asJSON()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func handleMarksAll(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
marks, err := allMarksJSON(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, marks)
|
||||
}
|
||||
}
|
||||
|
||||
func handleMarkGet(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("uid")
|
||||
var m Mark
|
||||
err := db.Where("photo_uid = ?", uid).First(&m).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, m.asJSON())
|
||||
}
|
||||
}
|
||||
|
||||
// upsert applies the patch and writes back. Returns the resulting JSON
|
||||
// shape (empty map if the row was deleted).
|
||||
func upsert(db *gorm.DB, uid string, patch *markPatch) (map[string]any, error) {
|
||||
var m Mark
|
||||
err := db.Where("photo_uid = ?", uid).First(&m).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
m.PhotoUID = uid
|
||||
keep := patch.apply(&m)
|
||||
m.UpdatedAt = time.Now().UTC()
|
||||
if !keep {
|
||||
// Drop the row entirely so a re-fetch returns {}.
|
||||
if err := db.Where("photo_uid = ?", uid).Delete(&Mark{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
if err := db.Save(&m).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.asJSON(), nil
|
||||
}
|
||||
|
||||
func handleMarkPut(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("uid")
|
||||
var patch markPatch
|
||||
if err := c.ShouldBindJSON(&patch); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid patch"})
|
||||
return
|
||||
}
|
||||
if err := patch.sanitize(); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, err := upsert(db, uid, &patch)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
type bulkBody struct {
|
||||
IDs []string `json:"ids"`
|
||||
Patch markPatch `json:"patch"`
|
||||
}
|
||||
|
||||
func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body bulkBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
if len(body.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ids[] required"})
|
||||
return
|
||||
}
|
||||
if err := body.Patch.sanitize(); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
applied := make(map[string]map[string]any, len(body.IDs))
|
||||
// Single transaction so a partial failure rolls back. The client
|
||||
// expects atomic semantics for a bulk star/colour stamp.
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, uid := range body.IDs {
|
||||
if uid == "" {
|
||||
continue
|
||||
}
|
||||
out, err := upsert(tx, uid, &body.Patch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applied[uid] = out
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"count": len(applied),
|
||||
"marks": applied,
|
||||
})
|
||||
}
|
||||
}
|
||||
143
sidecar/handlers_rename.go
Normal file
143
sidecar/handlers_rename.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// renameBody mirrors the Node prototype's wire contract — a single
|
||||
// `newName` field carrying the bare basename (no slashes).
|
||||
type renameBody struct {
|
||||
NewName string `json:"newName"`
|
||||
}
|
||||
|
||||
// ppPhoto is the partial PhotoPrism photo shape we need to find the
|
||||
// primary file's on-disk location. Anything we don't read stays
|
||||
// unspecified so version drift across PhotoPrism builds doesn't break
|
||||
// JSON unmarshalling.
|
||||
type ppPhoto struct {
|
||||
Files []ppFile `json:"Files"`
|
||||
}
|
||||
|
||||
type ppFile struct {
|
||||
Name string `json:"Name"`
|
||||
Root string `json:"Root"`
|
||||
Primary bool `json:"Primary"`
|
||||
}
|
||||
|
||||
func primaryFileOf(p *ppPhoto) (ppFile, bool) {
|
||||
if p == nil {
|
||||
return ppFile{}, false
|
||||
}
|
||||
for _, f := range p.Files {
|
||||
if f.Primary {
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
if len(p.Files) > 0 {
|
||||
return p.Files[0], true
|
||||
}
|
||||
return ppFile{}, false
|
||||
}
|
||||
|
||||
func handleRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
photoUID := c.Param("uid")
|
||||
|
||||
var body renameBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
newName, ok := sanitizeFilename(body.NewName)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "newName must be a plain filename"})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch the photo so we can resolve Files[0].Root + Name into a
|
||||
// concrete on-disk path. PhotoPrism has no "file by UID" endpoint
|
||||
// in this build, so the single-photo lookup is the cheapest path.
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/photos/"+photoUID, token, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !resp.OK {
|
||||
c.JSON(resp.Status, gin.H{"error": "photo not found"})
|
||||
return
|
||||
}
|
||||
var photo ppPhoto
|
||||
if err := json.Unmarshal(resp.Body, &photo); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo"})
|
||||
return
|
||||
}
|
||||
file, ok := primaryFileOf(&photo)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no files on this photo"})
|
||||
return
|
||||
}
|
||||
|
||||
root := strings.TrimPrefix(file.Root, "/")
|
||||
if root == "" || root == "/" {
|
||||
root = ""
|
||||
}
|
||||
relPath := filepath.Join(root, file.Name)
|
||||
oldAbs := filepath.Join(cfg.OriginalsRoot, relPath)
|
||||
|
||||
if !ensureWithinOriginals(cfg.OriginalsRoot, oldAbs) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "path escapes originals root"})
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(oldAbs)
|
||||
if err != nil || !st.Mode().IsRegular() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file missing on disk"})
|
||||
return
|
||||
}
|
||||
newAbs := filepath.Join(filepath.Dir(oldAbs), newName)
|
||||
if !ensureWithinOriginals(cfg.OriginalsRoot, newAbs) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "new path escapes originals root"})
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(newAbs); err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "target filename already exists"})
|
||||
return
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
newRel := filepath.Join(root, newName)
|
||||
slog.Info("rename", "from", relPath, "to", newRel)
|
||||
if err := os.Rename(oldAbs, newAbs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger reindex on the parent so PhotoPrism picks up the new
|
||||
// filename and drops the orphan row for the old name. Best-effort.
|
||||
reindexPath := "/"
|
||||
if root != "" {
|
||||
reindexPath = "/" + root
|
||||
}
|
||||
if err := pp.reindex(c.Request.Context(), token, reindexPath); err != nil {
|
||||
slog.Warn("rename reindex failed", "err", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"oldName": file.Name,
|
||||
"newName": newName,
|
||||
"oldRelPath": relPath,
|
||||
"newRelPath": newRel,
|
||||
})
|
||||
}
|
||||
}
|
||||
113
sidecar/main.go
Normal file
113
sidecar/main.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// mule-sidecar — Go service for endpoints PhotoPrism does not expose.
|
||||
//
|
||||
// Ports the Node prototype (server.mjs) to the stack the merge plan calls
|
||||
// out: Go + Gin + GORM + MariaDB. Same wire contract as the prototype so
|
||||
// the SvelteKit web client doesn't need to change.
|
||||
//
|
||||
// Auth model is unchanged: the caller's X-Auth-Token is the only authority.
|
||||
// requireSession validates it against PhotoPrism's /api/v1/photos before
|
||||
// any destructive op runs.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
})))
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
slog.Error("config", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
db, err := openDB(cfg.DSN)
|
||||
if err != nil {
|
||||
slog.Error("db open", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
pp := newPPClient(cfg.PhotoprismBaseURL)
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
// Keep `%2F` literal in path params so callers can pass URL-encoded
|
||||
// nested folder paths (e.g. `foo%2Fbar`) without the router splitting
|
||||
// them into separate segments. Handlers decode via url.PathUnescape.
|
||||
r.UseRawPath = true
|
||||
r.UnescapePathValues = false
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
// Health probe — unauthenticated so a process supervisor can call it
|
||||
// without needing PhotoPrism to be reachable.
|
||||
r.GET("/api/sidecar/healthz", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"originalsRoot": cfg.OriginalsRoot,
|
||||
})
|
||||
})
|
||||
|
||||
// Every other endpoint runs behind the session gate. Mounting them
|
||||
// under one group keeps the middleware wiring obvious.
|
||||
auth := r.Group("/api/sidecar", requireSession(pp))
|
||||
{
|
||||
auth.GET("/photos/marks", handleMarksAll(db))
|
||||
auth.GET("/photos/:uid/marks", handleMarkGet(db))
|
||||
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
|
||||
auth.POST("/photos/marks/bulk", handleMarkBulk(db))
|
||||
|
||||
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
|
||||
|
||||
auth.POST("/folders", handleFolderCreate(cfg, pp))
|
||||
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
|
||||
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
|
||||
|
||||
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
|
||||
|
||||
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
|
||||
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
|
||||
}
|
||||
|
||||
addr := "127.0.0.1:" + itoa(cfg.Port)
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Graceful shutdown so an in-flight duplicate scan or heap convert
|
||||
// gets a chance to finish (or at least flush logs) on SIGTERM.
|
||||
idleClosed := make(chan struct{})
|
||||
go func() {
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigs
|
||||
slog.Info("shutdown signal received")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(ctx)
|
||||
close(idleClosed)
|
||||
}()
|
||||
|
||||
slog.Info("mule-sidecar listening",
|
||||
"addr", "http://"+addr,
|
||||
"originals", cfg.OriginalsRoot,
|
||||
)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Error("listen", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
<-idleClosed
|
||||
}
|
||||
|
||||
115
sidecar/pp.go
Normal file
115
sidecar/pp.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ppClient is the thin HTTP wrapper around PhotoPrism's /api/v1. It is
|
||||
// deliberately *not* shared across requests with caching: each handler
|
||||
// forwards the caller's X-Auth-Token, so a single shared http.Client (we
|
||||
// reuse the stdlib default) plus per-call header injection is all we need.
|
||||
type ppClient struct {
|
||||
base string
|
||||
h *http.Client
|
||||
}
|
||||
|
||||
func newPPClient(base string) *ppClient {
|
||||
return &ppClient{
|
||||
base: base,
|
||||
h: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// ppResp is the trimmed projection of an HTTP response that callers
|
||||
// actually consume. Status + raw body are exposed so handlers can mirror
|
||||
// PhotoPrism's status code or parse the body themselves.
|
||||
type ppResp struct {
|
||||
OK bool
|
||||
Status int
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// call issues an authenticated request against PhotoPrism. body is
|
||||
// optional; pass nil for GET/DELETE. We don't JSON-decode here — callers
|
||||
// know the shape they want and decode lazily.
|
||||
func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body any) (*ppResp, error) {
|
||||
u, err := url.Parse(c.base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rel, err := url.Parse(urlPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
full := u.ResolveReference(rel).String()
|
||||
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader = bytes.NewReader(buf)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, full, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Auth-Token", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.h.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
buf, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ppResp{
|
||||
OK: resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
Status: resp.StatusCode,
|
||||
Body: buf,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateSession is the cheapest probe that the supplied token is live:
|
||||
// list one photo. 401 → bad/expired token. We never read the payload.
|
||||
func (c *ppClient) validateSession(ctx context.Context, token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
r, err := c.call(ctx, http.MethodGet, "/api/v1/photos?count=1", token, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return r.OK
|
||||
}
|
||||
|
||||
// reindex tells PhotoPrism to re-walk a single subpath of originals and
|
||||
// reconcile its DB with the on-disk state. Callers fire this after any
|
||||
// rename/create/delete so the timeline catches up. `cleanup: true` drops
|
||||
// orphan rows (e.g. the row for the file's old name after a rename).
|
||||
//
|
||||
// Best-effort: errors are surfaced to the caller, who logs but does not
|
||||
// abort — the file mutation has already happened on disk by the time
|
||||
// reindex runs.
|
||||
func (c *ppClient) reindex(ctx context.Context, token, parentRel string) error {
|
||||
if parentRel == "" {
|
||||
parentRel = "/"
|
||||
}
|
||||
_, err := c.call(ctx, http.MethodPost, "/api/v1/index", token, map[string]any{
|
||||
"path": parentRel,
|
||||
"rescan": false,
|
||||
"cleanup": true,
|
||||
})
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user