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

+32 -11
View File
@@ -39,10 +39,14 @@ type CreateMediaItemRequest struct {
MimeType string `json:"mime_type" validate:"required"`
CoverImagePath string `json:"cover_image_path"`
Series string `json:"series"`
SeriesNumber int32 `json:"series_number"`
SeriesNumber float64 `json:"series_number"`
Tags []string `json:"tags"`
ASIN string `json:"asin"`
DatePublished string `json:"date_published"`
Subtitle string `json:"subtitle"`
Explicit bool `json:"explicit"`
Authors []string `json:"authors"` // write truth; legacy `author` still accepted
Publishers []string `json:"publishers"` // write truth; legacy `publisher` still accepted
Publisher string `json:"publisher"`
Contributors []string `json:"contributors"`
// NEW: Reading direction and comic metadata fields
@@ -69,16 +73,20 @@ type CreateMediaItemRequest struct {
type UpdateMediaItemRequest struct {
Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
Author string `form:"author" json:"author"`
Authors []string `form:"authors" json:"authors"` // write truth; legacy `author` still accepted
Subtitle string `form:"subtitle" json:"subtitle"`
Explicit bool `form:"explicit" json:"explicit"`
ISBN string `form:"isbn" json:"isbn"`
Description string `form:"description" json:"description"`
CoverImagePath string `form:"cover_image_path" json:"cover_image_path"`
CoverAction string `form:"cover_action" json:"cover_action"`
Series string `form:"series" json:"series"`
SeriesNumber int32 `form:"series_number" json:"series_number"`
SeriesNumber float64 `form:"series_number" json:"series_number"`
Tags []string `form:"tags" json:"tags"`
ASIN string `form:"asin" json:"asin"`
DatePublished string `form:"date_published" json:"date_published"`
Publisher string `form:"publisher" json:"publisher"`
Publishers []string `form:"publishers" json:"publishers"` // write truth; legacy `publisher` still accepted
Contributors []string `form:"contributors" json:"contributors"`
Language string `form:"language" json:"language"`
Edition string `form:"edition" json:"edition"`
@@ -588,13 +596,16 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
updateParams := database.UpdateMediaItemParams{
ID: mediaUUID,
Title: existingMedia.Title,
Author: existingMedia.Author,
Subtitle: existingMedia.Subtitle,
Authors: existingMedia.Authors,
AuthorsSearch: existingMedia.AuthorsSearch,
Genre: existingMedia.Genre,
Language: existingMedia.Language,
Tags: existingMedia.Tags,
TagsSearch: existingMedia.TagsSearch,
Description: existingMedia.Description,
Publisher: existingMedia.Publisher,
Publishers: existingMedia.Publishers,
PublishersSearch: existingMedia.PublishersSearch,
CopyrightYear: existingMedia.CopyrightYear,
Isbn: existingMedia.Isbn,
Series: existingMedia.Series,
@@ -623,6 +634,7 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
AlternateInfo: existingMedia.AlternateInfo,
ScanInformation: existingMedia.ScanInformation,
Summary: existingMedia.Summary,
Explicit: existingMedia.Explicit,
}
// Bulk edits are user customizations: keep existing overrides and mark
@@ -633,7 +645,8 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
overrides = utils.MergeOverrides(overrides, utils.OverrideTitle)
}
if update.Updates.Author != nil {
updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true}
updateParams.Authors = []string{*update.Updates.Author}
updateParams.AuthorsSearch = utils.NormalizeTagsSearch(updateParams.Authors)
overrides = utils.MergeOverrides(overrides, utils.OverrideAuthor)
}
if update.Updates.Genre != nil {
@@ -1415,7 +1428,9 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
created, err := mh.db.CreateMediaItemIfNew(c.Request().Context(), database.CreateMediaItemIfNewParams{
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
Subtitle: pgtype.Text{String: req.Subtitle, Valid: req.Subtitle != ""},
Authors: utils.SingleOrSlice(req.Author, req.Authors),
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(req.Author, req.Authors)),
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
FilePath: req.FilePath,
@@ -1423,15 +1438,17 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""},
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
SeriesNumber: pgtype.Float8{Float64: req.SeriesNumber, Valid: req.SeriesNumber > 0},
Tags: req.Tags,
TagsSearch: tagsSearch,
Narrators: narrators,
NarratorsSearch: narratorsSearch,
Abridged: req.Abridged,
Explicit: req.Explicit,
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
Publishers: utils.SingleOrSlice(req.Publisher, req.Publishers),
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(req.Publisher, req.Publishers)),
Contributors: req.Contributors,
ContributorsSearch: contributorsSearch,
AddedByAdminID: user.ID,
@@ -1541,20 +1558,24 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
params := database.UpdateMediaItemParams{
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
Subtitle: pgtype.Text{String: req.Subtitle, Valid: req.Subtitle != ""},
Authors: utils.SingleOrSlice(req.Author, req.Authors),
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(req.Author, req.Authors)),
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
CoverImagePath: pgtype.Text{String: coverPath, Valid: coverPath != ""},
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
SeriesNumber: pgtype.Float8{Float64: req.SeriesNumber, Valid: req.SeriesNumber > 0},
Tags: req.Tags,
TagsSearch: tagsSearch,
Narrators: narrators,
NarratorsSearch: narratorsSearch,
Abridged: req.Abridged,
Explicit: req.Explicit,
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
Publishers: utils.SingleOrSlice(req.Publisher, req.Publishers),
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(req.Publisher, req.Publishers)),
Contributors: req.Contributors,
ContributorsSearch: contributorsSearch,
Language: pgtype.Text{String: req.Language, Valid: req.Language != ""},