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

+26 -19
View File
@@ -15,6 +15,7 @@ type CollectionItems struct {
AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"`
AddedByUserID pgtype.UUID `db:"added_by_user_id" json:"added_by_user_id"`
Excluded pgtype.Bool `db:"excluded" json:"excluded"`
Position pgtype.Int4 `db:"position" json:"position"`
}
type Collections struct {
@@ -232,25 +233,31 @@ type MediaItemFormats struct {
}
type MediaItems struct {
ID pgtype.UUID `db:"id" json:"id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
Isbn pgtype.Text `db:"isbn" json:"isbn"`
Description pgtype.Text `db:"description" json:"description"`
FilePath string `db:"file_path" json:"file_path"`
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
Tags []string `db:"tags" json:"tags"`
Narrators []string `db:"narrators" json:"narrators"`
Abridged bool `db:"abridged" json:"abridged"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors []string `db:"contributors" json:"contributors"`
ID pgtype.UUID `db:"id" json:"id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
Title string `db:"title" json:"title"`
Subtitle pgtype.Text `db:"subtitle" json:"subtitle"`
Author pgtype.Text `db:"author" json:"author"`
Isbn pgtype.Text `db:"isbn" json:"isbn"`
Description pgtype.Text `db:"description" json:"description"`
FilePath string `db:"file_path" json:"file_path"`
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Float8 `db:"series_number" json:"series_number"`
Tags []string `db:"tags" json:"tags"`
Narrators []string `db:"narrators" json:"narrators"`
Authors []string `db:"authors" json:"authors"`
AuthorsSearch []string `db:"authors_search" json:"authors_search"`
Publishers []string `db:"publishers" json:"publishers"`
PublishersSearch []string `db:"publishers_search" json:"publishers_search"`
Abridged bool `db:"abridged" json:"abridged"`
Explicit bool `db:"explicit" json:"explicit"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors []string `db:"contributors" json:"contributors"`
// ISO 639-1 language code (e.g., en, es, fr)
Language pgtype.Text `db:"language" json:"language"`
// Edition information (e.g., "First Edition", "Revised Edition")
+1 -1
View File
@@ -583,7 +583,7 @@ type Querier interface {
// books): rescans recompute the track manifest and the aggregate facts
// that follow from it. Editorial fields stay untouched — admins may have
// edited title/narrators after ingest.
UpdateMediaItemAudioBookPayload(ctx context.Context, arg UpdateMediaItemAudioBookPayloadParams) (MediaItems, error)
UpdateMediaItemAudioBookPayload(ctx context.Context, arg UpdateMediaItemAudioBookPayloadParams) (UpdateMediaItemAudioBookPayloadRow, error)
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
// Update media item format
UpdateMediaItemFormat(ctx context.Context, arg UpdateMediaItemFormatParams) (MediaItemFormats, error)
File diff suppressed because it is too large. Load diff
+49 -45
View File
@@ -152,8 +152,8 @@ GROUP BY l.id;
-- narrators/narrators_search are NOT NULL DEFAULT '{}' (A2): callers MUST
-- pass empty slices, never Go nil (an explicit NULL INSERT bypasses the
-- column default and 23502s). The API create handler normalizes nil.
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata, narrators, narrators_search, abridged)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48)
INSERT INTO media_items (library_id, title, subtitle, authors, authors_search, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publishers, publishers_search, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata, narrators, narrators_search, abridged, explicit)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52)
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
RETURNING *;
@@ -164,8 +164,8 @@ RETURNING *;
-- and a duplicate flag instead of pretending it created something.
-- narrators NOT NULL: callers pass empty slices, never nil (see
-- CreateMediaItem note).
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata, narrators, narrators_search, abridged)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48)
INSERT INTO media_items (library_id, title, subtitle, authors, authors_search, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publishers, publishers_search, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata, narrators, narrators_search, abridged, explicit)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52)
ON CONFLICT (library_id, file_path) DO NOTHING
RETURNING *;
@@ -282,46 +282,50 @@ ORDER BY
-- name: UpdateMediaItem :one
UPDATE media_items SET
title = $2,
author = $3,
isbn = $4,
description = $5,
cover_image_path = $6,
series = $7,
series_number = $8,
tags = $9,
tags_search = $10,
asin = $11,
date_published = $12,
publisher = $13,
contributors = $14,
contributors_search = $15,
language = $16,
edition = $17,
page_count = $18,
genre = $19,
copyright_year = $20,
goodreads_id = $21,
openlibrary_id = $22,
google_books_id = $23,
manga_type = $24,
reading_direction = $25,
series_count = $26,
volume = $27,
imprint = $28,
age_rating = $29,
web_url = $30,
metadata_notes = $31,
community_rating = $32,
story_arc = $33,
is_black_and_white = $34,
alternate_info = $35,
scan_information = $36,
summary = $37,
metadata_overrides = $38,
audio_metadata = $39,
narrators = $40,
narrators_search = $41,
abridged = $42,
subtitle = $3,
authors = $4,
authors_search = $5,
isbn = $6,
description = $7,
cover_image_path = $8,
series = $9,
series_number = $10,
tags = $11,
tags_search = $12,
asin = $13,
date_published = $14,
publishers = $15,
publishers_search = $16,
contributors = $17,
contributors_search = $18,
language = $19,
edition = $20,
page_count = $21,
genre = $22,
copyright_year = $23,
goodreads_id = $24,
openlibrary_id = $25,
google_books_id = $26,
manga_type = $27,
reading_direction = $28,
series_count = $29,
volume = $30,
imprint = $31,
age_rating = $32,
web_url = $33,
metadata_notes = $34,
community_rating = $35,
story_arc = $36,
is_black_and_white = $37,
alternate_info = $38,
scan_information = $39,
summary = $40,
metadata_overrides = $41,
audio_metadata = $42,
narrators = $43,
narrators_search = $44,
abridged = $45,
explicit = $46,
updated_at = NOW()
WHERE id = $1
RETURNING *;
@@ -2917,7 +2921,7 @@ WHERE series = sqlc.narg('series')
AND library_id = sqlc.narg('library_id')
AND archived_at IS NULL
AND missing_scan_count = 0
ORDER BY series_number ASC NULLS LAST;
ORDER BY series_number ASC NULLS LAST, title ASC;
-- name: GetContinueSeriesItems :many
WITH user_series_progress AS (
+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 != ""},
+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 != ""},
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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")
+4 -4
View File
@@ -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},
+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)