feat(metadata): per-field user overrides that survive library rescans

Previously both the metadata editor (PUT /api/media-items/:id) and the
scanner (library scans, force rescans, per-book rescans) wrote through
the same unconditional UPDATE media_items query, so any rescan wiped
user-written descriptions, tags, and uploaded covers. Custom and scanned
values were indistinguishable, and custom cover uploads even wrote to
the same {file}.cover.jpg sidecar path the scanner generates, so each
side silently clobbered the other.

Introduce metadata_overrides, a TEXT[] column on media_items listing the
column names the user has customized:

- Saving metadata records overrides per field by diffing the submitted
  values against the stored row (an untouched save records nothing);
  overrides accumulate until an explicit reset. Cover upload/removal
  always marks cover_image_path. Bulk updates mark each applied field.
- The scanner merges: updateMediaItem() now takes the existing row and
  restores every overridden column (including derived *_search arrays)
  before writing, and preserves the override set itself.
- Uploaded covers move to a dedicated {file}.custom_cover.{jpg|png|webp}
  sidecar so the scanner can never overwrite a user cover on disk.
- RescanMediaItem gains resetOverrides: POST /api/media-items/:id/rescan?
  reset_overrides=true clears the set first, returning the item to pure
  scanned defaults.

Shared detection/restore helpers live in internal/utils
(metadata_overrides.go) with unit tests covering detection, accumulation,
unset-form equality, and restore-with-derived-fields. Schema change is
an idempotent ADD COLUMN IF NOT EXISTS applied on startup. Also includes
incidental gofmt of NewMediaScanner literals in media_scanner.go.
This commit is contained in:
John O'Keefe
2026-09-12 14:21:35 -04:00
parent b9645752f5
commit 7f64b92b9d
10 changed files with 653 additions and 111 deletions
+40 -22
View File
@@ -157,18 +157,18 @@ type CalibreOPFMetadata struct {
// never closed.
func NewMediaScanner(db *database.Queries) *MediaScanner {
return &MediaScanner{
db: db,
watcher: nil,
settingsCache: NewSettingsCache(30 * time.Second),
dirtyDirs: make(map[string]time.Time),
fileStability: make(map[string]*atomic.Bool),
watching: atomic.Bool{},
scanInProgress: atomic.Bool{},
folders: []string{},
adminID: pgtype.UUID{},
db: db,
watcher: nil,
settingsCache: NewSettingsCache(30 * time.Second),
dirtyDirs: make(map[string]time.Time),
fileStability: make(map[string]*atomic.Bool),
watching: atomic.Bool{},
scanInProgress: atomic.Bool{},
folders: []string{},
adminID: pgtype.UUID{},
defaultLibraryID: pgtype.UUID{Valid: false},
libraryTypes: make(map[string][]string),
logger: NewScannerLogger(),
libraryTypes: make(map[string][]string),
logger: NewScannerLogger(),
}
}
@@ -697,7 +697,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
if s.forceRescan {
fmt.Printf("Force rescan enabled, updating existing media item: %s\n", path)
// Use UPDATE instead of DELETE+INSERT to preserve created_at
if err := s.updateMediaItem(ctx, existingItem.ID, path, info); err != nil {
if err := s.updateMediaItem(ctx, existingItem, path); err != nil {
fmt.Printf("Warning: failed to update existing media item: %v\n", err)
}
// Recompute hash identifiers too - a force rescan is the admin's
@@ -708,7 +708,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
// Normal behavior: check if file has changed (by size)
if existingItem.FileSize.Int64 != info.Size() {
fmt.Printf("File size changed, updating media item: %s\n", path)
_ = s.updateMediaItem(ctx, existingItem.ID, path, info)
_ = s.updateMediaItem(ctx, existingItem, path)
// The bytes changed, so any stored hash is stale.
s.recomputeHashInfo(ctx, existingItem.ID, libraryID, path)
return false, nil
@@ -758,7 +758,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
fmt.Printf("Media item with same SHA-256 already exists in library (path %q), skipping duplicate: %s\n",
existingByHash.FilePath, path)
if s.forceRescan {
_ = s.updateMediaItem(ctx, existingByHash.ID, path, info)
_ = s.updateMediaItem(ctx, existingByHash, path)
}
return false, nil
} else if err != nil && !errors.Is(err, pgx.ErrNoRows) {
@@ -2605,7 +2605,11 @@ func countArchiveImages(filePath string) (int, error) {
return count, nil
}
func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.UUID, path string, _ os.FileInfo) error {
// updateMediaItem re-extracts metadata for an existing media item and writes
// it back. Fields listed in the item's metadata_overrides set are preserved
// from the existing row so rescans never clobber user customizations; only
// reset-to-scanned-defaults (RescanMediaItem with reset) clears them.
func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.MediaItems, path string) error {
// Re-extract metadata for the update
metadata, err := s.extractMetadata(path)
if err != nil {
@@ -2624,8 +2628,8 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U
if metadata.AlternateInfo != "" {
alternateInfoBytes = []byte(metadata.AlternateInfo)
}
_, err = s.db.UpdateMediaItem(ctx, database.UpdateMediaItemParams{
ID: mediaItemID,
params := database.UpdateMediaItemParams{
ID: existing.ID,
Title: metadata.Title,
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
@@ -2657,19 +2661,34 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U
AlternateInfo: alternateInfoBytes,
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
})
}
// Keep user-customized fields, and keep the override set itself intact.
utils.ApplyMetadataOverrides(&params, existing)
params.MetadataOverrides = existing.MetadataOverrides
_, err = s.db.UpdateMediaItem(ctx, params)
return err
}
// RescanMediaItem re-extracts metadata for a single media item and updates it.
// It is the per-book rescan used by the Edit Metadata dialog and backfills
// covers for items imported before the PDF render fallback existed.
func (s *MediaScanner) RescanMediaItem(ctx context.Context, mediaItemID pgtype.UUID) error {
// With resetOverrides, user customizations are discarded first: the item is
// returned to pure scanned defaults (the "Reset to Scanned" action).
func (s *MediaScanner) RescanMediaItem(ctx context.Context, mediaItemID pgtype.UUID, resetOverrides bool) error {
item, err := s.db.GetMediaItem(ctx, mediaItemID)
if err != nil {
return fmt.Errorf("media item not found: %w", err)
}
if resetOverrides {
if err := s.db.ClearMediaItemMetadataOverrides(ctx, mediaItemID); err != nil {
return fmt.Errorf("failed to clear metadata overrides: %w", err)
}
item.MetadataOverrides = nil
}
folders, err := s.db.GetLibraryFolders(ctx, item.LibraryID)
if err != nil || len(folders) == 0 {
return fmt.Errorf("no library folders found for library")
@@ -2693,12 +2712,11 @@ func (s *MediaScanner) RescanMediaItem(ctx context.Context, mediaItemID pgtype.U
return fmt.Errorf("media file not found on disk: %s", item.FilePath)
}
info, err := os.Stat(fullPath)
if err != nil {
if _, err := os.Stat(fullPath); err != nil {
return fmt.Errorf("failed to stat media file: %w", err)
}
if err := s.updateMediaItem(ctx, mediaItemID, fullPath, info); err != nil {
if err := s.updateMediaItem(ctx, item, fullPath); err != nil {
return fmt.Errorf("failed to update media item: %w", err)
}
s.recomputeHashInfo(ctx, mediaItemID, item.LibraryID, fullPath)