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:
@@ -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(¶ms, 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)
|
||||
|
||||
Reference in New Issue
Block a user