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.
113 lines
3.9 KiB
Go
113 lines
3.9 KiB
Go
package utils
|
|
|
|
import (
|
|
"reflect"
|
|
"testing"
|
|
|
|
"bookhoard/internal/database"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
func TestMergeOverrides(t *testing.T) {
|
|
got := MergeOverrides([]string{"title", "tags"}, "tags", "description", "")
|
|
want := []string{"title", "tags", "description"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("MergeOverrides() = %v, want %v", got, want)
|
|
}
|
|
if MergeOverrides(nil) == nil {
|
|
t.Error("MergeOverrides(nil) must return non-nil slice")
|
|
}
|
|
}
|
|
|
|
func TestDetectMetadataOverrides(t *testing.T) {
|
|
existing := database.MediaItems{
|
|
Title: "Scanned Title",
|
|
Description: pgtype.Text{String: "Scanned description", Valid: true},
|
|
Tags: []string{"foo", "bar"},
|
|
}
|
|
params := database.UpdateMediaItemParams{
|
|
// unchanged
|
|
Title: "Scanned Title",
|
|
// changed
|
|
Description: pgtype.Text{String: "My custom description", Valid: true},
|
|
Tags: []string{"foo", "bar"},
|
|
}
|
|
|
|
got := DetectMetadataOverrides(params, existing)
|
|
if !reflect.DeepEqual(got, []string{"description"}) {
|
|
t.Errorf("DetectMetadataOverrides() = %v, want [description]", got)
|
|
}
|
|
|
|
// Overrides accumulate: an existing override survives a later save.
|
|
existing.MetadataOverrides = []string{"publisher"}
|
|
got = DetectMetadataOverrides(params, existing)
|
|
want := []string{"publisher", "description"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("DetectMetadataOverrides() accumulate = %v, want %v", got, want)
|
|
}
|
|
|
|
// An untouched save detects nothing new.
|
|
noop := database.UpdateMediaItemParams{
|
|
Title: existing.Title,
|
|
Description: existing.Description,
|
|
Tags: existing.Tags,
|
|
}
|
|
got = DetectMetadataOverrides(noop, existing)
|
|
if !reflect.DeepEqual(got, []string{"publisher"}) {
|
|
t.Errorf("DetectMetadataOverrides() noop = %v, want [publisher]", got)
|
|
}
|
|
}
|
|
|
|
func TestDetectMetadataOverridesUnsetFormsEqual(t *testing.T) {
|
|
// Zero-value params must not look "changed" against NULL-ish columns.
|
|
existing := database.MediaItems{
|
|
PageCount: pgtype.Int4{Int32: 0, Valid: false},
|
|
SeriesNumber: pgtype.Int4{Int32: 5, Valid: true},
|
|
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
|
|
}
|
|
params := database.UpdateMediaItemParams{
|
|
PageCount: pgtype.Int4{Int32: 0, Valid: false},
|
|
SeriesNumber: pgtype.Int4{Int32: 0, Valid: false}, // cleared by user -> changed
|
|
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
|
|
}
|
|
got := DetectMetadataOverrides(params, existing)
|
|
if !reflect.DeepEqual(got, []string{"series_number"}) {
|
|
t.Errorf("DetectMetadataOverrides() = %v, want [series_number]", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyMetadataOverrides(t *testing.T) {
|
|
existing := database.MediaItems{
|
|
Title: "Custom Title",
|
|
Description: pgtype.Text{String: "Custom desc", Valid: true},
|
|
Tags: []string{"mine"},
|
|
TagsSearch: []string{"mine"},
|
|
CoverImagePath: pgtype.Text{String: "books/x.epub.custom_cover.jpg", Valid: true},
|
|
}
|
|
params := database.UpdateMediaItemParams{
|
|
Title: "Scanned Title",
|
|
Description: pgtype.Text{String: "Scanned desc", Valid: true},
|
|
Tags: []string{"scanned"},
|
|
TagsSearch: []string{"scanned"},
|
|
CoverImagePath: pgtype.Text{String: "books/x.epub.cover.jpg", Valid: true},
|
|
}
|
|
|
|
// Only title and tags overridden; scanned description and cover win.
|
|
existing.MetadataOverrides = []string{"title", "tags"}
|
|
ApplyMetadataOverrides(¶ms, existing)
|
|
|
|
if params.Title != "Custom Title" {
|
|
t.Errorf("Title = %q, want %q", params.Title, "Custom Title")
|
|
}
|
|
if !reflect.DeepEqual(params.Tags, []string{"mine"}) || !reflect.DeepEqual(params.TagsSearch, []string{"mine"}) {
|
|
t.Error("Tags/TagsSearch must be restored together")
|
|
}
|
|
if params.Description.String != "Scanned desc" {
|
|
t.Errorf("Description = %q, want scanned value", params.Description.String)
|
|
}
|
|
if params.CoverImagePath.String != "books/x.epub.cover.jpg" {
|
|
t.Errorf("CoverImagePath = %q, want scanned value", params.CoverImagePath.String)
|
|
}
|
|
}
|