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

+1 -1
View File
@@ -108,7 +108,7 @@ func TestCalibreLibraryScan(t *testing.T) {
require.Equal(t, "Test Book", book.Title, "Title should match sidecar")
require.Equal(t, "Test Author", book.Author.String, "Author should match sidecar")
require.Equal(t, "Test Series", book.Series.String, "Series should match sidecar")
require.Equal(t, int32(1), book.SeriesNumber.Int32, "Series number should match sidecar")
require.Equal(t, float64(1), book.SeriesNumber.Float64, "Series number should match sidecar")
require.Equal(t, "Test Publisher", book.Publisher.String, "Publisher should match sidecar")
require.Len(t, book.Tags, 2, "Should have 2 tags from sidecar")
require.Contains(t, book.Tags, "Fantasy", "Should have Fantasy tag")
+1 -1
View File
@@ -382,7 +382,7 @@ func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID s
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: "Preview Test Book " + string(rune('A'+i)),
Author: pgtype.Text{String: "Test Author " + string(rune('A'+i)), Valid: true},
Authors: []string{"Test Author " + string(rune('A'+i))},
Genre: pgtype.Text{String: genre, Valid: true},
FilePath: "/tmp/test" + string(rune('A'+i)) + ".epub",
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
+1 -1
View File
@@ -111,7 +111,7 @@ func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uui
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: title,
Author: pgtype.Text{String: "Smart Rule Author", Valid: true},
Authors: []string{"Smart Rule Author"},
Genre: pgtype.Text{String: genre, Valid: true},
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
+6 -2
View File
@@ -123,7 +123,7 @@ func createContractFixture(t *testing.T, setup *TestServerSetup) contractFixture
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: itemTitle,
Author: pgtype.Text{String: "Test Contract Author", Valid: true},
Authors: []string{"Test Contract Author"},
Series: pgtype.Text{String: seriesName, Valid: true},
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
FilePath: "/tmp/contract-test-item.epub",
@@ -711,10 +711,14 @@ func TestContractPin_MediaListBothBranches(t *testing.T) {
fx := createContractFixture(t, setup)
// The sqlc row key sets behind BOTH branches.
// A2b (2026-10-19): + subtitle, explicit, authors, authors_search,
// publishers, publishers_search (docs/contract_pins.md changelog).
rawRowKeys := []string{
"id", "library_id", "title", "author", "isbn", "description",
"id", "library_id", "title", "subtitle", "author", "isbn", "description",
"file_path", "file_size", "mime_type", "cover_image_path",
"series", "series_number", "tags", "narrators", "abridged",
"explicit", "authors", "authors_search", "publishers",
"publishers_search",
"asin", "date_published", "publisher", "contributors", "language",
"edition", "page_count", "genre", "copyright_year", "goodreads_id",
"openlibrary_id", "google_books_id", "added_by_admin_id", "created_at",
+1 -1
View File
@@ -88,7 +88,7 @@ func (s *OwnershipAndAdminTestSuite) createItemViaDB(withFolder bool) (itemID, l
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Ownership Suite Item",
Author: pgtype.Text{String: "Suite Author", Valid: true},
Authors: []string{"Suite Author"},
FilePath: relPath,
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
+1 -1
View File
@@ -51,7 +51,7 @@ func createTestQueueItem(t *testing.T, db *database.Queries, userID uuid.UUID) (
mediaItem, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Test Book for Queue",
Author: pgtype.Text{String: "Test Author", Valid: true},
Authors: []string{"Test Author"},
FilePath: "/tmp/test.epub",
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
+1 -1
View File
@@ -544,7 +544,7 @@ func TestLibraryUsageStats(t *testing.T) {
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
Title: title,
Author: pgtype.Text{String: author, Valid: true},
Authors: []string{author},
Genre: pgtype.Text{String: genre, Valid: true},
FilePath: fmt.Sprintf("/tmp/usage-%s-%d.bin", title, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: size, Valid: true},
+1 -1
View File
@@ -95,7 +95,7 @@ func createSyncTestMedia(t *testing.T, db *database.Queries, libraryID pgtype.UU
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: libraryID,
Title: "Sync Test Book",
Author: pgtype.Text{String: "Test Author", Valid: true},
Authors: []string{"Test Author"},
FilePath: "/tmp/test.epub",
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
+84 -2
View File
@@ -136,7 +136,8 @@ CREATE TABLE IF NOT EXISTS media_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
author VARCHAR(255),
subtitle TEXT, -- Series-y/edition subtitle from sidecars; display field, never part of `title`
author VARCHAR(255), -- DEPRECATED for writes: display string regenerated from `authors` by trigger
isbn VARCHAR(13), -- Still relevant for ebooks
description TEXT,
file_path VARCHAR(500) NOT NULL,
@@ -144,10 +145,15 @@ CREATE TABLE IF NOT EXISTS media_items (
mime_type VARCHAR(100),
cover_image_path VARCHAR(500),
series VARCHAR(255),
series_number INTEGER,
series_number DOUBLE PRECISION, -- Real ordinals are decimals (0.5, 1.5 novellas); was INTEGER
tags TEXT[],
narrators TEXT[], -- Audiobook narrators (sidecar first; user-editable)
authors TEXT[] NOT NULL DEFAULT '{}', -- Write truth for authorship; `author` above is trigger-regenerated display
authors_search TEXT[] NOT NULL DEFAULT '{}',
publishers TEXT[] NOT NULL DEFAULT '{}', -- Write truth; `publisher` below is trigger-regenerated display
publishers_search TEXT[] NOT NULL DEFAULT '{}',
abridged BOOLEAN NOT NULL DEFAULT FALSE, -- Libation/Audible abridged flag
explicit BOOLEAN NOT NULL DEFAULT FALSE, -- Adult flag from sidecars (ABS explicit / Audible isAdult)
asin VARCHAR(20), -- Still relevant for ebooks
date_published DATE,
publisher VARCHAR(255),
@@ -267,6 +273,30 @@ ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS narrators TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS narrators_search TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS abridged BOOLEAN NOT NULL DEFAULT FALSE;
-- A2b: subtitle + explicit flags, authors/publishers arrays (narrators
-- precedent — arrays are write truth, varchar display columns regenerate).
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS subtitle TEXT;
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS explicit BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS authors TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS authors_search TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS publishers TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS publishers_search TEXT[] NOT NULL DEFAULT '{}';
-- Series ordinals are real-world decimals (0.5, 1.5 novellas); INTEGER
-- truncated them to the wrong shelf order. In-place widening; no-op on a
-- fresh install (column is already double precision from CREATE TABLE).
ALTER TABLE media_items ALTER COLUMN series_number TYPE double precision
USING series_number::double precision;
-- Backfill the arrays from the legacy single-value columns. Deliberately NO
-- comma-splitting ("Tolkien, J.R.R." would corrupt); every existing row is
-- effectively single-valued because the scanner historically kept only
-- authors[0]. Guarded so the pass is free after the first startup.
UPDATE media_items SET authors = ARRAY[author]
WHERE author IS NOT NULL AND author <> '' AND authors = '{}';
UPDATE media_items SET publishers = ARRAY[publisher]
WHERE publisher IS NOT NULL AND publisher <> '' AND publishers = '{}';
-- audio_metadata is declared in the CREATE TABLE above, which only fresh
-- installs ever see — this ALTER is the upgrade path for pre-A2 installs
-- (its absence 42703'd every scan and archive pass on upgraded deployments).
@@ -316,6 +346,20 @@ BEGIN
IF NEW.narrators_search IS NULL THEN
NEW.narrators_search := '{}';
END IF;
-- A2b: authors/publishers arrays are NOT NULL — Go nil slices become
-- explicit NULLs in params and would 23502 (the narrators lesson).
IF NEW.authors IS NULL THEN
NEW.authors := '{}';
END IF;
IF NEW.authors_search IS NULL THEN
NEW.authors_search := '{}';
END IF;
IF NEW.publishers IS NULL THEN
NEW.publishers := '{}';
END IF;
IF NEW.publishers_search IS NULL THEN
NEW.publishers_search := '{}';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
@@ -333,6 +377,35 @@ CREATE TRIGGER set_library_type_name_on_library_change
FOR EACH ROW
EXECUTE FUNCTION set_library_type_name();
-- Display sync: `author`/`publisher` are denormalized display strings
-- regenerated from the authors/publishers arrays on every write. The varchar
-- columns face search (trgm), facets and API responses; the arrays are the
-- write truth. array_to_string is STABLE on PG15 (not IMMUTABLE), so this
-- must be a trigger — a generated column would reject it. Absent arrays
-- yield NULL display (matches the legacy no-author row shape).
DROP TRIGGER IF EXISTS sync_author_publisher_display_trg ON media_items;
DROP FUNCTION IF EXISTS sync_author_publisher_display();
CREATE OR REPLACE FUNCTION sync_author_publisher_display() RETURNS TRIGGER AS $$
BEGIN
-- Defense in depth: normalize NULL arrays first (this trigger may fire
-- before set_library_type_name depending on name-ordered execution).
IF NEW.authors IS NULL THEN
NEW.authors := '{}';
END IF;
IF NEW.publishers IS NULL THEN
NEW.publishers := '{}';
END IF;
NEW.author := CASE WHEN NEW.authors = '{}' THEN NULL
ELSE array_to_string(NEW.authors, ', ') END;
NEW.publisher := CASE WHEN NEW.publishers = '{}' THEN NULL
ELSE array_to_string(NEW.publishers, ', ') END;
RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER sync_author_publisher_display_trg
BEFORE INSERT OR UPDATE ON media_items
FOR EACH ROW
EXECUTE FUNCTION sync_author_publisher_display();
-- Add GIN indexes for pg_trgm fuzzy search performance
CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm
ON media_items USING GIN (author gin_trgm_ops);
@@ -1256,12 +1329,21 @@ CREATE TABLE IF NOT EXISTS collection_items (
added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
added_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
excluded BOOLEAN DEFAULT false,
-- Manual playlist ordering (reading-order collections). NULL = un-ordered
-- legacy item; ordering falls back to added_at ASC.
position INTEGER,
UNIQUE(collection_id, media_item_id)
);
CREATE INDEX IF NOT EXISTS idx_collection_items_collection ON collection_items(collection_id);
CREATE INDEX IF NOT EXISTS idx_collection_items_media ON collection_items(media_item_id);
-- Playlist ordering (A2c): position-first ordering for collection listings;
-- NULLs fall back to added_at ASC.
ALTER TABLE collection_items ADD COLUMN IF NOT EXISTS position INTEGER;
CREATE INDEX IF NOT EXISTS idx_collection_items_order
ON collection_items(collection_id, position ASC NULLS LAST);
-- Index for excluding auto-assigned items
CREATE INDEX IF NOT EXISTS idx_collection_items_excluded ON collection_items(collection_id, excluded)
WHERE excluded = true;
+17
View File
@@ -119,3 +119,20 @@ cover_image_path, has_conflict, format_group, badge`.
- `media_items.audio_metadata` payload gained additive JSON keys
`layout` and `tracks` (see schema.sql comment). Consumers parse
additively; stored single-file payloads are unchanged.
## 2026-10-19 — A2b: metadata array/model overhaul (phase 1)
- `media_items.series_number` widened INTEGER → DOUBLE PRECISION across all
surfaces. JSON serializers emit the same key; values are JSON numbers, so
integer-valued ordinals are byte-identical ("3") and decimal ordinals
(0.5/1.5) now serialize as decimals instead of truncating. Clients reading
JSON numbers are unaffected; the Android client parses either form.
- `GET /api/media-items*` / detail responses gain ADDITIVE keys:
`subtitle` (string), `explicit` (bool), `authors` ([]string, write truth —
legacy `author` display string still emitted, now trigger-regenerated),
`publishers` ([]string; legacy `publisher` likewise).
- `POST/PUT /api/media-items*` accept ADDITIVE request keys `subtitle`,
`explicit`, `authors`, `publishers`; legacy `author`/`publisher` singles
still accepted (arrays win when both present).
- Override keys unchanged: `author`/`publisher` override names are preserved
for existing rows and now protect the arrays.
+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)
+2 -2
View File
@@ -191,8 +191,8 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
style="background-color: var(--accent); color: var(--bg-primary); text-decoration: none;"
>
{ book.Series.String }
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
#{ book.SeriesNumber.Int32 }
if book.SeriesNumber.Valid && book.SeriesNumber.Float64 > 0 {
#{ fmtNumber(book.SeriesNumber.Float64) }
}
</a>
}
+1 -1
View File
@@ -447,7 +447,7 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Number</label>
<input type="number" name="series_number"
value={ fmt.Sprintf("%d", book.SeriesNumber.Int32) }
value={ fmtNumber(book.SeriesNumber.Float64) }
class="input" />
</div>
<div>
+2 -2
View File
@@ -963,9 +963,9 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var48 string
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.SeriesNumber.Int32))
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmtNumber(book.SeriesNumber.Float64))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 450, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 450, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
if templ_7745c5c3_Err != nil {
+3 -3
View File
@@ -537,15 +537,15 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
if book.SeriesNumber.Valid && book.SeriesNumber.Float64 > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "#")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(book.SeriesNumber.Int32)
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(fmtNumber(book.SeriesNumber.Float64))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 195, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 195, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
+8
View File
@@ -5,6 +5,7 @@ import (
"bookhoard/internal/handlers"
"encoding/json"
"fmt"
"strconv"
"net/url"
"sort"
"strings"
@@ -467,3 +468,10 @@ func LibraryTypeDisplayName(name string) string {
r := []rune(name)
return strings.ToUpper(string(r[0])) + string(r[1:])
}
// fmtNumber renders a series ordinal without a trailing decimal zero:
// 1 → "1", 1.5 → "1.5". Series numbers became double precision when
// mid-series novellas (0.5/1.5) arrived; "%d" no longer fits.
func fmtNumber(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}