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:
1 parent
f7dc8189a1
commit
646477afad
30 files changed
+790
-247
No files matched your search
@@ -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 != ""},
|
||||
|
||||
@@ -437,8 +437,8 @@ func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) {
|
||||
if series := report.tag("grouping", "----:com.apple.itunes:series"); series != "" {
|
||||
metadata.Series = series
|
||||
if part := report.tag("----:com.apple.itunes:series-part"); part != "" {
|
||||
if idx, err := strconv.ParseFloat(strings.TrimSpace(part), 32); err == nil {
|
||||
metadata.SeriesNumber = int32(idx)
|
||||
if idx, err := strconv.ParseFloat(strings.TrimSpace(part), 64); err == nil {
|
||||
metadata.SeriesNumber = idx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ func TestApplyAudioTagFallbacks(t *testing.T) {
|
||||
t.Fatal("series not filled from tags")
|
||||
}
|
||||
if meta.SeriesNumber != 4 {
|
||||
t.Fatalf("series number = %d", meta.SeriesNumber)
|
||||
t.Fatalf("series number = %v", meta.SeriesNumber)
|
||||
}
|
||||
if meta.ASIN != "B0ASIN1234" {
|
||||
t.Fatalf("asin = %q", meta.ASIN)
|
||||
|
||||
@@ -536,7 +536,9 @@ func (s *MediaScanner) createAudioBookItem(ctx context.Context, libraryID pgtype
|
||||
return 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 != ""},
|
||||
@@ -545,14 +547,16 @@ func (s *MediaScanner) createAudioBookItem(ctx context.Context, libraryID pgtype
|
||||
MimeType: pgtype.Text{String: mimeType, Valid: mimeType != ""},
|
||||
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: utils.NormalizeContributors(metadata.Contributors),
|
||||
Narrators: nilToEmpty(metadata.Narrators),
|
||||
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
||||
Abridged: metadata.Abridged,
|
||||
Explicit: metadata.Explicit,
|
||||
Tags: utils.NormalizeTags(metadata.Tags),
|
||||
AddedByAdminID: s.adminID,
|
||||
CreatedAt: modTime,
|
||||
|
||||
@@ -139,8 +139,8 @@ func TestParseOPFContentCalibreSeries(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("parseOPFContent() error: %v", err)
|
||||
}
|
||||
if metadata.Series != "Great Series" || metadata.SeriesNumber != 2 {
|
||||
t.Errorf("Series = %q/%d, want Great Series/2", metadata.Series, metadata.SeriesNumber)
|
||||
if metadata.Series != "Great Series" || metadata.SeriesNumber != 2.5 {
|
||||
t.Errorf("Series = %q/%v, want Great Series/2.5", metadata.Series, metadata.SeriesNumber)
|
||||
}
|
||||
if metadata.ISBN == "" {
|
||||
t.Error("schemed ISBN not extracted")
|
||||
@@ -179,8 +179,8 @@ func TestExtractAudiobookshelfSidecar(t *testing.T) {
|
||||
if m.Title != "An Book" || m.Author != "Author One" {
|
||||
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
|
||||
}
|
||||
if m.Series != "The Series" || m.SeriesNumber != 4 {
|
||||
t.Errorf("Series = %q/%d, want The Series/4", m.Series, m.SeriesNumber)
|
||||
if m.Series != "The Series" || m.SeriesNumber != 4.5 {
|
||||
t.Errorf("Series = %q/%v, want The Series/4.5", m.Series, m.SeriesNumber)
|
||||
}
|
||||
if len(m.Tags) != 2 {
|
||||
t.Errorf("Tags = %v, want genres+tags merged", m.Tags)
|
||||
|
||||
@@ -171,8 +171,8 @@ func TestParseOPFContentTitleTypeAndSeries(t *testing.T) {
|
||||
if want := "The Main Title: The Subtitle"; metadata.Title != want {
|
||||
t.Errorf("Title = %q, want %q", metadata.Title, want)
|
||||
}
|
||||
if metadata.Series != "Great Series" || metadata.SeriesNumber != 4 {
|
||||
t.Errorf("Series = %q/%d, want Great Series/4", metadata.Series, metadata.SeriesNumber)
|
||||
if metadata.Series != "Great Series" || metadata.SeriesNumber != 4.5 {
|
||||
t.Errorf("Series = %q/%v, want Great Series/4.5", metadata.Series, metadata.SeriesNumber)
|
||||
}
|
||||
if metadata.ISBN == "" {
|
||||
t.Error("urn:isbn: identifier not extracted")
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestContinueSeriesRowToMediaItems_FieldsMappedCorrectly(t *testing.T) {
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
CoverImagePath: pgtype.Text{String: "/covers/test.jpg", Valid: true},
|
||||
Series: pgtype.Text{String: "Test Series", Valid: true},
|
||||
SeriesNumber: pgtype.Int4{Int32: 3, Valid: true},
|
||||
SeriesNumber: pgtype.Float8{Float64: 3, Valid: true},
|
||||
Tags: []string{"fantasy", "adventure"},
|
||||
FormatGroup: "epub",
|
||||
AddedByAdminID: pgtype.UUID{Bytes: adminUUID, Valid: true},
|
||||
@@ -43,7 +43,7 @@ func TestContinueSeriesRowToMediaItems_FieldsMappedCorrectly(t *testing.T) {
|
||||
assert.Equal(t, pgtype.Text{String: "application/epub+zip", Valid: true}, result.MimeType, "MimeType should match")
|
||||
assert.Equal(t, pgtype.Text{String: "/covers/test.jpg", Valid: true}, result.CoverImagePath, "CoverImagePath should match")
|
||||
assert.Equal(t, pgtype.Text{String: "Test Series", Valid: true}, result.Series, "Series should match")
|
||||
assert.Equal(t, pgtype.Int4{Int32: 3, Valid: true}, result.SeriesNumber, "SeriesNumber should match")
|
||||
assert.Equal(t, pgtype.Float8{Float64: 3, Valid: true}, result.SeriesNumber, "SeriesNumber should match")
|
||||
assert.Equal(t, []string{"fantasy", "adventure"}, result.Tags, "Tags should match")
|
||||
assert.Equal(t, "epub", result.FormatGroup, "FormatGroup should match")
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func TestContinueSeriesRowToMediaItems_NullFieldsHandled(t *testing.T) {
|
||||
MimeType: pgtype.Text{Valid: false},
|
||||
CoverImagePath: pgtype.Text{Valid: false},
|
||||
Series: pgtype.Text{Valid: false},
|
||||
SeriesNumber: pgtype.Int4{Valid: false},
|
||||
SeriesNumber: pgtype.Float8{Valid: false},
|
||||
Tags: nil,
|
||||
FormatGroup: "epub",
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestContinueSeriesRowToMediaItems_AllFieldsMapped(t *testing.T) {
|
||||
MimeType: pgtype.Text{String: "epub", Valid: true},
|
||||
CoverImagePath: pgtype.Text{String: "/cover.jpg", Valid: true},
|
||||
Series: pgtype.Text{String: "Series", Valid: true},
|
||||
SeriesNumber: pgtype.Int4{Int32: 1, Valid: true},
|
||||
SeriesNumber: pgtype.Float8{Float64: 1, Valid: true},
|
||||
Tags: []string{"tag1"},
|
||||
Asin: pgtype.Text{String: "ASIN", Valid: true},
|
||||
Publisher: pgtype.Text{String: "Pub", Valid: true},
|
||||
|
||||
Reference in new issue
Block a user