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

+30 -18
View File
@@ -49,11 +49,14 @@ import (
// MediaMetadata contains extracted metadata for media files (ebooks, comics, manga)
type MediaMetadata struct {
Title string
Author string
Subtitle string
Author string // legacy single-value fill; Authors[] is the write truth
Authors []string
Description string
Series string
SeriesNumber int32
Publisher string
SeriesNumber float64
Publisher string // legacy single-value fill; Publishers[] is the write truth
Publishers []string
PublishDate time.Time
Contributors []string
CoverPath string
@@ -61,6 +64,7 @@ type MediaMetadata struct {
ASIN string
Tags []string
Genre string
Explicit bool
FileHashInfo *HashInfo
FileFormats []*FormatInfo
@@ -715,8 +719,8 @@ func (s *MediaScanner) extractFolderStructureMetadata(path, rootFolder string) *
seriesMatch := regexp.MustCompile(`^(.*),\s+Book\s+#(\d+)\s*-\s*(.*)$`).FindStringSubmatch(possibleSeries)
if len(seriesMatch) == 4 {
metadata.Series = strings.ReplaceAll(seriesMatch[1], "_", " ")
if seriesNum, err := strconv.ParseInt(seriesMatch[2], 10, 32); err == nil {
metadata.SeriesNumber = int32(seriesNum)
if seriesNum, err := strconv.ParseFloat(seriesMatch[2], 64); err == nil {
metadata.SeriesNumber = seriesNum
}
metadata.Title = strings.ReplaceAll(possibleTitle, "_", " ")
} else {
@@ -725,11 +729,11 @@ func (s *MediaScanner) extractFolderStructureMetadata(path, rootFolder string) *
metadata.Title = strings.ReplaceAll(possibleTitle, "_", " ")
// Try to extract series number from title
titleNumMatch := regexp.MustCompile(`^(.*)\s+(\d+)$`).FindStringSubmatch(metadata.Title)
titleNumMatch := regexp.MustCompile(`^(.*)\s+(\d+(?:\.\d+)?)$`).FindStringSubmatch(metadata.Title)
if len(titleNumMatch) == 3 {
metadata.Title = titleNumMatch[1]
if seriesNum, err := strconv.ParseInt(titleNumMatch[2], 10, 32); err == nil {
metadata.SeriesNumber = int32(seriesNum)
if seriesNum, err := strconv.ParseFloat(titleNumMatch[2], 64); err == nil {
metadata.SeriesNumber = seriesNum
}
}
}
@@ -1009,7 +1013,9 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
createdItem, err := s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: libraryID,
Title: metadata.Title,
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
Subtitle: pgtype.Text{String: metadata.Subtitle, Valid: metadata.Subtitle != ""},
Authors: nilToEmpty(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
@@ -1018,8 +1024,9 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
MimeType: pgtype.Text{String: s.getMimeType(path), Valid: true},
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
SeriesNumber: pgtype.Float8{Float64: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Publishers: nilToEmpty(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
Contributors: metadata.Contributors,
@@ -1027,6 +1034,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
Narrators: nilToEmpty(metadata.Narrators),
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
Abridged: metadata.Abridged,
Explicit: metadata.Explicit,
Tags: metadata.Tags,
TagsSearch: tagsSearch,
AddedByAdminID: s.adminID,
@@ -1240,8 +1248,8 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
}
if seriesName != "" {
metadata.Series = strings.TrimSpace(seriesName)
if index, err := strconv.ParseFloat(strings.TrimSpace(seriesSequence), 32); err == nil {
metadata.SeriesNumber = int32(index)
if index, err := strconv.ParseFloat(strings.TrimSpace(seriesSequence), 64); err == nil {
metadata.SeriesNumber = index
}
}
if tags := append(append([]string{}, sidecar.Genres...), sidecar.Tags...); len(tags) > 0 {
@@ -1400,7 +1408,7 @@ func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata
metadata.Series = comicInfo.Series
}
if metadata.SeriesNumber == 0 && comicInfo.Number > 0 {
metadata.SeriesNumber = int32(comicInfo.Number)
metadata.SeriesNumber = float64(comicInfo.Number)
}
if metadata.Publisher == "" && comicInfo.Publisher != "" {
metadata.Publisher = comicInfo.Publisher
@@ -2221,7 +2229,7 @@ func parseOPFContent(content []byte) (*MediaMetadata, error) {
if series, index := opf.readSeries(); series != "" {
metadata.Series = series
if index > 0 {
metadata.SeriesNumber = int32(index)
metadata.SeriesNumber = index
}
}
// Calibre-specific meta tags (filter by name attribute)
@@ -3329,20 +3337,24 @@ func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing
params := database.UpdateMediaItemParams{
ID: existing.ID,
Title: metadata.Title,
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
Subtitle: pgtype.Text{String: metadata.Subtitle, Valid: metadata.Subtitle != ""},
Authors: nilToEmpty(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
CoverImagePath: pgtype.Text{String: s.getRelativePath(metadata.CoverPath), Valid: metadata.CoverPath != ""},
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
SeriesNumber: pgtype.Float8{Float64: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Narrators: nilToEmpty(metadata.Narrators),
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
Abridged: metadata.Abridged,
Explicit: metadata.Explicit,
Tags: metadata.Tags,
TagsSearch: tagsSearch,
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
Publishers: nilToEmpty(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
Contributors: metadata.Contributors,
ContributorsSearch: contributorsSearch,
Language: pgtype.Text{String: metadata.Language, Valid: metadata.Language != ""},