feat(backend): drain import folder and keep mtime on server-side import

The directory import now removes each source file after it is safely
ingested, so the import folder drains and re-running doesn't create
duplicates (a removal failure is reported per-file but doesn't undo the
import). It also captures the source file's mtime and passes it as a new
ContentDatetimeFallback on Upload, used for content_datetime only when
the file has no EXIF date — so non-photo files keep a meaningful date
instead of the zero value once the source is gone. Adds an integration
test covering ingest, directory skip, source removal and the mtime
fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 18:19:59 +03:00
parent bce79867e4
commit 1b04d67e20
2 changed files with 95 additions and 13 deletions
+32 -6
View File
@@ -33,8 +33,12 @@ type UploadParams struct {
Notes *string
Metadata json.RawMessage
ContentDatetime *time.Time
IsPublic bool
TagIDs []uuid.UUID
// ContentDatetimeFallback is used for content_datetime only when neither an
// explicit ContentDatetime nor an EXIF date is available (e.g. the source
// file's mtime on a server-side import).
ContentDatetimeFallback *time.Time
IsPublic bool
TagIDs []uuid.UUID
}
// UpdateParams holds the parameters for updating file metadata.
@@ -128,12 +132,14 @@ func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File,
// Extract EXIF metadata (best-effort; non-image files will error silently).
exifData, exifDatetime := extractEXIFWithDatetime(data)
// Resolve content datetime: explicit > EXIF > zero value.
// Resolve content datetime: explicit > EXIF > fallback (e.g. import mtime) > zero.
var contentDatetime time.Time
if p.ContentDatetime != nil {
contentDatetime = *p.ContentDatetime
} else if exifDatetime != nil {
contentDatetime = *exifDatetime
} else if p.ContentDatetimeFallback != nil {
contentDatetime = *p.ContentDatetimeFallback
}
// Assign UUID v7 so CreatedAt can be derived from it later.
@@ -584,11 +590,21 @@ func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, e
continue
}
// Preserve the file's mtime as a content_datetime fallback (used only when
// the file has no EXIF date) — once the source is removed below it's the
// only date left for non-photo files.
var mtime *time.Time
if info, statErr := entry.Info(); statErr == nil {
t := info.ModTime()
mtime = &t
}
name := entry.Name()
_, uploadErr := s.Upload(ctx, UploadParams{
Reader: f,
MIMEType: mimeStr,
OriginalName: &name,
Reader: f,
MIMEType: mimeStr,
OriginalName: &name,
ContentDatetimeFallback: mtime,
})
f.Close()
@@ -600,6 +616,16 @@ func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, e
continue
}
result.Imported++
// Remove the source on success so the import folder drains and re-running
// 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),
})
}
}
return result, nil