feat(schema): subtitle/explicit columns, authors+publishers arrays, float series numbers
Phase 1 of the series/collections/metadata overhaul
(~/.opencode/plan/series-collections-metadata-overhaul.md).
Schema (in place, idempotent):
- media_items.series_number INTEGER -> DOUBLE PRECISION: real-world
ordinals are decimals (0.5, 1.5 novellas); INTEGER silently truncated
them (calibre 2.5 pinned as 2 in tests).
- + subtitle TEXT, explicit BOOLEAN (sidecar ingest; ABS explicit /
Audible isAdult).
- + authors/authors_search/publishers/publishers_search TEXT[] NOT NULL
(narrators A2a precedent). Arrays are write truth; author/publisher
varchars become trigger-regenerated display strings
(sync_author_publisher_display) so every varchar consumer (trgm
search, facets, API, OPDS/Kobo) keeps working unchanged.
- Row-level NULL normalization for the new arrays in
set_library_type_name (A2 lesson: Go nil -> explicit NULL -> 23502).
- Backfills ARRAY[author]/ARRAY[publisher] — deliberately no
comma-splitting ('Tolkien, J.R.R.' would corrupt; scanner kept only
authors[0] historically).
- collection_items.position INTEGER + (collection_id, position) index
for playlist ordering.
Go: sqlc regen; MediaMetadata/handler params gain Subtitle/Explicit/
Authors/Publishers (legacy singles still accepted, arrays win);
SeriesNumber int32 -> float64 end to end; metadata_overrides adapted
with legacy override keys ('author'/'publisher') preserved so existing
user overrides survive; bulk author edit writes the array.
Pins widened (docs/contract_pins.md): raw sqlc row key set + the six
new columns; changelog entry added.
Verified: unit suite green; integration sweep (bulk/smart collections/
scanner/stats/sync/queue/calibre/pins/audiobooks) green against the
shared dev DB after in-place migration. Pre-existing unrelated red:
TestOwnershipAndAdminTestSuite/TestAdminCreateUser timezone inheritance
fails identically at the pre-change commit (live shared state).
This commit is contained in:
1 parent
f7dc8189a1
commit
646477afad
30 files changed
+790
-247
No files matched your search
@@ -108,7 +108,7 @@ func TestCalibreLibraryScan(t *testing.T) {
|
|||||||
require.Equal(t, "Test Book", book.Title, "Title should match sidecar")
|
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 Author", book.Author.String, "Author should match sidecar")
|
||||||
require.Equal(t, "Test Series", book.Series.String, "Series 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.Equal(t, "Test Publisher", book.Publisher.String, "Publisher should match sidecar")
|
||||||
require.Len(t, book.Tags, 2, "Should have 2 tags from sidecar")
|
require.Len(t, book.Tags, 2, "Should have 2 tags from sidecar")
|
||||||
require.Contains(t, book.Tags, "Fantasy", "Should have Fantasy tag")
|
require.Contains(t, book.Tags, "Fantasy", "Should have Fantasy tag")
|
||||||
|
|||||||
@@ -382,7 +382,7 @@ func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID s
|
|||||||
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
Title: "Preview Test Book " + string(rune('A'+i)),
|
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},
|
Genre: pgtype.Text{String: genre, Valid: true},
|
||||||
FilePath: "/tmp/test" + string(rune('A'+i)) + ".epub",
|
FilePath: "/tmp/test" + string(rune('A'+i)) + ".epub",
|
||||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uui
|
|||||||
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
Title: title,
|
Title: title,
|
||||||
Author: pgtype.Text{String: "Smart Rule Author", Valid: true},
|
Authors: []string{"Smart Rule Author"},
|
||||||
Genre: pgtype.Text{String: genre, Valid: true},
|
Genre: pgtype.Text{String: genre, Valid: true},
|
||||||
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
|
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
|
||||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ func createContractFixture(t *testing.T, setup *TestServerSetup) contractFixture
|
|||||||
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
Title: itemTitle,
|
Title: itemTitle,
|
||||||
Author: pgtype.Text{String: "Test Contract Author", Valid: true},
|
Authors: []string{"Test Contract Author"},
|
||||||
Series: pgtype.Text{String: seriesName, Valid: true},
|
Series: pgtype.Text{String: seriesName, Valid: true},
|
||||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
||||||
FilePath: "/tmp/contract-test-item.epub",
|
FilePath: "/tmp/contract-test-item.epub",
|
||||||
@@ -711,10 +711,14 @@ func TestContractPin_MediaListBothBranches(t *testing.T) {
|
|||||||
fx := createContractFixture(t, setup)
|
fx := createContractFixture(t, setup)
|
||||||
|
|
||||||
// The sqlc row key sets behind BOTH branches.
|
// 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{
|
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",
|
"file_path", "file_size", "mime_type", "cover_image_path",
|
||||||
"series", "series_number", "tags", "narrators", "abridged",
|
"series", "series_number", "tags", "narrators", "abridged",
|
||||||
|
"explicit", "authors", "authors_search", "publishers",
|
||||||
|
"publishers_search",
|
||||||
"asin", "date_published", "publisher", "contributors", "language",
|
"asin", "date_published", "publisher", "contributors", "language",
|
||||||
"edition", "page_count", "genre", "copyright_year", "goodreads_id",
|
"edition", "page_count", "genre", "copyright_year", "goodreads_id",
|
||||||
"openlibrary_id", "google_books_id", "added_by_admin_id", "created_at",
|
"openlibrary_id", "google_books_id", "added_by_admin_id", "created_at",
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func (s *OwnershipAndAdminTestSuite) createItemViaDB(withFolder bool) (itemID, l
|
|||||||
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
LibraryID: library.ID,
|
LibraryID: library.ID,
|
||||||
Title: "Ownership Suite Item",
|
Title: "Ownership Suite Item",
|
||||||
Author: pgtype.Text{String: "Suite Author", Valid: true},
|
Authors: []string{"Suite Author"},
|
||||||
FilePath: relPath,
|
FilePath: relPath,
|
||||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func createTestQueueItem(t *testing.T, db *database.Queries, userID uuid.UUID) (
|
|||||||
mediaItem, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
mediaItem, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
LibraryID: library.ID,
|
LibraryID: library.ID,
|
||||||
Title: "Test Book for Queue",
|
Title: "Test Book for Queue",
|
||||||
Author: pgtype.Text{String: "Test Author", Valid: true},
|
Authors: []string{"Test Author"},
|
||||||
FilePath: "/tmp/test.epub",
|
FilePath: "/tmp/test.epub",
|
||||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
|||||||
@@ -544,7 +544,7 @@ func TestLibraryUsageStats(t *testing.T) {
|
|||||||
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
|
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
|
||||||
Title: title,
|
Title: title,
|
||||||
Author: pgtype.Text{String: author, Valid: true},
|
Authors: []string{author},
|
||||||
Genre: pgtype.Text{String: genre, Valid: true},
|
Genre: pgtype.Text{String: genre, Valid: true},
|
||||||
FilePath: fmt.Sprintf("/tmp/usage-%s-%d.bin", title, time.Now().UnixNano()),
|
FilePath: fmt.Sprintf("/tmp/usage-%s-%d.bin", title, time.Now().UnixNano()),
|
||||||
FileSize: pgtype.Int8{Int64: size, Valid: true},
|
FileSize: pgtype.Int8{Int64: size, Valid: true},
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func createSyncTestMedia(t *testing.T, db *database.Queries, libraryID pgtype.UU
|
|||||||
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
LibraryID: libraryID,
|
LibraryID: libraryID,
|
||||||
Title: "Sync Test Book",
|
Title: "Sync Test Book",
|
||||||
Author: pgtype.Text{String: "Test Author", Valid: true},
|
Authors: []string{"Test Author"},
|
||||||
FilePath: "/tmp/test.epub",
|
FilePath: "/tmp/test.epub",
|
||||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ CREATE TABLE IF NOT EXISTS media_items (
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||||
title VARCHAR(255) NOT NULL,
|
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
|
isbn VARCHAR(13), -- Still relevant for ebooks
|
||||||
description TEXT,
|
description TEXT,
|
||||||
file_path VARCHAR(500) NOT NULL,
|
file_path VARCHAR(500) NOT NULL,
|
||||||
@@ -144,10 +145,15 @@ CREATE TABLE IF NOT EXISTS media_items (
|
|||||||
mime_type VARCHAR(100),
|
mime_type VARCHAR(100),
|
||||||
cover_image_path VARCHAR(500),
|
cover_image_path VARCHAR(500),
|
||||||
series VARCHAR(255),
|
series VARCHAR(255),
|
||||||
series_number INTEGER,
|
series_number DOUBLE PRECISION, -- Real ordinals are decimals (0.5, 1.5 novellas); was INTEGER
|
||||||
tags TEXT[],
|
tags TEXT[],
|
||||||
narrators TEXT[], -- Audiobook narrators (sidecar first; user-editable)
|
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
|
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
|
asin VARCHAR(20), -- Still relevant for ebooks
|
||||||
date_published DATE,
|
date_published DATE,
|
||||||
publisher VARCHAR(255),
|
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 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 narrators_search TEXT[] NOT NULL DEFAULT '{}';
|
||||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS abridged BOOLEAN NOT NULL DEFAULT FALSE;
|
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
|
-- 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
|
-- 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).
|
-- (its absence 42703'd every scan and archive pass on upgraded deployments).
|
||||||
@@ -316,6 +346,20 @@ BEGIN
|
|||||||
IF NEW.narrators_search IS NULL THEN
|
IF NEW.narrators_search IS NULL THEN
|
||||||
NEW.narrators_search := '{}';
|
NEW.narrators_search := '{}';
|
||||||
END IF;
|
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;
|
RETURN NEW;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
@@ -333,6 +377,35 @@ CREATE TRIGGER set_library_type_name_on_library_change
|
|||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION set_library_type_name();
|
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
|
-- Add GIN indexes for pg_trgm fuzzy search performance
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm
|
CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm
|
||||||
ON media_items USING GIN (author gin_trgm_ops);
|
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_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
added_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
added_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
excluded BOOLEAN DEFAULT false,
|
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)
|
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_collection ON collection_items(collection_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_collection_items_media ON collection_items(media_item_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
|
-- Index for excluding auto-assigned items
|
||||||
CREATE INDEX IF NOT EXISTS idx_collection_items_excluded ON collection_items(collection_id, excluded)
|
CREATE INDEX IF NOT EXISTS idx_collection_items_excluded ON collection_items(collection_id, excluded)
|
||||||
WHERE excluded = true;
|
WHERE excluded = true;
|
||||||
|
|||||||
@@ -119,3 +119,20 @@ cover_image_path, has_conflict, format_group, badge`.
|
|||||||
- `media_items.audio_metadata` payload gained additive JSON keys
|
- `media_items.audio_metadata` payload gained additive JSON keys
|
||||||
`layout` and `tracks` (see schema.sql comment). Consumers parse
|
`layout` and `tracks` (see schema.sql comment). Consumers parse
|
||||||
additively; stored single-file payloads are unchanged.
|
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
@@ -15,6 +15,7 @@ type CollectionItems struct {
|
|||||||
AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"`
|
AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"`
|
||||||
AddedByUserID pgtype.UUID `db:"added_by_user_id" json:"added_by_user_id"`
|
AddedByUserID pgtype.UUID `db:"added_by_user_id" json:"added_by_user_id"`
|
||||||
Excluded pgtype.Bool `db:"excluded" json:"excluded"`
|
Excluded pgtype.Bool `db:"excluded" json:"excluded"`
|
||||||
|
Position pgtype.Int4 `db:"position" json:"position"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Collections struct {
|
type Collections struct {
|
||||||
@@ -232,25 +233,31 @@ type MediaItemFormats struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MediaItems struct {
|
type MediaItems struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
Title string `db:"title" json:"title"`
|
Title string `db:"title" json:"title"`
|
||||||
Author pgtype.Text `db:"author" json:"author"`
|
Subtitle pgtype.Text `db:"subtitle" json:"subtitle"`
|
||||||
Isbn pgtype.Text `db:"isbn" json:"isbn"`
|
Author pgtype.Text `db:"author" json:"author"`
|
||||||
Description pgtype.Text `db:"description" json:"description"`
|
Isbn pgtype.Text `db:"isbn" json:"isbn"`
|
||||||
FilePath string `db:"file_path" json:"file_path"`
|
Description pgtype.Text `db:"description" json:"description"`
|
||||||
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
FilePath string `db:"file_path" json:"file_path"`
|
||||||
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
|
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
||||||
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
|
||||||
Series pgtype.Text `db:"series" json:"series"`
|
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
||||||
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
|
Series pgtype.Text `db:"series" json:"series"`
|
||||||
Tags []string `db:"tags" json:"tags"`
|
SeriesNumber pgtype.Float8 `db:"series_number" json:"series_number"`
|
||||||
Narrators []string `db:"narrators" json:"narrators"`
|
Tags []string `db:"tags" json:"tags"`
|
||||||
Abridged bool `db:"abridged" json:"abridged"`
|
Narrators []string `db:"narrators" json:"narrators"`
|
||||||
Asin pgtype.Text `db:"asin" json:"asin"`
|
Authors []string `db:"authors" json:"authors"`
|
||||||
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
|
AuthorsSearch []string `db:"authors_search" json:"authors_search"`
|
||||||
Publisher pgtype.Text `db:"publisher" json:"publisher"`
|
Publishers []string `db:"publishers" json:"publishers"`
|
||||||
Contributors []string `db:"contributors" json:"contributors"`
|
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)
|
// ISO 639-1 language code (e.g., en, es, fr)
|
||||||
Language pgtype.Text `db:"language" json:"language"`
|
Language pgtype.Text `db:"language" json:"language"`
|
||||||
// Edition information (e.g., "First Edition", "Revised Edition")
|
// Edition information (e.g., "First Edition", "Revised Edition")
|
||||||
|
|||||||
@@ -583,7 +583,7 @@ type Querier interface {
|
|||||||
// books): rescans recompute the track manifest and the aggregate facts
|
// books): rescans recompute the track manifest and the aggregate facts
|
||||||
// that follow from it. Editorial fields stay untouched — admins may have
|
// that follow from it. Editorial fields stay untouched — admins may have
|
||||||
// edited title/narrators after ingest.
|
// 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)
|
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
|
||||||
// Update media item format
|
// Update media item format
|
||||||
UpdateMediaItemFormat(ctx context.Context, arg UpdateMediaItemFormatParams) (MediaItemFormats, error)
|
UpdateMediaItemFormat(ctx context.Context, arg UpdateMediaItemFormatParams) (MediaItemFormats, error)
|
||||||
|
|||||||
+469
-109
File diff suppressed because it is too large.
Load diff
@@ -152,8 +152,8 @@ GROUP BY l.id;
|
|||||||
-- narrators/narrators_search are NOT NULL DEFAULT '{}' (A2): callers MUST
|
-- narrators/narrators_search are NOT NULL DEFAULT '{}' (A2): callers MUST
|
||||||
-- pass empty slices, never Go nil (an explicit NULL INSERT bypasses the
|
-- pass empty slices, never Go nil (an explicit NULL INSERT bypasses the
|
||||||
-- column default and 23502s). The API create handler normalizes nil.
|
-- 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)
|
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)
|
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()
|
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
@@ -164,8 +164,8 @@ RETURNING *;
|
|||||||
-- and a duplicate flag instead of pretending it created something.
|
-- and a duplicate flag instead of pretending it created something.
|
||||||
-- narrators NOT NULL: callers pass empty slices, never nil (see
|
-- narrators NOT NULL: callers pass empty slices, never nil (see
|
||||||
-- CreateMediaItem note).
|
-- 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)
|
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)
|
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
|
ON CONFLICT (library_id, file_path) DO NOTHING
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
@@ -282,46 +282,50 @@ ORDER BY
|
|||||||
-- name: UpdateMediaItem :one
|
-- name: UpdateMediaItem :one
|
||||||
UPDATE media_items SET
|
UPDATE media_items SET
|
||||||
title = $2,
|
title = $2,
|
||||||
author = $3,
|
subtitle = $3,
|
||||||
isbn = $4,
|
authors = $4,
|
||||||
description = $5,
|
authors_search = $5,
|
||||||
cover_image_path = $6,
|
isbn = $6,
|
||||||
series = $7,
|
description = $7,
|
||||||
series_number = $8,
|
cover_image_path = $8,
|
||||||
tags = $9,
|
series = $9,
|
||||||
tags_search = $10,
|
series_number = $10,
|
||||||
asin = $11,
|
tags = $11,
|
||||||
date_published = $12,
|
tags_search = $12,
|
||||||
publisher = $13,
|
asin = $13,
|
||||||
contributors = $14,
|
date_published = $14,
|
||||||
contributors_search = $15,
|
publishers = $15,
|
||||||
language = $16,
|
publishers_search = $16,
|
||||||
edition = $17,
|
contributors = $17,
|
||||||
page_count = $18,
|
contributors_search = $18,
|
||||||
genre = $19,
|
language = $19,
|
||||||
copyright_year = $20,
|
edition = $20,
|
||||||
goodreads_id = $21,
|
page_count = $21,
|
||||||
openlibrary_id = $22,
|
genre = $22,
|
||||||
google_books_id = $23,
|
copyright_year = $23,
|
||||||
manga_type = $24,
|
goodreads_id = $24,
|
||||||
reading_direction = $25,
|
openlibrary_id = $25,
|
||||||
series_count = $26,
|
google_books_id = $26,
|
||||||
volume = $27,
|
manga_type = $27,
|
||||||
imprint = $28,
|
reading_direction = $28,
|
||||||
age_rating = $29,
|
series_count = $29,
|
||||||
web_url = $30,
|
volume = $30,
|
||||||
metadata_notes = $31,
|
imprint = $31,
|
||||||
community_rating = $32,
|
age_rating = $32,
|
||||||
story_arc = $33,
|
web_url = $33,
|
||||||
is_black_and_white = $34,
|
metadata_notes = $34,
|
||||||
alternate_info = $35,
|
community_rating = $35,
|
||||||
scan_information = $36,
|
story_arc = $36,
|
||||||
summary = $37,
|
is_black_and_white = $37,
|
||||||
metadata_overrides = $38,
|
alternate_info = $38,
|
||||||
audio_metadata = $39,
|
scan_information = $39,
|
||||||
narrators = $40,
|
summary = $40,
|
||||||
narrators_search = $41,
|
metadata_overrides = $41,
|
||||||
abridged = $42,
|
audio_metadata = $42,
|
||||||
|
narrators = $43,
|
||||||
|
narrators_search = $44,
|
||||||
|
abridged = $45,
|
||||||
|
explicit = $46,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
@@ -2917,7 +2921,7 @@ WHERE series = sqlc.narg('series')
|
|||||||
AND library_id = sqlc.narg('library_id')
|
AND library_id = sqlc.narg('library_id')
|
||||||
AND archived_at IS NULL
|
AND archived_at IS NULL
|
||||||
AND missing_scan_count = 0
|
AND missing_scan_count = 0
|
||||||
ORDER BY series_number ASC NULLS LAST;
|
ORDER BY series_number ASC NULLS LAST, title ASC;
|
||||||
|
|
||||||
-- name: GetContinueSeriesItems :many
|
-- name: GetContinueSeriesItems :many
|
||||||
WITH user_series_progress AS (
|
WITH user_series_progress AS (
|
||||||
|
|||||||
+32
-11
@@ -39,10 +39,14 @@ type CreateMediaItemRequest struct {
|
|||||||
MimeType string `json:"mime_type" validate:"required"`
|
MimeType string `json:"mime_type" validate:"required"`
|
||||||
CoverImagePath string `json:"cover_image_path"`
|
CoverImagePath string `json:"cover_image_path"`
|
||||||
Series string `json:"series"`
|
Series string `json:"series"`
|
||||||
SeriesNumber int32 `json:"series_number"`
|
SeriesNumber float64 `json:"series_number"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
ASIN string `json:"asin"`
|
ASIN string `json:"asin"`
|
||||||
DatePublished string `json:"date_published"`
|
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"`
|
Publisher string `json:"publisher"`
|
||||||
Contributors []string `json:"contributors"`
|
Contributors []string `json:"contributors"`
|
||||||
// NEW: Reading direction and comic metadata fields
|
// NEW: Reading direction and comic metadata fields
|
||||||
@@ -69,16 +73,20 @@ type CreateMediaItemRequest struct {
|
|||||||
type UpdateMediaItemRequest struct {
|
type UpdateMediaItemRequest struct {
|
||||||
Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
|
Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
|
||||||
Author string `form:"author" json:"author"`
|
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"`
|
ISBN string `form:"isbn" json:"isbn"`
|
||||||
Description string `form:"description" json:"description"`
|
Description string `form:"description" json:"description"`
|
||||||
CoverImagePath string `form:"cover_image_path" json:"cover_image_path"`
|
CoverImagePath string `form:"cover_image_path" json:"cover_image_path"`
|
||||||
CoverAction string `form:"cover_action" json:"cover_action"`
|
CoverAction string `form:"cover_action" json:"cover_action"`
|
||||||
Series string `form:"series" json:"series"`
|
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"`
|
Tags []string `form:"tags" json:"tags"`
|
||||||
ASIN string `form:"asin" json:"asin"`
|
ASIN string `form:"asin" json:"asin"`
|
||||||
DatePublished string `form:"date_published" json:"date_published"`
|
DatePublished string `form:"date_published" json:"date_published"`
|
||||||
Publisher string `form:"publisher" json:"publisher"`
|
Publisher string `form:"publisher" json:"publisher"`
|
||||||
|
Publishers []string `form:"publishers" json:"publishers"` // write truth; legacy `publisher` still accepted
|
||||||
Contributors []string `form:"contributors" json:"contributors"`
|
Contributors []string `form:"contributors" json:"contributors"`
|
||||||
Language string `form:"language" json:"language"`
|
Language string `form:"language" json:"language"`
|
||||||
Edition string `form:"edition" json:"edition"`
|
Edition string `form:"edition" json:"edition"`
|
||||||
@@ -588,13 +596,16 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
|
|||||||
updateParams := database.UpdateMediaItemParams{
|
updateParams := database.UpdateMediaItemParams{
|
||||||
ID: mediaUUID,
|
ID: mediaUUID,
|
||||||
Title: existingMedia.Title,
|
Title: existingMedia.Title,
|
||||||
Author: existingMedia.Author,
|
Subtitle: existingMedia.Subtitle,
|
||||||
|
Authors: existingMedia.Authors,
|
||||||
|
AuthorsSearch: existingMedia.AuthorsSearch,
|
||||||
Genre: existingMedia.Genre,
|
Genre: existingMedia.Genre,
|
||||||
Language: existingMedia.Language,
|
Language: existingMedia.Language,
|
||||||
Tags: existingMedia.Tags,
|
Tags: existingMedia.Tags,
|
||||||
TagsSearch: existingMedia.TagsSearch,
|
TagsSearch: existingMedia.TagsSearch,
|
||||||
Description: existingMedia.Description,
|
Description: existingMedia.Description,
|
||||||
Publisher: existingMedia.Publisher,
|
Publishers: existingMedia.Publishers,
|
||||||
|
PublishersSearch: existingMedia.PublishersSearch,
|
||||||
CopyrightYear: existingMedia.CopyrightYear,
|
CopyrightYear: existingMedia.CopyrightYear,
|
||||||
Isbn: existingMedia.Isbn,
|
Isbn: existingMedia.Isbn,
|
||||||
Series: existingMedia.Series,
|
Series: existingMedia.Series,
|
||||||
@@ -623,6 +634,7 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
|
|||||||
AlternateInfo: existingMedia.AlternateInfo,
|
AlternateInfo: existingMedia.AlternateInfo,
|
||||||
ScanInformation: existingMedia.ScanInformation,
|
ScanInformation: existingMedia.ScanInformation,
|
||||||
Summary: existingMedia.Summary,
|
Summary: existingMedia.Summary,
|
||||||
|
Explicit: existingMedia.Explicit,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bulk edits are user customizations: keep existing overrides and mark
|
// 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)
|
overrides = utils.MergeOverrides(overrides, utils.OverrideTitle)
|
||||||
}
|
}
|
||||||
if update.Updates.Author != nil {
|
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)
|
overrides = utils.MergeOverrides(overrides, utils.OverrideAuthor)
|
||||||
}
|
}
|
||||||
if update.Updates.Genre != nil {
|
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{
|
created, err := mh.db.CreateMediaItemIfNew(c.Request().Context(), database.CreateMediaItemIfNewParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
|
||||||
Title: req.Title,
|
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 != ""},
|
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
|
||||||
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
||||||
FilePath: req.FilePath,
|
FilePath: req.FilePath,
|
||||||
@@ -1423,15 +1438,17 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
|
|||||||
MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""},
|
MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""},
|
||||||
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
|
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
|
||||||
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
|
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,
|
Tags: req.Tags,
|
||||||
TagsSearch: tagsSearch,
|
TagsSearch: tagsSearch,
|
||||||
Narrators: narrators,
|
Narrators: narrators,
|
||||||
NarratorsSearch: narratorsSearch,
|
NarratorsSearch: narratorsSearch,
|
||||||
Abridged: req.Abridged,
|
Abridged: req.Abridged,
|
||||||
|
Explicit: req.Explicit,
|
||||||
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
||||||
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
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,
|
Contributors: req.Contributors,
|
||||||
ContributorsSearch: contributorsSearch,
|
ContributorsSearch: contributorsSearch,
|
||||||
AddedByAdminID: user.ID,
|
AddedByAdminID: user.ID,
|
||||||
@@ -1541,20 +1558,24 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|||||||
params := database.UpdateMediaItemParams{
|
params := database.UpdateMediaItemParams{
|
||||||
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
Title: req.Title,
|
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 != ""},
|
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
|
||||||
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
||||||
CoverImagePath: pgtype.Text{String: coverPath, Valid: coverPath != ""},
|
CoverImagePath: pgtype.Text{String: coverPath, Valid: coverPath != ""},
|
||||||
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
|
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,
|
Tags: req.Tags,
|
||||||
TagsSearch: tagsSearch,
|
TagsSearch: tagsSearch,
|
||||||
Narrators: narrators,
|
Narrators: narrators,
|
||||||
NarratorsSearch: narratorsSearch,
|
NarratorsSearch: narratorsSearch,
|
||||||
Abridged: req.Abridged,
|
Abridged: req.Abridged,
|
||||||
|
Explicit: req.Explicit,
|
||||||
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
||||||
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
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,
|
Contributors: req.Contributors,
|
||||||
ContributorsSearch: contributorsSearch,
|
ContributorsSearch: contributorsSearch,
|
||||||
Language: pgtype.Text{String: req.Language, Valid: req.Language != ""},
|
Language: pgtype.Text{String: req.Language, Valid: req.Language != ""},
|
||||||
|
|||||||
@@ -49,11 +49,14 @@ import (
|
|||||||
// MediaMetadata contains extracted metadata for media files (ebooks, comics, manga)
|
// MediaMetadata contains extracted metadata for media files (ebooks, comics, manga)
|
||||||
type MediaMetadata struct {
|
type MediaMetadata struct {
|
||||||
Title string
|
Title string
|
||||||
Author string
|
Subtitle string
|
||||||
|
Author string // legacy single-value fill; Authors[] is the write truth
|
||||||
|
Authors []string
|
||||||
Description string
|
Description string
|
||||||
Series string
|
Series string
|
||||||
SeriesNumber int32
|
SeriesNumber float64
|
||||||
Publisher string
|
Publisher string // legacy single-value fill; Publishers[] is the write truth
|
||||||
|
Publishers []string
|
||||||
PublishDate time.Time
|
PublishDate time.Time
|
||||||
Contributors []string
|
Contributors []string
|
||||||
CoverPath string
|
CoverPath string
|
||||||
@@ -61,6 +64,7 @@ type MediaMetadata struct {
|
|||||||
ASIN string
|
ASIN string
|
||||||
Tags []string
|
Tags []string
|
||||||
Genre string
|
Genre string
|
||||||
|
Explicit bool
|
||||||
|
|
||||||
FileHashInfo *HashInfo
|
FileHashInfo *HashInfo
|
||||||
FileFormats []*FormatInfo
|
FileFormats []*FormatInfo
|
||||||
@@ -715,8 +719,8 @@ func (s *MediaScanner) extractFolderStructureMetadata(path, rootFolder string) *
|
|||||||
seriesMatch := regexp.MustCompile(`^(.*),\s+Book\s+#(\d+)\s*-\s*(.*)$`).FindStringSubmatch(possibleSeries)
|
seriesMatch := regexp.MustCompile(`^(.*),\s+Book\s+#(\d+)\s*-\s*(.*)$`).FindStringSubmatch(possibleSeries)
|
||||||
if len(seriesMatch) == 4 {
|
if len(seriesMatch) == 4 {
|
||||||
metadata.Series = strings.ReplaceAll(seriesMatch[1], "_", " ")
|
metadata.Series = strings.ReplaceAll(seriesMatch[1], "_", " ")
|
||||||
if seriesNum, err := strconv.ParseInt(seriesMatch[2], 10, 32); err == nil {
|
if seriesNum, err := strconv.ParseFloat(seriesMatch[2], 64); err == nil {
|
||||||
metadata.SeriesNumber = int32(seriesNum)
|
metadata.SeriesNumber = seriesNum
|
||||||
}
|
}
|
||||||
metadata.Title = strings.ReplaceAll(possibleTitle, "_", " ")
|
metadata.Title = strings.ReplaceAll(possibleTitle, "_", " ")
|
||||||
} else {
|
} else {
|
||||||
@@ -725,11 +729,11 @@ func (s *MediaScanner) extractFolderStructureMetadata(path, rootFolder string) *
|
|||||||
metadata.Title = strings.ReplaceAll(possibleTitle, "_", " ")
|
metadata.Title = strings.ReplaceAll(possibleTitle, "_", " ")
|
||||||
|
|
||||||
// Try to extract series number from title
|
// 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 {
|
if len(titleNumMatch) == 3 {
|
||||||
metadata.Title = titleNumMatch[1]
|
metadata.Title = titleNumMatch[1]
|
||||||
if seriesNum, err := strconv.ParseInt(titleNumMatch[2], 10, 32); err == nil {
|
if seriesNum, err := strconv.ParseFloat(titleNumMatch[2], 64); err == nil {
|
||||||
metadata.SeriesNumber = int32(seriesNum)
|
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{
|
createdItem, err := s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
LibraryID: libraryID,
|
LibraryID: libraryID,
|
||||||
Title: metadata.Title,
|
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 != ""},
|
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
|
||||||
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
|
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
|
||||||
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
|
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},
|
MimeType: pgtype.Text{String: s.getMimeType(path), Valid: true},
|
||||||
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
|
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
|
||||||
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
|
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},
|
||||||
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)),
|
||||||
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
||||||
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
|
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
|
||||||
Contributors: metadata.Contributors,
|
Contributors: metadata.Contributors,
|
||||||
@@ -1027,6 +1034,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
|||||||
Narrators: nilToEmpty(metadata.Narrators),
|
Narrators: nilToEmpty(metadata.Narrators),
|
||||||
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
||||||
Abridged: metadata.Abridged,
|
Abridged: metadata.Abridged,
|
||||||
|
Explicit: metadata.Explicit,
|
||||||
Tags: metadata.Tags,
|
Tags: metadata.Tags,
|
||||||
TagsSearch: tagsSearch,
|
TagsSearch: tagsSearch,
|
||||||
AddedByAdminID: s.adminID,
|
AddedByAdminID: s.adminID,
|
||||||
@@ -1240,8 +1248,8 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
|
|||||||
}
|
}
|
||||||
if seriesName != "" {
|
if seriesName != "" {
|
||||||
metadata.Series = strings.TrimSpace(seriesName)
|
metadata.Series = strings.TrimSpace(seriesName)
|
||||||
if index, err := strconv.ParseFloat(strings.TrimSpace(seriesSequence), 32); err == nil {
|
if index, err := strconv.ParseFloat(strings.TrimSpace(seriesSequence), 64); err == nil {
|
||||||
metadata.SeriesNumber = int32(index)
|
metadata.SeriesNumber = index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if tags := append(append([]string{}, sidecar.Genres...), sidecar.Tags...); len(tags) > 0 {
|
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
|
metadata.Series = comicInfo.Series
|
||||||
}
|
}
|
||||||
if metadata.SeriesNumber == 0 && comicInfo.Number > 0 {
|
if metadata.SeriesNumber == 0 && comicInfo.Number > 0 {
|
||||||
metadata.SeriesNumber = int32(comicInfo.Number)
|
metadata.SeriesNumber = float64(comicInfo.Number)
|
||||||
}
|
}
|
||||||
if metadata.Publisher == "" && comicInfo.Publisher != "" {
|
if metadata.Publisher == "" && comicInfo.Publisher != "" {
|
||||||
metadata.Publisher = comicInfo.Publisher
|
metadata.Publisher = comicInfo.Publisher
|
||||||
@@ -2221,7 +2229,7 @@ func parseOPFContent(content []byte) (*MediaMetadata, error) {
|
|||||||
if series, index := opf.readSeries(); series != "" {
|
if series, index := opf.readSeries(); series != "" {
|
||||||
metadata.Series = series
|
metadata.Series = series
|
||||||
if index > 0 {
|
if index > 0 {
|
||||||
metadata.SeriesNumber = int32(index)
|
metadata.SeriesNumber = index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Calibre-specific meta tags (filter by name attribute)
|
// Calibre-specific meta tags (filter by name attribute)
|
||||||
@@ -3329,20 +3337,24 @@ func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing
|
|||||||
params := database.UpdateMediaItemParams{
|
params := database.UpdateMediaItemParams{
|
||||||
ID: existing.ID,
|
ID: existing.ID,
|
||||||
Title: metadata.Title,
|
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 != ""},
|
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
|
||||||
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
|
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
|
||||||
CoverImagePath: pgtype.Text{String: s.getRelativePath(metadata.CoverPath), Valid: metadata.CoverPath != ""},
|
CoverImagePath: pgtype.Text{String: s.getRelativePath(metadata.CoverPath), Valid: metadata.CoverPath != ""},
|
||||||
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
|
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),
|
Narrators: nilToEmpty(metadata.Narrators),
|
||||||
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
||||||
Abridged: metadata.Abridged,
|
Abridged: metadata.Abridged,
|
||||||
|
Explicit: metadata.Explicit,
|
||||||
Tags: metadata.Tags,
|
Tags: metadata.Tags,
|
||||||
TagsSearch: tagsSearch,
|
TagsSearch: tagsSearch,
|
||||||
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
|
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
|
||||||
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
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,
|
Contributors: metadata.Contributors,
|
||||||
ContributorsSearch: contributorsSearch,
|
ContributorsSearch: contributorsSearch,
|
||||||
Language: pgtype.Text{String: metadata.Language, Valid: metadata.Language != ""},
|
Language: pgtype.Text{String: metadata.Language, Valid: metadata.Language != ""},
|
||||||
|
|||||||
@@ -437,8 +437,8 @@ func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) {
|
|||||||
if series := report.tag("grouping", "----:com.apple.itunes:series"); series != "" {
|
if series := report.tag("grouping", "----:com.apple.itunes:series"); series != "" {
|
||||||
metadata.Series = series
|
metadata.Series = series
|
||||||
if part := report.tag("----:com.apple.itunes:series-part"); part != "" {
|
if part := report.tag("----:com.apple.itunes:series-part"); part != "" {
|
||||||
if idx, err := strconv.ParseFloat(strings.TrimSpace(part), 32); err == nil {
|
if idx, err := strconv.ParseFloat(strings.TrimSpace(part), 64); err == nil {
|
||||||
metadata.SeriesNumber = int32(idx)
|
metadata.SeriesNumber = idx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ func TestApplyAudioTagFallbacks(t *testing.T) {
|
|||||||
t.Fatal("series not filled from tags")
|
t.Fatal("series not filled from tags")
|
||||||
}
|
}
|
||||||
if meta.SeriesNumber != 4 {
|
if meta.SeriesNumber != 4 {
|
||||||
t.Fatalf("series number = %d", meta.SeriesNumber)
|
t.Fatalf("series number = %v", meta.SeriesNumber)
|
||||||
}
|
}
|
||||||
if meta.ASIN != "B0ASIN1234" {
|
if meta.ASIN != "B0ASIN1234" {
|
||||||
t.Fatalf("asin = %q", meta.ASIN)
|
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{
|
return s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
LibraryID: libraryID,
|
LibraryID: libraryID,
|
||||||
Title: metadata.Title,
|
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 != ""},
|
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
|
||||||
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
|
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
|
||||||
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
|
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 != ""},
|
MimeType: pgtype.Text{String: mimeType, Valid: mimeType != ""},
|
||||||
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
|
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
|
||||||
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
|
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},
|
||||||
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)),
|
||||||
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
||||||
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
|
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
|
||||||
Contributors: utils.NormalizeContributors(metadata.Contributors),
|
Contributors: utils.NormalizeContributors(metadata.Contributors),
|
||||||
Narrators: nilToEmpty(metadata.Narrators),
|
Narrators: nilToEmpty(metadata.Narrators),
|
||||||
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
||||||
Abridged: metadata.Abridged,
|
Abridged: metadata.Abridged,
|
||||||
|
Explicit: metadata.Explicit,
|
||||||
Tags: utils.NormalizeTags(metadata.Tags),
|
Tags: utils.NormalizeTags(metadata.Tags),
|
||||||
AddedByAdminID: s.adminID,
|
AddedByAdminID: s.adminID,
|
||||||
CreatedAt: modTime,
|
CreatedAt: modTime,
|
||||||
|
|||||||
@@ -139,8 +139,8 @@ func TestParseOPFContentCalibreSeries(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("parseOPFContent() error: %v", err)
|
t.Fatalf("parseOPFContent() error: %v", err)
|
||||||
}
|
}
|
||||||
if metadata.Series != "Great Series" || metadata.SeriesNumber != 2 {
|
if metadata.Series != "Great Series" || metadata.SeriesNumber != 2.5 {
|
||||||
t.Errorf("Series = %q/%d, want Great Series/2", metadata.Series, metadata.SeriesNumber)
|
t.Errorf("Series = %q/%v, want Great Series/2.5", metadata.Series, metadata.SeriesNumber)
|
||||||
}
|
}
|
||||||
if metadata.ISBN == "" {
|
if metadata.ISBN == "" {
|
||||||
t.Error("schemed ISBN not extracted")
|
t.Error("schemed ISBN not extracted")
|
||||||
@@ -179,8 +179,8 @@ func TestExtractAudiobookshelfSidecar(t *testing.T) {
|
|||||||
if m.Title != "An Book" || m.Author != "Author One" {
|
if m.Title != "An Book" || m.Author != "Author One" {
|
||||||
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
|
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
|
||||||
}
|
}
|
||||||
if m.Series != "The Series" || m.SeriesNumber != 4 {
|
if m.Series != "The Series" || m.SeriesNumber != 4.5 {
|
||||||
t.Errorf("Series = %q/%d, want The Series/4", m.Series, m.SeriesNumber)
|
t.Errorf("Series = %q/%v, want The Series/4.5", m.Series, m.SeriesNumber)
|
||||||
}
|
}
|
||||||
if len(m.Tags) != 2 {
|
if len(m.Tags) != 2 {
|
||||||
t.Errorf("Tags = %v, want genres+tags merged", m.Tags)
|
t.Errorf("Tags = %v, want genres+tags merged", m.Tags)
|
||||||
|
|||||||
@@ -171,8 +171,8 @@ func TestParseOPFContentTitleTypeAndSeries(t *testing.T) {
|
|||||||
if want := "The Main Title: The Subtitle"; metadata.Title != want {
|
if want := "The Main Title: The Subtitle"; metadata.Title != want {
|
||||||
t.Errorf("Title = %q, want %q", metadata.Title, want)
|
t.Errorf("Title = %q, want %q", metadata.Title, want)
|
||||||
}
|
}
|
||||||
if metadata.Series != "Great Series" || metadata.SeriesNumber != 4 {
|
if metadata.Series != "Great Series" || metadata.SeriesNumber != 4.5 {
|
||||||
t.Errorf("Series = %q/%d, want Great Series/4", metadata.Series, metadata.SeriesNumber)
|
t.Errorf("Series = %q/%v, want Great Series/4.5", metadata.Series, metadata.SeriesNumber)
|
||||||
}
|
}
|
||||||
if metadata.ISBN == "" {
|
if metadata.ISBN == "" {
|
||||||
t.Error("urn:isbn: identifier not extracted")
|
t.Error("urn:isbn: identifier not extracted")
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func TestContinueSeriesRowToMediaItems_FieldsMappedCorrectly(t *testing.T) {
|
|||||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
CoverImagePath: pgtype.Text{String: "/covers/test.jpg", Valid: true},
|
CoverImagePath: pgtype.Text{String: "/covers/test.jpg", Valid: true},
|
||||||
Series: pgtype.Text{String: "Test Series", 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"},
|
Tags: []string{"fantasy", "adventure"},
|
||||||
FormatGroup: "epub",
|
FormatGroup: "epub",
|
||||||
AddedByAdminID: pgtype.UUID{Bytes: adminUUID, Valid: true},
|
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: "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: "/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.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, []string{"fantasy", "adventure"}, result.Tags, "Tags should match")
|
||||||
assert.Equal(t, "epub", result.FormatGroup, "FormatGroup 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},
|
MimeType: pgtype.Text{Valid: false},
|
||||||
CoverImagePath: pgtype.Text{Valid: false},
|
CoverImagePath: pgtype.Text{Valid: false},
|
||||||
Series: pgtype.Text{Valid: false},
|
Series: pgtype.Text{Valid: false},
|
||||||
SeriesNumber: pgtype.Int4{Valid: false},
|
SeriesNumber: pgtype.Float8{Valid: false},
|
||||||
Tags: nil,
|
Tags: nil,
|
||||||
FormatGroup: "epub",
|
FormatGroup: "epub",
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ func TestContinueSeriesRowToMediaItems_AllFieldsMapped(t *testing.T) {
|
|||||||
MimeType: pgtype.Text{String: "epub", Valid: true},
|
MimeType: pgtype.Text{String: "epub", Valid: true},
|
||||||
CoverImagePath: pgtype.Text{String: "/cover.jpg", Valid: true},
|
CoverImagePath: pgtype.Text{String: "/cover.jpg", Valid: true},
|
||||||
Series: pgtype.Text{String: "Series", 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"},
|
Tags: []string{"tag1"},
|
||||||
Asin: pgtype.Text{String: "ASIN", Valid: true},
|
Asin: pgtype.Text{String: "ASIN", Valid: true},
|
||||||
Publisher: pgtype.Text{String: "Pub", Valid: true},
|
Publisher: pgtype.Text{String: "Pub", Valid: true},
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
OverrideTitle = "title"
|
OverrideTitle = "title"
|
||||||
OverrideAuthor = "author"
|
OverrideAuthor = "author" // protects the authors[] array (legacy key preserved)
|
||||||
OverrideISBN = "isbn"
|
OverrideISBN = "isbn"
|
||||||
OverrideDescription = "description"
|
OverrideDescription = "description"
|
||||||
OverrideCoverImagePath = "cover_image_path"
|
OverrideCoverImagePath = "cover_image_path"
|
||||||
@@ -29,7 +29,9 @@ const (
|
|||||||
OverrideTags = "tags"
|
OverrideTags = "tags"
|
||||||
OverrideAsin = "asin"
|
OverrideAsin = "asin"
|
||||||
OverrideDatePublished = "date_published"
|
OverrideDatePublished = "date_published"
|
||||||
OverridePublisher = "publisher"
|
OverrideSubtitle = "subtitle"
|
||||||
|
OverrideExplicit = "explicit"
|
||||||
|
OverridePublisher = "publisher" // protects the publishers[] array (legacy key preserved)
|
||||||
OverrideContributors = "contributors"
|
OverrideContributors = "contributors"
|
||||||
OverrideLanguage = "language"
|
OverrideLanguage = "language"
|
||||||
OverrideEdition = "edition"
|
OverrideEdition = "edition"
|
||||||
@@ -150,17 +152,19 @@ func detectMetadataOverridesDiff(params database.UpdateMediaItemParams, existing
|
|||||||
}
|
}
|
||||||
|
|
||||||
add(OverrideTitle, params.Title != existing.Title)
|
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(OverrideISBN, normText(params.Isbn) != normText(existing.Isbn))
|
||||||
add(OverrideDescription, normText(params.Description) != normText(existing.Description))
|
add(OverrideDescription, normText(params.Description) != normText(existing.Description))
|
||||||
add(OverrideSeries, normText(params.Series) != normText(existing.Series))
|
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(OverrideTags, normStringSlice(params.Tags) != normStringSlice(existing.Tags))
|
||||||
add(OverrideNarrators, normStringSlice(params.Narrators) != normStringSlice(existing.Narrators))
|
add(OverrideNarrators, normStringSlice(params.Narrators) != normStringSlice(existing.Narrators))
|
||||||
add(OverrideAbridged, params.Abridged != existing.Abridged)
|
add(OverrideAbridged, params.Abridged != existing.Abridged)
|
||||||
|
add(OverrideExplicit, params.Explicit != existing.Explicit)
|
||||||
add(OverrideAsin, normText(params.Asin) != normText(existing.Asin))
|
add(OverrideAsin, normText(params.Asin) != normText(existing.Asin))
|
||||||
add(OverrideDatePublished, normDate(params.DatePublished) != normDate(existing.DatePublished))
|
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(OverrideContributors, normStringSlice(params.Contributors) != normStringSlice(existing.Contributors))
|
||||||
add(OverrideLanguage, normText(params.Language) != normText(existing.Language))
|
add(OverrideLanguage, normText(params.Language) != normText(existing.Language))
|
||||||
add(OverrideEdition, normText(params.Edition) != normText(existing.Edition))
|
add(OverrideEdition, normText(params.Edition) != normText(existing.Edition))
|
||||||
@@ -205,8 +209,12 @@ func ApplyMetadataOverrides(params *database.UpdateMediaItemParams, existing dat
|
|||||||
if hasOverride(o, OverrideTitle) {
|
if hasOverride(o, OverrideTitle) {
|
||||||
params.Title = existing.Title
|
params.Title = existing.Title
|
||||||
}
|
}
|
||||||
|
if hasOverride(o, OverrideSubtitle) {
|
||||||
|
params.Subtitle = existing.Subtitle
|
||||||
|
}
|
||||||
if hasOverride(o, OverrideAuthor) {
|
if hasOverride(o, OverrideAuthor) {
|
||||||
params.Author = existing.Author
|
params.Authors = existing.Authors
|
||||||
|
params.AuthorsSearch = existing.AuthorsSearch
|
||||||
}
|
}
|
||||||
if hasOverride(o, OverrideISBN) {
|
if hasOverride(o, OverrideISBN) {
|
||||||
params.Isbn = existing.Isbn
|
params.Isbn = existing.Isbn
|
||||||
@@ -241,7 +249,8 @@ func ApplyMetadataOverrides(params *database.UpdateMediaItemParams, existing dat
|
|||||||
params.DatePublished = existing.DatePublished
|
params.DatePublished = existing.DatePublished
|
||||||
}
|
}
|
||||||
if hasOverride(o, OverridePublisher) {
|
if hasOverride(o, OverridePublisher) {
|
||||||
params.Publisher = existing.Publisher
|
params.Publishers = existing.Publishers
|
||||||
|
params.PublishersSearch = existing.PublishersSearch
|
||||||
}
|
}
|
||||||
if hasOverride(o, OverrideContributors) {
|
if hasOverride(o, OverrideContributors) {
|
||||||
params.Contributors = existing.Contributors
|
params.Contributors = existing.Contributors
|
||||||
|
|||||||
@@ -63,12 +63,12 @@ func TestDetectMetadataOverridesUnsetFormsEqual(t *testing.T) {
|
|||||||
// Zero-value params must not look "changed" against NULL-ish columns.
|
// Zero-value params must not look "changed" against NULL-ish columns.
|
||||||
existing := database.MediaItems{
|
existing := database.MediaItems{
|
||||||
PageCount: pgtype.Int4{Int32: 0, Valid: false},
|
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},
|
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
|
||||||
}
|
}
|
||||||
params := database.UpdateMediaItemParams{
|
params := database.UpdateMediaItemParams{
|
||||||
PageCount: pgtype.Int4{Int32: 0, Valid: false},
|
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},
|
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
|
||||||
}
|
}
|
||||||
got := DetectMetadataOverrides(params, existing)
|
got := DetectMetadataOverrides(params, existing)
|
||||||
|
|||||||
@@ -183,6 +183,21 @@ func NormalizeNarratorsSearch(narrators []string) []string {
|
|||||||
return NormalizeTagsSearch(narrators)
|
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:
|
// NormalizeContributors normalizes an array of contributors for display:
|
||||||
// 1. Trim whitespace
|
// 1. Trim whitespace
|
||||||
// 2. Preserve original casing (including CAPSLOCK companies)
|
// 2. Preserve original casing (including CAPSLOCK companies)
|
||||||
|
|||||||
@@ -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;"
|
style="background-color: var(--accent); color: var(--bg-primary); text-decoration: none;"
|
||||||
>
|
>
|
||||||
{ book.Series.String }
|
{ book.Series.String }
|
||||||
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
|
if book.SeriesNumber.Valid && book.SeriesNumber.Float64 > 0 {
|
||||||
#{ book.SeriesNumber.Int32 }
|
#{ fmtNumber(book.SeriesNumber.Float64) }
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -447,7 +447,7 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Number</label>
|
<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"
|
<input type="number" name="series_number"
|
||||||
value={ fmt.Sprintf("%d", book.SeriesNumber.Int32) }
|
value={ fmtNumber(book.SeriesNumber.Float64) }
|
||||||
class="input" />
|
class="input" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -963,9 +963,9 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var48 string
|
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 {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -537,15 +537,15 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, "#")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "#")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var26 string
|
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 {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -467,3 +468,10 @@ func LibraryTypeDisplayName(name string) string {
|
|||||||
r := []rune(name)
|
r := []rune(name)
|
||||||
return strings.ToUpper(string(r[0])) + string(r[1:])
|
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)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user