feat(schema): subtitle/explicit columns, authors+publishers arrays, float series numbers

Phase 1 of the series/collections/metadata overhaul
(~/.opencode/plan/series-collections-metadata-overhaul.md).

Schema (in place, idempotent):
- media_items.series_number INTEGER -> DOUBLE PRECISION: real-world
  ordinals are decimals (0.5, 1.5 novellas); INTEGER silently truncated
  them (calibre 2.5 pinned as 2 in tests).
- + subtitle TEXT, explicit BOOLEAN (sidecar ingest; ABS explicit /
  Audible isAdult).
- + authors/authors_search/publishers/publishers_search TEXT[] NOT NULL
  (narrators A2a precedent). Arrays are write truth; author/publisher
  varchars become trigger-regenerated display strings
  (sync_author_publisher_display) so every varchar consumer (trgm
  search, facets, API, OPDS/Kobo) keeps working unchanged.
- Row-level NULL normalization for the new arrays in
  set_library_type_name (A2 lesson: Go nil -> explicit NULL -> 23502).
- Backfills ARRAY[author]/ARRAY[publisher] — deliberately no
  comma-splitting ('Tolkien, J.R.R.' would corrupt; scanner kept only
  authors[0] historically).
- collection_items.position INTEGER + (collection_id, position) index
  for playlist ordering.

Go: sqlc regen; MediaMetadata/handler params gain Subtitle/Explicit/
Authors/Publishers (legacy singles still accepted, arrays win);
SeriesNumber int32 -> float64 end to end; metadata_overrides adapted
with legacy override keys ('author'/'publisher') preserved so existing
user overrides survive; bulk author edit writes the array.

Pins widened (docs/contract_pins.md): raw sqlc row key set + the six
new columns; changelog entry added.

Verified: unit suite green; integration sweep (bulk/smart collections/
scanner/stats/sync/queue/calibre/pins/audiobooks) green against the
shared dev DB after in-place migration. Pre-existing unrelated red:
TestOwnershipAndAdminTestSuite/TestAdminCreateUser timezone inheritance
fails identically at the pre-change commit (live shared state).
This commit is contained in:
John O'Keefe committed 2026-10-19 14:17:50 -04:00
1 parent f7dc8189a1
commit 646477afad
30 files changed
+790 -247

No files matched your search

+16 -7
View File
@@ -18,7 +18,7 @@ import (
const (
OverrideTitle = "title"
OverrideAuthor = "author"
OverrideAuthor = "author" // protects the authors[] array (legacy key preserved)
OverrideISBN = "isbn"
OverrideDescription = "description"
OverrideCoverImagePath = "cover_image_path"
@@ -29,7 +29,9 @@ const (
OverrideTags = "tags"
OverrideAsin = "asin"
OverrideDatePublished = "date_published"
OverridePublisher = "publisher"
OverrideSubtitle = "subtitle"
OverrideExplicit = "explicit"
OverridePublisher = "publisher" // protects the publishers[] array (legacy key preserved)
OverrideContributors = "contributors"
OverrideLanguage = "language"
OverrideEdition = "edition"
@@ -150,17 +152,19 @@ func detectMetadataOverridesDiff(params database.UpdateMediaItemParams, existing
}
add(OverrideTitle, params.Title != existing.Title)
add(OverrideAuthor, normText(params.Author) != normText(existing.Author))
add(OverrideSubtitle, normText(params.Subtitle) != normText(existing.Subtitle))
add(OverrideAuthor, normStringSlice(params.Authors) != normStringSlice(existing.Authors))
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(OverrideSeriesNumber, normFloat(params.SeriesNumber) != normFloat(existing.SeriesNumber))
add(OverrideTags, normStringSlice(params.Tags) != normStringSlice(existing.Tags))
add(OverrideNarrators, normStringSlice(params.Narrators) != normStringSlice(existing.Narrators))
add(OverrideAbridged, params.Abridged != existing.Abridged)
add(OverrideExplicit, params.Explicit != existing.Explicit)
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(OverridePublisher, normStringSlice(params.Publishers) != normStringSlice(existing.Publishers))
add(OverrideContributors, normStringSlice(params.Contributors) != normStringSlice(existing.Contributors))
add(OverrideLanguage, normText(params.Language) != normText(existing.Language))
add(OverrideEdition, normText(params.Edition) != normText(existing.Edition))
@@ -205,8 +209,12 @@ func ApplyMetadataOverrides(params *database.UpdateMediaItemParams, existing dat
if hasOverride(o, OverrideTitle) {
params.Title = existing.Title
}
if hasOverride(o, OverrideSubtitle) {
params.Subtitle = existing.Subtitle
}
if hasOverride(o, OverrideAuthor) {
params.Author = existing.Author
params.Authors = existing.Authors
params.AuthorsSearch = existing.AuthorsSearch
}
if hasOverride(o, OverrideISBN) {
params.Isbn = existing.Isbn
@@ -241,7 +249,8 @@ func ApplyMetadataOverrides(params *database.UpdateMediaItemParams, existing dat
params.DatePublished = existing.DatePublished
}
if hasOverride(o, OverridePublisher) {
params.Publisher = existing.Publisher
params.Publishers = existing.Publishers
params.PublishersSearch = existing.PublishersSearch
}
if hasOverride(o, OverrideContributors) {
params.Contributors = existing.Contributors
+2 -2
View File
@@ -63,12 +63,12 @@ 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},
SeriesNumber: pgtype.Float8{Float64: 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
SeriesNumber: pgtype.Float8{Float64: 0, Valid: false}, // cleared by user -> changed
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
}
got := DetectMetadataOverrides(params, existing)
+15
View File
@@ -183,6 +183,21 @@ func NormalizeNarratorsSearch(narrators []string) []string {
return NormalizeTagsSearch(narrators)
}
// SingleOrSlice returns the write-truth array for a field that has both a
// legacy single-value form and an array form: the explicit array when
// populated, else the single string as a one-element slice. Used by the
// authors/publishers arrays (`author`/`publisher` varchars regenerate from
// them via the sync_author_publisher_display trigger).
func SingleOrSlice(single string, arr []string) []string {
if len(arr) > 0 {
return arr
}
if single == "" {
return []string{}
}
return []string{single}
}
// NormalizeContributors normalizes an array of contributors for display:
// 1. Trim whitespace
// 2. Preserve original casing (including CAPSLOCK companies)