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.
306 lines
10 KiB
Go
306 lines
10 KiB
Go
package utils
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
|
|
"bookhoard/internal/database"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
// Per-field user-override tracking for media item metadata.
|
|
//
|
|
// metadata_overrides is a TEXT[] column on media_items holding the names of
|
|
// columns the user has customized via the metadata editor. Library scans and
|
|
// per-book rescans MUST preserve those columns (applyMetadataOverrides);
|
|
// only the reset-to-scanned-defaults action clears the set.
|
|
|
|
const (
|
|
OverrideTitle = "title"
|
|
OverrideAuthor = "author"
|
|
OverrideISBN = "isbn"
|
|
OverrideDescription = "description"
|
|
OverrideCoverImagePath = "cover_image_path"
|
|
OverrideSeries = "series"
|
|
OverrideSeriesNumber = "series_number"
|
|
OverrideTags = "tags"
|
|
OverrideAsin = "asin"
|
|
OverrideDatePublished = "date_published"
|
|
OverridePublisher = "publisher"
|
|
OverrideContributors = "contributors"
|
|
OverrideLanguage = "language"
|
|
OverrideEdition = "edition"
|
|
OverridePageCount = "page_count"
|
|
OverrideGenre = "genre"
|
|
OverrideCopyrightYear = "copyright_year"
|
|
OverrideGoodreadsID = "goodreads_id"
|
|
OverrideOpenlibraryID = "openlibrary_id"
|
|
OverrideGoogleBooksID = "google_books_id"
|
|
OverrideMangaType = "manga_type"
|
|
OverrideReadingDirection = "reading_direction"
|
|
OverrideSeriesCount = "series_count"
|
|
OverrideVolume = "volume"
|
|
OverrideImprint = "imprint"
|
|
OverrideAgeRating = "age_rating"
|
|
OverrideWebURL = "web_url"
|
|
OverrideMetadataNotes = "metadata_notes"
|
|
OverrideCommunityRating = "community_rating"
|
|
OverrideStoryArc = "story_arc"
|
|
OverrideIsBlackAndWhite = "is_black_and_white"
|
|
OverrideAlternateInfo = "alternate_info"
|
|
OverrideScanInformation = "scan_information"
|
|
OverrideSummary = "summary"
|
|
)
|
|
|
|
// hasOverride reports whether key is in the override set.
|
|
func hasOverride(overrides []string, key string) bool {
|
|
for _, k := range overrides {
|
|
if k == key {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// MergeOverrides unions existing and added, preserving order and dropping
|
|
// duplicates. Returns a non-nil slice so it always satisfies NOT NULL columns.
|
|
func MergeOverrides(existing []string, added ...string) []string {
|
|
seen := make(map[string]bool, len(existing)+len(added))
|
|
merged := make([]string, 0, len(existing)+len(added))
|
|
for _, k := range existing {
|
|
if k != "" && !seen[k] {
|
|
seen[k] = true
|
|
merged = append(merged, k)
|
|
}
|
|
}
|
|
for _, k := range added {
|
|
if k != "" && !seen[k] {
|
|
seen[k] = true
|
|
merged = append(merged, k)
|
|
}
|
|
}
|
|
return merged
|
|
}
|
|
|
|
// Normalizers turn nullable column values into comparable strings so that
|
|
// "unset" (invalid/zero) forms compare equal regardless of which side they
|
|
// come from.
|
|
|
|
func normText(t pgtype.Text) string {
|
|
if !t.Valid {
|
|
return ""
|
|
}
|
|
return t.String
|
|
}
|
|
|
|
func normInt(i pgtype.Int4) string {
|
|
if !i.Valid {
|
|
return ""
|
|
}
|
|
return strconv.FormatInt(int64(i.Int32), 10)
|
|
}
|
|
|
|
func normFloat(f pgtype.Float8) string {
|
|
if !f.Valid {
|
|
return ""
|
|
}
|
|
return strconv.FormatFloat(f.Float64, 'g', -1, 64)
|
|
}
|
|
|
|
func normBool(b pgtype.Bool) string {
|
|
if !b.Valid {
|
|
return ""
|
|
}
|
|
return strconv.FormatBool(b.Bool)
|
|
}
|
|
|
|
func normDate(d pgtype.Date) string {
|
|
if !d.Valid {
|
|
return ""
|
|
}
|
|
return d.Time.Format("2006-01-02")
|
|
}
|
|
|
|
func normStringSlice(s []string) string {
|
|
if len(s) == 0 {
|
|
return ""
|
|
}
|
|
return strings.Join(s, "\x1f")
|
|
}
|
|
|
|
func normBytes(b []byte) string {
|
|
if len(b) == 0 {
|
|
return ""
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// detectMetadataOverridesDiff returns the keys whose value in params differs
|
|
// from the existing row. Used by the metadata editor save path to grow the
|
|
// override set with exactly the fields the user changed.
|
|
func detectMetadataOverridesDiff(params database.UpdateMediaItemParams, existing database.MediaItems) []string {
|
|
var changed []string
|
|
add := func(key string, differs bool) {
|
|
if differs {
|
|
changed = append(changed, key)
|
|
}
|
|
}
|
|
|
|
add(OverrideTitle, params.Title != existing.Title)
|
|
add(OverrideAuthor, normText(params.Author) != normText(existing.Author))
|
|
add(OverrideISBN, normText(params.Isbn) != normText(existing.Isbn))
|
|
add(OverrideDescription, normText(params.Description) != normText(existing.Description))
|
|
add(OverrideSeries, normText(params.Series) != normText(existing.Series))
|
|
add(OverrideSeriesNumber, normInt(params.SeriesNumber) != normInt(existing.SeriesNumber))
|
|
add(OverrideTags, normStringSlice(params.Tags) != normStringSlice(existing.Tags))
|
|
add(OverrideAsin, normText(params.Asin) != normText(existing.Asin))
|
|
add(OverrideDatePublished, normDate(params.DatePublished) != normDate(existing.DatePublished))
|
|
add(OverridePublisher, normText(params.Publisher) != normText(existing.Publisher))
|
|
add(OverrideContributors, normStringSlice(params.Contributors) != normStringSlice(existing.Contributors))
|
|
add(OverrideLanguage, normText(params.Language) != normText(existing.Language))
|
|
add(OverrideEdition, normText(params.Edition) != normText(existing.Edition))
|
|
add(OverridePageCount, normInt(params.PageCount) != normInt(existing.PageCount))
|
|
add(OverrideGenre, normText(params.Genre) != normText(existing.Genre))
|
|
add(OverrideCopyrightYear, normInt(params.CopyrightYear) != normInt(existing.CopyrightYear))
|
|
add(OverrideGoodreadsID, normText(params.GoodreadsID) != normText(existing.GoodreadsID))
|
|
add(OverrideOpenlibraryID, normText(params.OpenlibraryID) != normText(existing.OpenlibraryID))
|
|
add(OverrideGoogleBooksID, normText(params.GoogleBooksID) != normText(existing.GoogleBooksID))
|
|
add(OverrideMangaType, normText(params.MangaType) != normText(existing.MangaType))
|
|
add(OverrideReadingDirection, normText(params.ReadingDirection) != normText(existing.ReadingDirection))
|
|
add(OverrideSeriesCount, normInt(params.SeriesCount) != normInt(existing.SeriesCount))
|
|
add(OverrideVolume, normInt(params.Volume) != normInt(existing.Volume))
|
|
add(OverrideImprint, normText(params.Imprint) != normText(existing.Imprint))
|
|
add(OverrideAgeRating, normText(params.AgeRating) != normText(existing.AgeRating))
|
|
add(OverrideWebURL, normText(params.WebUrl) != normText(existing.WebUrl))
|
|
add(OverrideMetadataNotes, normText(params.MetadataNotes) != normText(existing.MetadataNotes))
|
|
add(OverrideCommunityRating, normFloat(params.CommunityRating) != normFloat(existing.CommunityRating))
|
|
add(OverrideStoryArc, normText(params.StoryArc) != normText(existing.StoryArc))
|
|
add(OverrideIsBlackAndWhite, normBool(params.IsBlackAndWhite) != normBool(existing.IsBlackAndWhite))
|
|
add(OverrideAlternateInfo, normBytes(params.AlternateInfo) != normBytes(existing.AlternateInfo))
|
|
add(OverrideScanInformation, normText(params.ScanInformation) != normText(existing.ScanInformation))
|
|
add(OverrideSummary, normText(params.Summary) != normText(existing.Summary))
|
|
|
|
return changed
|
|
}
|
|
|
|
// DetectMetadataOverrides returns the union of the existing override set and
|
|
// any fields whose incoming (user-submitted) values differ from the stored
|
|
// row. Overrides accumulate: a field stays protected until an explicit reset,
|
|
// even if a later save reverts the value.
|
|
func DetectMetadataOverrides(params database.UpdateMediaItemParams, existing database.MediaItems) []string {
|
|
return MergeOverrides(existing.MetadataOverrides, detectMetadataOverridesDiff(params, existing)...)
|
|
}
|
|
|
|
// ApplyMetadataOverrides restores every overridden field in params from the
|
|
// existing row so scanner updates cannot clobber user customizations. Derived
|
|
// search columns are restored together with their base column.
|
|
func ApplyMetadataOverrides(params *database.UpdateMediaItemParams, existing database.MediaItems) {
|
|
o := existing.MetadataOverrides
|
|
|
|
if hasOverride(o, OverrideTitle) {
|
|
params.Title = existing.Title
|
|
}
|
|
if hasOverride(o, OverrideAuthor) {
|
|
params.Author = existing.Author
|
|
}
|
|
if hasOverride(o, OverrideISBN) {
|
|
params.Isbn = existing.Isbn
|
|
}
|
|
if hasOverride(o, OverrideDescription) {
|
|
params.Description = existing.Description
|
|
}
|
|
if hasOverride(o, OverrideCoverImagePath) {
|
|
params.CoverImagePath = existing.CoverImagePath
|
|
}
|
|
if hasOverride(o, OverrideSeries) {
|
|
params.Series = existing.Series
|
|
}
|
|
if hasOverride(o, OverrideSeriesNumber) {
|
|
params.SeriesNumber = existing.SeriesNumber
|
|
}
|
|
if hasOverride(o, OverrideTags) {
|
|
params.Tags = existing.Tags
|
|
params.TagsSearch = existing.TagsSearch
|
|
}
|
|
if hasOverride(o, OverrideAsin) {
|
|
params.Asin = existing.Asin
|
|
}
|
|
if hasOverride(o, OverrideDatePublished) {
|
|
params.DatePublished = existing.DatePublished
|
|
}
|
|
if hasOverride(o, OverridePublisher) {
|
|
params.Publisher = existing.Publisher
|
|
}
|
|
if hasOverride(o, OverrideContributors) {
|
|
params.Contributors = existing.Contributors
|
|
params.ContributorsSearch = existing.ContributorsSearch
|
|
}
|
|
if hasOverride(o, OverrideLanguage) {
|
|
params.Language = existing.Language
|
|
}
|
|
if hasOverride(o, OverrideEdition) {
|
|
params.Edition = existing.Edition
|
|
}
|
|
if hasOverride(o, OverridePageCount) {
|
|
params.PageCount = existing.PageCount
|
|
}
|
|
if hasOverride(o, OverrideGenre) {
|
|
params.Genre = existing.Genre
|
|
}
|
|
if hasOverride(o, OverrideCopyrightYear) {
|
|
params.CopyrightYear = existing.CopyrightYear
|
|
}
|
|
if hasOverride(o, OverrideGoodreadsID) {
|
|
params.GoodreadsID = existing.GoodreadsID
|
|
}
|
|
if hasOverride(o, OverrideOpenlibraryID) {
|
|
params.OpenlibraryID = existing.OpenlibraryID
|
|
}
|
|
if hasOverride(o, OverrideGoogleBooksID) {
|
|
params.GoogleBooksID = existing.GoogleBooksID
|
|
}
|
|
if hasOverride(o, OverrideMangaType) {
|
|
params.MangaType = existing.MangaType
|
|
}
|
|
if hasOverride(o, OverrideReadingDirection) {
|
|
params.ReadingDirection = existing.ReadingDirection
|
|
}
|
|
if hasOverride(o, OverrideSeriesCount) {
|
|
params.SeriesCount = existing.SeriesCount
|
|
}
|
|
if hasOverride(o, OverrideVolume) {
|
|
params.Volume = existing.Volume
|
|
}
|
|
if hasOverride(o, OverrideImprint) {
|
|
params.Imprint = existing.Imprint
|
|
}
|
|
if hasOverride(o, OverrideAgeRating) {
|
|
params.AgeRating = existing.AgeRating
|
|
}
|
|
if hasOverride(o, OverrideWebURL) {
|
|
params.WebUrl = existing.WebUrl
|
|
}
|
|
if hasOverride(o, OverrideMetadataNotes) {
|
|
params.MetadataNotes = existing.MetadataNotes
|
|
}
|
|
if hasOverride(o, OverrideCommunityRating) {
|
|
params.CommunityRating = existing.CommunityRating
|
|
}
|
|
if hasOverride(o, OverrideStoryArc) {
|
|
params.StoryArc = existing.StoryArc
|
|
}
|
|
if hasOverride(o, OverrideIsBlackAndWhite) {
|
|
params.IsBlackAndWhite = existing.IsBlackAndWhite
|
|
}
|
|
if hasOverride(o, OverrideAlternateInfo) {
|
|
params.AlternateInfo = existing.AlternateInfo
|
|
}
|
|
if hasOverride(o, OverrideScanInformation) {
|
|
params.ScanInformation = existing.ScanInformation
|
|
}
|
|
if hasOverride(o, OverrideSummary) {
|
|
params.Summary = existing.Summary
|
|
}
|
|
}
|