Compare commits
6 Commits
48ea1fe720
...
ca3bca59e7
| Author | SHA1 | Date | |
|---|---|---|---|
| ca3bca59e7 | |||
| c8bd8512ce | |||
| 129cc59793 | |||
| 5571dfa46d | |||
| 52c62b5c8d | |||
| 0b0f797fae |
+6
-4
@@ -49,13 +49,15 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 3 — minimal runtime
|
||||
#
|
||||
# Alpine (not distroless/scratch) because video thumbnailing invokes ffmpeg as
|
||||
# an external process; it must be present on the runtime image.
|
||||
# Alpine (not distroless/scratch) because video thumbnailing invokes ffmpeg and
|
||||
# metadata extraction invokes exiftool as external processes; both must be
|
||||
# present on the runtime image.
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM alpine:3.21 AS runtime
|
||||
|
||||
# ffmpeg: video frame extraction. ca-certificates/tzdata: TLS + time zones.
|
||||
RUN apk add --no-cache ffmpeg ca-certificates tzdata
|
||||
# ffmpeg: video frame extraction. exiftool: rich image/video/audio metadata.
|
||||
# ca-certificates/tzdata: TLS + time zones.
|
||||
RUN apk add --no-cache ffmpeg exiftool ca-certificates tzdata
|
||||
|
||||
# Run as an unprivileged user.
|
||||
RUN addgroup -S -g 42776 tanabata && adduser -S -G tanabata -u 42776 tanabata
|
||||
|
||||
@@ -684,13 +684,36 @@ func (h *FileHandler) Import(c *gin.Context) {
|
||||
// Body is optional; ignore bind errors.
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
|
||||
result, err := h.fileSvc.Import(c.Request.Context(), body.Path)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
// Stream progress as newline-delimited JSON so the client can render a live
|
||||
// progress bar and per-file status. Headers are deferred until the first
|
||||
// event, so a validation error (bad path, import disabled) raised before any
|
||||
// file is touched can still be returned as a normal JSON error response.
|
||||
flusher, canFlush := c.Writer.(http.Flusher)
|
||||
started := false
|
||||
enc := json.NewEncoder(c.Writer)
|
||||
|
||||
emit := func(ev service.ImportEvent) {
|
||||
if !started {
|
||||
c.Header("Content-Type", "application/x-ndjson")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("X-Accel-Buffering", "no") // don't let a proxy buffer the stream
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
started = true
|
||||
}
|
||||
_ = enc.Encode(ev) // appends a newline
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(c, http.StatusOK, result)
|
||||
if _, err := h.fileSvc.Import(c.Request.Context(), body.Path, emit); err != nil {
|
||||
if !started {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// Headers already sent; surface the failure as a terminal stream event.
|
||||
emit(service.ImportEvent{Type: "error", Reason: err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -902,6 +902,35 @@ func TestNonOwnerAccessControl(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
// importEvent mirrors service.ImportEvent for decoding the streamed progress.
|
||||
type importEvent struct {
|
||||
Type string `json:"type"`
|
||||
Total int `json:"total"`
|
||||
Index int `json:"index"`
|
||||
Filename string `json:"filename"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors int `json:"errors"`
|
||||
}
|
||||
|
||||
// parseImportEvents splits an NDJSON import response into its events.
|
||||
func parseImportEvents(t *testing.T, resp *testResponse) []importEvent {
|
||||
t.Helper()
|
||||
var events []importEvent
|
||||
for _, line := range bytes.Split(resp.bodyBytes, []byte("\n")) {
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var ev importEvent
|
||||
require.NoError(t, json.Unmarshal(line, &ev), "event line: %s", line)
|
||||
events = append(events, ev)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// TestImportFromFolder verifies the admin server-side import: supported files
|
||||
// are ingested, subdirectories are skipped, the source is removed from the
|
||||
// import folder afterwards, and a file without EXIF takes the source's mtime as
|
||||
@@ -923,18 +952,31 @@ func TestImportFromFolder(t *testing.T) {
|
||||
|
||||
resp := h.doJSON("POST", "/files/import", map[string]any{}, adminToken)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||
var res struct {
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []struct {
|
||||
Filename string `json:"filename"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"errors"`
|
||||
|
||||
// The import streams newline-delimited JSON progress events.
|
||||
events := parseImportEvents(t, resp)
|
||||
var start, done *importEvent
|
||||
files := map[string]importEvent{}
|
||||
for i := range events {
|
||||
switch events[i].Type {
|
||||
case "start":
|
||||
start = &events[i]
|
||||
case "done":
|
||||
done = &events[i]
|
||||
case "file":
|
||||
files[events[i].Filename] = events[i]
|
||||
}
|
||||
}
|
||||
resp.decode(t, &res)
|
||||
assert.Equal(t, 1, res.Imported, resp.String())
|
||||
assert.Equal(t, 1, res.Skipped, resp.String()) // the nested directory
|
||||
assert.Empty(t, res.Errors, resp.String())
|
||||
require.NotNil(t, start, resp.String())
|
||||
require.NotNil(t, done, resp.String())
|
||||
assert.Equal(t, 2, start.Total, "start total counts every entry")
|
||||
assert.Equal(t, 1, done.Imported, resp.String())
|
||||
assert.Equal(t, 1, done.Skipped, resp.String()) // the nested directory
|
||||
assert.Equal(t, 0, done.Errors, resp.String())
|
||||
|
||||
// Per-file events: the JPEG imported, the subdirectory was skipped.
|
||||
assert.Equal(t, "imported", files["scan.jpg"].Status, resp.String())
|
||||
assert.Equal(t, "skipped", files["nested"].Status, resp.String())
|
||||
|
||||
// Source file is gone from the import folder after a successful import.
|
||||
_, statErr := os.Stat(srcPath)
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rwcarlsen/goexif/exif"
|
||||
|
||||
"tanabata/backend/internal/domain"
|
||||
"tanabata/backend/internal/port"
|
||||
@@ -71,6 +70,24 @@ type ImportResult struct {
|
||||
Errors []ImportFileError `json:"errors"`
|
||||
}
|
||||
|
||||
// ImportEvent is one progress message streamed during an import, letting the UI
|
||||
// show a live progress bar and a per-file status list. Type is the discriminator:
|
||||
//
|
||||
// "start" — total is the number of entries about to be processed.
|
||||
// "file" — one entry finished: index (1-based), filename, status, optional reason.
|
||||
// "done" — final tallies (imported/skipped/errors).
|
||||
type ImportEvent struct {
|
||||
Type string `json:"type"`
|
||||
Total int `json:"total,omitempty"`
|
||||
Index int `json:"index,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Status string `json:"status,omitempty"` // "imported" | "skipped" | "error"
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Imported int `json:"imported,omitempty"`
|
||||
Skipped int `json:"skipped,omitempty"`
|
||||
Errors int `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// FileService handles business logic for file records.
|
||||
type FileService struct {
|
||||
files port.FileRepo
|
||||
@@ -112,7 +129,7 @@ func NewFileService(
|
||||
|
||||
// Upload validates the MIME type, saves the file to storage, creates the DB
|
||||
// record, and applies any initial tags — all within a single transaction.
|
||||
// If ContentDatetime is nil and EXIF DateTimeOriginal is present, it is used.
|
||||
// If ContentDatetime is nil and the metadata carries a capture date, it is used.
|
||||
func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File, error) {
|
||||
userID, _, _ := domain.UserFromContext(ctx)
|
||||
|
||||
@@ -129,10 +146,14 @@ func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File,
|
||||
}
|
||||
data := buf.Bytes()
|
||||
|
||||
// Extract EXIF metadata (best-effort; non-image files will error silently).
|
||||
exifData, exifDatetime := extractEXIFWithDatetime(data)
|
||||
// Extract rich metadata (best-effort; covers images, video and audio).
|
||||
var origName string
|
||||
if p.OriginalName != nil {
|
||||
origName = *p.OriginalName
|
||||
}
|
||||
exifData, exifDatetime := extractMetadata(data, origName, p.ContentDatetimeFallback)
|
||||
|
||||
// Resolve content datetime: explicit > EXIF > fallback (e.g. import mtime) > zero.
|
||||
// Resolve content datetime: explicit > metadata date > fallback (e.g. import mtime) > zero.
|
||||
var contentDatetime time.Time
|
||||
if p.ContentDatetime != nil {
|
||||
contentDatetime = *p.ContentDatetime
|
||||
@@ -405,7 +426,11 @@ func (s *FileService) Replace(ctx context.Context, id uuid.UUID, p UploadParams)
|
||||
return nil, fmt.Errorf("FileService.Replace: read body: %w", err)
|
||||
}
|
||||
data := buf.Bytes()
|
||||
exifData, _ := extractEXIFWithDatetime(data)
|
||||
var origName string
|
||||
if p.OriginalName != nil {
|
||||
origName = *p.OriginalName
|
||||
}
|
||||
exifData, _ := extractMetadata(data, origName, nil)
|
||||
|
||||
if _, err := s.storage.Save(ctx, id, bytes.NewReader(data)); err != nil {
|
||||
return nil, fmt.Errorf("FileService.Replace: save to storage: %w", err)
|
||||
@@ -525,7 +550,12 @@ func (s *FileService) BulkDelete(ctx context.Context, fileIDs []uuid.UUID) error
|
||||
|
||||
// Import scans a server-side directory and uploads all supported files.
|
||||
// If path is empty, the configured default import path is used.
|
||||
func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, error) {
|
||||
//
|
||||
// onProgress, when non-nil, receives a "start" event, one "file" event per
|
||||
// directory entry as it is processed, and a final "done" event — letting a
|
||||
// caller stream live progress. It is always called from this goroutine (never
|
||||
// concurrently). The aggregate result is also returned for non-streaming callers.
|
||||
func (s *FileService) Import(ctx context.Context, path string, onProgress func(ImportEvent)) (*ImportResult, error) {
|
||||
if s.importPath == "" {
|
||||
return nil, domain.ErrValidation
|
||||
}
|
||||
@@ -546,47 +576,58 @@ func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, e
|
||||
return nil, fmt.Errorf("FileService.Import: read dir %q: %w", dir, err)
|
||||
}
|
||||
|
||||
result := &ImportResult{Errors: []ImportFileError{}}
|
||||
emit := func(ev ImportEvent) {
|
||||
if onProgress != nil {
|
||||
onProgress(ev)
|
||||
}
|
||||
}
|
||||
|
||||
result := &ImportResult{Errors: []ImportFileError{}}
|
||||
total := len(entries)
|
||||
emit(ImportEvent{Type: "start", Total: total})
|
||||
|
||||
for i, entry := range entries {
|
||||
name := entry.Name()
|
||||
file := func(status, reason string) {
|
||||
emit(ImportEvent{
|
||||
Type: "file", Index: i + 1, Total: total,
|
||||
Filename: name, Status: status, Reason: reason,
|
||||
})
|
||||
}
|
||||
fail := func(reason string) {
|
||||
result.Errors = append(result.Errors, ImportFileError{Filename: name, Reason: reason})
|
||||
file("error", reason)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
result.Skipped++
|
||||
file("skipped", "directory")
|
||||
continue
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(dir, entry.Name())
|
||||
fullPath := filepath.Join(dir, name)
|
||||
|
||||
mt, err := mimetype.DetectFile(fullPath)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, ImportFileError{
|
||||
Filename: entry.Name(),
|
||||
Reason: fmt.Sprintf("MIME detection failed: %s", err),
|
||||
})
|
||||
fail(fmt.Sprintf("MIME detection failed: %s", err))
|
||||
continue
|
||||
}
|
||||
|
||||
mimeStr := mt.String()
|
||||
// Strip parameters (e.g. "text/plain; charset=utf-8" → "text/plain").
|
||||
if idx := len(mimeStr); idx > 0 {
|
||||
for i, c := range mimeStr {
|
||||
if c == ';' {
|
||||
mimeStr = mimeStr[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if j := strings.IndexByte(mimeStr, ';'); j >= 0 {
|
||||
mimeStr = mimeStr[:j]
|
||||
}
|
||||
|
||||
if _, err := s.mimes.GetByName(ctx, mimeStr); err != nil {
|
||||
result.Skipped++
|
||||
file("skipped", "unsupported type: "+mimeStr)
|
||||
continue
|
||||
}
|
||||
|
||||
f, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, ImportFileError{
|
||||
Filename: entry.Name(),
|
||||
Reason: fmt.Sprintf("open failed: %s", err),
|
||||
})
|
||||
fail(fmt.Sprintf("open failed: %s", err))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -599,7 +640,6 @@ func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, e
|
||||
mtime = &t
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
_, uploadErr := s.Upload(ctx, UploadParams{
|
||||
Reader: f,
|
||||
MIMEType: mimeStr,
|
||||
@@ -609,10 +649,7 @@ func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, e
|
||||
f.Close()
|
||||
|
||||
if uploadErr != nil {
|
||||
result.Errors = append(result.Errors, ImportFileError{
|
||||
Filename: entry.Name(),
|
||||
Reason: uploadErr.Error(),
|
||||
})
|
||||
fail(uploadErr.Error())
|
||||
continue
|
||||
}
|
||||
result.Imported++
|
||||
@@ -621,13 +658,19 @@ func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, e
|
||||
// doesn't duplicate. The file is already safely copied into storage; a
|
||||
// removal failure is reported but doesn't undo the import.
|
||||
if rmErr := os.Remove(fullPath); rmErr != nil {
|
||||
result.Errors = append(result.Errors, ImportFileError{
|
||||
Filename: entry.Name(),
|
||||
Reason: fmt.Sprintf("imported, but failed to remove source: %s", rmErr),
|
||||
})
|
||||
reason := fmt.Sprintf("imported, but failed to remove source: %s", rmErr)
|
||||
result.Errors = append(result.Errors, ImportFileError{Filename: name, Reason: reason})
|
||||
file("imported", reason) // imported, with a warning
|
||||
continue
|
||||
}
|
||||
file("imported", "")
|
||||
}
|
||||
|
||||
emit(ImportEvent{
|
||||
Type: "done", Total: total,
|
||||
Imported: result.Imported, Skipped: result.Skipped, Errors: len(result.Errors),
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -656,21 +699,3 @@ func confineToBase(base, target string) (string, error) {
|
||||
}
|
||||
return absTarget, nil
|
||||
}
|
||||
|
||||
// extractEXIFWithDatetime parses EXIF from raw bytes, returning both the JSON
|
||||
// representation and the DateTimeOriginal (if present). Both may be nil.
|
||||
func extractEXIFWithDatetime(data []byte) (json.RawMessage, *time.Time) {
|
||||
x, err := exif.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
b, err := x.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
var dt *time.Time
|
||||
if t, err := x.DateTime(); err == nil {
|
||||
dt = &t
|
||||
}
|
||||
return json.RawMessage(b), dt
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rwcarlsen/goexif/exif"
|
||||
)
|
||||
|
||||
// exiftoolPath is resolved once at startup. When exiftool isn't installed we
|
||||
// skip the subprocess and fall back to the pure-Go EXIF reader, so the server
|
||||
// still runs (with thinner metadata) on hosts without it.
|
||||
var exiftoolPath, _ = exec.LookPath("exiftool")
|
||||
|
||||
// metadataTimeout bounds a single exiftool invocation so a pathological file
|
||||
// can't wedge an upload.
|
||||
const metadataTimeout = 30 * time.Second
|
||||
|
||||
// metaTempFileKeys are exiftool fields that describe the temporary file we feed
|
||||
// it rather than the content. Dropping them avoids leaking internal paths and
|
||||
// recording the temp file's permissions/inode timestamps.
|
||||
var metaTempFileKeys = []string{
|
||||
"SourceFile",
|
||||
"Directory",
|
||||
"FileAccessDate",
|
||||
"FileInodeChangeDate",
|
||||
"FilePermissions",
|
||||
}
|
||||
|
||||
// metaDateKeys are the metadata fields, in priority order, holding the moment
|
||||
// the content was actually captured/created — photos first, then video atoms.
|
||||
var metaDateKeys = []string{
|
||||
"DateTimeOriginal",
|
||||
"CreateDate",
|
||||
"MediaCreateDate",
|
||||
"TrackCreateDate",
|
||||
"ModifyDate",
|
||||
}
|
||||
|
||||
// extractMetadata returns rich metadata as JSON plus the best content datetime
|
||||
// it can find. It prefers exiftool, which understands video, audio and every
|
||||
// image format and emits machine-readable numeric values (the basis for later
|
||||
// analytics); when exiftool is unavailable it falls back to the pure-Go EXIF
|
||||
// reader, which only handles JPEG/TIFF.
|
||||
//
|
||||
// originalName supplies the extension exiftool uses for format detection and the
|
||||
// FileName reported back. mtime, when set (e.g. a server-side import), is stamped
|
||||
// onto the temp file so FileModifyDate reflects the real source.
|
||||
func extractMetadata(data []byte, originalName string, mtime *time.Time) (json.RawMessage, *time.Time) {
|
||||
if exiftoolPath != "" {
|
||||
if raw, dt, ok := exiftoolExtract(data, originalName, mtime); ok {
|
||||
return raw, dt
|
||||
}
|
||||
}
|
||||
return extractEXIFWithDatetime(data)
|
||||
}
|
||||
|
||||
// exiftoolExtract stages the bytes in a temp file and shells out to exiftool.
|
||||
// It returns ok=false on any failure so the caller can fall back.
|
||||
func exiftoolExtract(data []byte, originalName string, mtime *time.Time) (json.RawMessage, *time.Time, bool) {
|
||||
// exiftool reads a real file far more reliably than a pipe (it seeks freely,
|
||||
// e.g. to a trailing MP4 moov atom), so stage the bytes in a temp file whose
|
||||
// extension matches the original for accurate format detection.
|
||||
tmp, err := os.CreateTemp("", "tfm-meta-*"+filepath.Ext(originalName))
|
||||
if err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return nil, nil, false
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
if mtime != nil {
|
||||
_ = os.Chtimes(tmpName, *mtime, *mtime)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), metadataTimeout)
|
||||
defer cancel()
|
||||
// -n forces raw numeric/machine values for every tag (no "3.53 Mbps" strings)
|
||||
// so the metadata is analytics-ready. -all extracts every tag. largefilesupport
|
||||
// handles multi-GB videos. Output is a one-element JSON array.
|
||||
out, err := exec.CommandContext(ctx, exiftoolPath,
|
||||
"-n", "-all", "-json", "-api", "largefilesupport=1", tmpName,
|
||||
).Output()
|
||||
if err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
var arr []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(out, &arr); err != nil || len(arr) == 0 {
|
||||
return nil, nil, false
|
||||
}
|
||||
m := arr[0]
|
||||
|
||||
dt := pickMetaDatetime(m)
|
||||
|
||||
// Strip temp-file artifacts and substitute the real name.
|
||||
for _, k := range metaTempFileKeys {
|
||||
delete(m, k)
|
||||
}
|
||||
if mtime == nil {
|
||||
// Without a real source mtime this is just the temp file's write time.
|
||||
delete(m, "FileModifyDate")
|
||||
}
|
||||
if originalName != "" {
|
||||
if nb, err := json.Marshal(originalName); err == nil {
|
||||
m["FileName"] = nb
|
||||
}
|
||||
} else {
|
||||
delete(m, "FileName")
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
return raw, dt, true
|
||||
}
|
||||
|
||||
// pickMetaDatetime returns the first parseable content date among metaDateKeys.
|
||||
func pickMetaDatetime(m map[string]json.RawMessage) *time.Time {
|
||||
for _, key := range metaDateKeys {
|
||||
raw, ok := m[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
continue
|
||||
}
|
||||
if t, ok := parseExifDate(s); ok {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseExifDate parses exiftool's "YYYY:MM:DD HH:MM:SS" timestamps, with or
|
||||
// without a trailing timezone offset. Zeroed placeholders ("0000:00:00 ...")
|
||||
// fail to parse and are skipped by the caller.
|
||||
func parseExifDate(s string) (time.Time, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
for _, layout := range []string{
|
||||
"2006:01:02 15:04:05-07:00",
|
||||
"2006:01:02 15:04:05Z07:00",
|
||||
"2006:01:02 15:04:05",
|
||||
} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// extractEXIFWithDatetime is the pure-Go fallback used when exiftool is absent.
|
||||
// It parses EXIF from raw bytes (JPEG/TIFF only), returning both the JSON
|
||||
// representation and the DateTimeOriginal (if present). Both may be nil.
|
||||
func extractEXIFWithDatetime(data []byte) (json.RawMessage, *time.Time) {
|
||||
x, err := exif.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
b, err := x.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
var dt *time.Time
|
||||
if t, err := x.DateTime(); err == nil {
|
||||
dt = &t
|
||||
}
|
||||
return json.RawMessage(b), dt
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseExifDate(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
ok bool
|
||||
want time.Time
|
||||
}{
|
||||
{"2026:03:24 16:57:58", true, time.Date(2026, 3, 24, 16, 57, 58, 0, time.UTC)},
|
||||
{"2026:05:08 23:07:55+03:00", true, time.Date(2026, 5, 8, 23, 7, 55, 0, time.FixedZone("", 3*3600))},
|
||||
{" 2026:01:02 03:04:05 ", true, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)},
|
||||
{"0000:00:00 00:00:00", false, time.Time{}},
|
||||
{"not a date", false, time.Time{}},
|
||||
{"", false, time.Time{}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := parseExifDate(c.in)
|
||||
if ok != c.ok {
|
||||
t.Errorf("parseExifDate(%q) ok=%v, want %v", c.in, ok, c.ok)
|
||||
continue
|
||||
}
|
||||
if ok && !got.Equal(c.want) {
|
||||
t.Errorf("parseExifDate(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tinyPNG returns a valid 2x3 PNG with no embedded EXIF/date.
|
||||
func tinyPNG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, 2, 3))
|
||||
img.Set(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestExtractMetadataExiftool(t *testing.T) {
|
||||
if exiftoolPath == "" {
|
||||
t.Skip("exiftool not installed; metadata extraction falls back to goexif")
|
||||
}
|
||||
|
||||
raw, dt := extractMetadata(tinyPNG(t), "snapshot.png", nil)
|
||||
if raw == nil {
|
||||
t.Fatal("expected non-nil metadata JSON")
|
||||
}
|
||||
if dt != nil {
|
||||
t.Errorf("a PNG without a capture date should yield no content datetime, got %v", dt)
|
||||
}
|
||||
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("metadata is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
// exiftool understood the format (goexif never would for PNG).
|
||||
if v := jsonString(t, m, "FileType"); v != "PNG" {
|
||||
t.Errorf("FileType = %q, want PNG", v)
|
||||
}
|
||||
|
||||
// Dimensions are numeric, not human-readable strings.
|
||||
for _, key := range []string{"ImageWidth", "ImageHeight"} {
|
||||
raw, ok := m[key]
|
||||
if !ok {
|
||||
t.Errorf("missing %s", key)
|
||||
continue
|
||||
}
|
||||
var n float64
|
||||
if err := json.Unmarshal(raw, &n); err != nil {
|
||||
t.Errorf("%s is not numeric: %s", key, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// FileName is the original, not the temp file; temp-file artifacts are gone.
|
||||
if v := jsonString(t, m, "FileName"); v != "snapshot.png" {
|
||||
t.Errorf("FileName = %q, want snapshot.png", v)
|
||||
}
|
||||
for _, leaked := range []string{"SourceFile", "Directory", "FilePermissions", "FileModifyDate"} {
|
||||
if _, ok := m[leaked]; ok {
|
||||
t.Errorf("temp-file field %q should have been stripped", leaked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func jsonString(t *testing.T, m map[string]json.RawMessage, key string) string {
|
||||
t.Helper()
|
||||
raw, ok := m[key]
|
||||
if !ok {
|
||||
t.Errorf("missing key %q", key)
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
t.Errorf("key %q is not a string: %s", key, raw)
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -23,6 +23,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: tfm
|
||||
restart: unless-stopped
|
||||
|
||||
# All application config (secrets, DATABASE_URL, tunables) comes from .env.
|
||||
|
||||
@@ -166,6 +166,66 @@ export function uploadWithProgress<T>(
|
||||
});
|
||||
}
|
||||
|
||||
/** POST that consumes a streamed newline-delimited JSON (NDJSON) response,
|
||||
* invoking onEvent once per parsed line. Used by the server-side import so the
|
||||
* UI can render live per-file progress. Reuses the bearer token and a single
|
||||
* 401 refresh+retry, but (unlike request()) keeps the body as a stream. */
|
||||
export async function postStream(
|
||||
path: string,
|
||||
body: unknown,
|
||||
onEvent: (ev: Record<string, unknown>) => void
|
||||
): Promise<void> {
|
||||
const init: RequestInit = { method: 'POST', body: JSON.stringify(body) };
|
||||
const send = () =>
|
||||
fetch(BASE + path, { ...init, headers: buildHeaders(init, get(authStore).accessToken) });
|
||||
|
||||
let res = await send();
|
||||
if (res.status === 401) {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = refreshTokens().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
try {
|
||||
await refreshPromise;
|
||||
} catch {
|
||||
throw new ApiError(401, 'unauthorized', 'Session expired');
|
||||
}
|
||||
res = await send();
|
||||
}
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
let b: { code?: string; message?: string } = {};
|
||||
try {
|
||||
b = await res.json();
|
||||
} catch {
|
||||
// ignore parse failure
|
||||
}
|
||||
throw new ApiError(res.status, b.code ?? 'error', b.message ?? res.statusText);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
const flushLine = (line: string) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) onEvent(JSON.parse(trimmed));
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
flushLine(buf.slice(0, nl));
|
||||
buf = buf.slice(nl + 1);
|
||||
}
|
||||
}
|
||||
buf += decoder.decode();
|
||||
flushLine(buf);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { api, ApiError } from '$lib/api/client';
|
||||
import { api, ApiError, postStream } from '$lib/api/client';
|
||||
import { authStore } from '$lib/stores/auth';
|
||||
import { themeStore, toggleTheme } from '$lib/stores/theme';
|
||||
import { appSettings } from '$lib/stores/appSettings';
|
||||
@@ -100,23 +100,66 @@
|
||||
}
|
||||
|
||||
// ---- Server-side import (admin only) ----
|
||||
interface ImportResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
errors: { filename: string; reason: string }[];
|
||||
// The backend streams NDJSON progress events (start → file… → done); we render
|
||||
// a live progress bar and a per-file status list as they arrive.
|
||||
type ImportStatus = 'imported' | 'skipped' | 'error';
|
||||
interface ImportItem {
|
||||
filename: string;
|
||||
status: ImportStatus;
|
||||
reason?: string;
|
||||
}
|
||||
let importPath = $state('');
|
||||
let importing = $state(false);
|
||||
let importError = $state('');
|
||||
let importResult = $state<ImportResult | null>(null);
|
||||
let importTotal = $state(0);
|
||||
let importProcessed = $state(0);
|
||||
let importDone = $state(false);
|
||||
let importItems = $state<ImportItem[]>([]);
|
||||
let importSummary = $state<{ imported: number; skipped: number; errors: number } | null>(null);
|
||||
let importListEl = $state<HTMLUListElement | null>(null);
|
||||
|
||||
// Keep the newest row in view as files stream in.
|
||||
$effect(() => {
|
||||
importItems.length;
|
||||
if (importListEl) importListEl.scrollTop = importListEl.scrollHeight;
|
||||
});
|
||||
|
||||
async function runImport() {
|
||||
importing = true;
|
||||
importError = '';
|
||||
importResult = null;
|
||||
importTotal = 0;
|
||||
importProcessed = 0;
|
||||
importDone = false;
|
||||
importItems = [];
|
||||
importSummary = null;
|
||||
try {
|
||||
const sub = importPath.trim();
|
||||
importResult = await api.post<ImportResult>('/files/import', sub ? { path: sub } : {});
|
||||
await postStream('/files/import', sub ? { path: sub } : {}, (ev) => {
|
||||
switch (ev.type) {
|
||||
case 'start':
|
||||
importTotal = (ev.total as number) ?? 0;
|
||||
break;
|
||||
case 'file':
|
||||
importProcessed = (ev.index as number) ?? importProcessed + 1;
|
||||
importItems.push({
|
||||
filename: (ev.filename as string) ?? '',
|
||||
status: (ev.status as ImportStatus) ?? 'skipped',
|
||||
reason: (ev.reason as string) || undefined
|
||||
});
|
||||
break;
|
||||
case 'done':
|
||||
importDone = true;
|
||||
importSummary = {
|
||||
imported: (ev.imported as number) ?? 0,
|
||||
skipped: (ev.skipped as number) ?? 0,
|
||||
errors: (ev.errors as number) ?? 0
|
||||
};
|
||||
break;
|
||||
case 'error':
|
||||
importError = (ev.reason as string) ?? 'Import failed';
|
||||
break;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
importError = e instanceof ApiError ? e.message : 'Import failed';
|
||||
} finally {
|
||||
@@ -344,17 +387,50 @@
|
||||
{#if importError}
|
||||
<p class="msg error" role="alert">{importError}</p>
|
||||
{/if}
|
||||
{#if importResult}
|
||||
<p class="msg success" role="status">
|
||||
Imported {importResult.imported}, skipped {importResult.skipped}{importResult.errors
|
||||
.length
|
||||
? `, ${importResult.errors.length} error${importResult.errors.length === 1 ? '' : 's'}`
|
||||
: ''}.
|
||||
</p>
|
||||
{#if importResult.errors.length}
|
||||
<ul class="import-errors">
|
||||
{#each importResult.errors as err}
|
||||
<li><span class="err-file">{err.filename}</span> — {err.reason}</li>
|
||||
|
||||
{#if importing || importDone || importItems.length > 0}
|
||||
<div class="import-progress">
|
||||
<div
|
||||
class="progress-track"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={importTotal}
|
||||
aria-valuenow={importProcessed}
|
||||
>
|
||||
<div
|
||||
class="progress-fill"
|
||||
class:done={importDone}
|
||||
style:width={importTotal > 0
|
||||
? `${Math.round((importProcessed / importTotal) * 100)}%`
|
||||
: importDone
|
||||
? '100%'
|
||||
: '0%'}
|
||||
></div>
|
||||
</div>
|
||||
<p class="progress-label">
|
||||
{#if importDone && importSummary}
|
||||
Done — imported {importSummary.imported}, skipped {importSummary.skipped}{importSummary.errors
|
||||
? `, ${importSummary.errors} error${importSummary.errors === 1 ? '' : 's'}`
|
||||
: ''}.
|
||||
{:else if importTotal > 0}
|
||||
Importing… {importProcessed}/{importTotal}
|
||||
{:else}
|
||||
Scanning…
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if importItems.length > 0}
|
||||
<ul class="import-list" bind:this={importListEl}>
|
||||
{#each importItems as item}
|
||||
<li class="import-item {item.status}">
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
<span class="item-file" title={item.filename}>{item.filename}</span>
|
||||
<span class="item-status">{item.status}</span>
|
||||
{#if item.reason}
|
||||
<span class="item-reason">{item.reason}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
@@ -612,24 +688,107 @@
|
||||
}
|
||||
|
||||
/* ---- Server import ---- */
|
||||
.import-errors {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
.import-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border-radius: 7px;
|
||||
background-color: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background-color: color-mix(in srgb, var(--color-accent) 15%, var(--color-bg-primary));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-accent);
|
||||
transition:
|
||||
width 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.progress-fill.done {
|
||||
background-color: #7ecba1;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
max-height: 180px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.import-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.import-errors .err-file {
|
||||
.import-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
flex-shrink: 0;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
align-self: center;
|
||||
background-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.import-item.imported .status-dot {
|
||||
background-color: #7ecba1;
|
||||
}
|
||||
.import-item.error .status-dot {
|
||||
background-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.item-file {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 45%;
|
||||
}
|
||||
|
||||
.import-item.skipped .item-file {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.item-status {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.import-item.error .item-status {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.item-reason {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---- Sessions ---- */
|
||||
|
||||
+37
-10
@@ -654,6 +654,13 @@ paths:
|
||||
not recursed. A successfully imported file is removed from the import
|
||||
folder. For files without an EXIF date, the source file's modified time
|
||||
is used as content_datetime.
|
||||
|
||||
|
||||
Progress is streamed as newline-delimited JSON (`application/x-ndjson`):
|
||||
a single `start` event, one `file` event per directory entry as it is
|
||||
processed, and a final `done` event with the tallies. A validation error
|
||||
raised before any file is touched (e.g. import disabled, bad path) is
|
||||
instead returned as a normal JSON error with a 4xx/5xx status.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -666,25 +673,45 @@ paths:
|
||||
description: Server directory path (uses user's configured import path if omitted)
|
||||
responses:
|
||||
'200':
|
||||
description: Import result
|
||||
description: >
|
||||
A stream of newline-delimited JSON import progress events. The
|
||||
schema below describes a single event line; the response body is one
|
||||
such object per line.
|
||||
content:
|
||||
application/json:
|
||||
application/x-ndjson:
|
||||
schema:
|
||||
type: object
|
||||
required: [type]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [start, file, done, error]
|
||||
description: Event discriminator.
|
||||
total:
|
||||
type: integer
|
||||
description: Entries to process (start) or processed (done).
|
||||
index:
|
||||
type: integer
|
||||
description: 1-based position of this entry (file events).
|
||||
filename:
|
||||
type: string
|
||||
description: Entry name (file events).
|
||||
status:
|
||||
type: string
|
||||
enum: [imported, skipped, error]
|
||||
description: Outcome for this entry (file events).
|
||||
reason:
|
||||
type: string
|
||||
description: Detail for a skipped/error/warning entry.
|
||||
imported:
|
||||
type: integer
|
||||
description: Total imported (done event).
|
||||
skipped:
|
||||
type: integer
|
||||
description: Total skipped (done event).
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
filename:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
type: integer
|
||||
description: Total errors (done event).
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tags
|
||||
|
||||
Reference in New Issue
Block a user