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 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")
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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},
|
||||
})
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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
|
||||
-- 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
@@ -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 != ""},
|
||||
|
||||
@@ -49,11 +49,14 @@ import (
|
||||
// MediaMetadata contains extracted metadata for media files (ebooks, comics, manga)
|
||||
type MediaMetadata struct {
|
||||
Title string
|
||||
Author string
|
||||
Subtitle string
|
||||
Author string // legacy single-value fill; Authors[] is the write truth
|
||||
Authors []string
|
||||
Description string
|
||||
Series string
|
||||
SeriesNumber int32
|
||||
Publisher string
|
||||
SeriesNumber float64
|
||||
Publisher string // legacy single-value fill; Publishers[] is the write truth
|
||||
Publishers []string
|
||||
PublishDate time.Time
|
||||
Contributors []string
|
||||
CoverPath string
|
||||
@@ -61,6 +64,7 @@ type MediaMetadata struct {
|
||||
ASIN string
|
||||
Tags []string
|
||||
Genre string
|
||||
Explicit bool
|
||||
|
||||
FileHashInfo *HashInfo
|
||||
FileFormats []*FormatInfo
|
||||
@@ -715,8 +719,8 @@ func (s *MediaScanner) extractFolderStructureMetadata(path, rootFolder string) *
|
||||
seriesMatch := regexp.MustCompile(`^(.*),\s+Book\s+#(\d+)\s*-\s*(.*)$`).FindStringSubmatch(possibleSeries)
|
||||
if len(seriesMatch) == 4 {
|
||||
metadata.Series = strings.ReplaceAll(seriesMatch[1], "_", " ")
|
||||
if seriesNum, err := strconv.ParseInt(seriesMatch[2], 10, 32); err == nil {
|
||||
metadata.SeriesNumber = int32(seriesNum)
|
||||
if seriesNum, err := strconv.ParseFloat(seriesMatch[2], 64); err == nil {
|
||||
metadata.SeriesNumber = seriesNum
|
||||
}
|
||||
metadata.Title = strings.ReplaceAll(possibleTitle, "_", " ")
|
||||
} else {
|
||||
@@ -725,11 +729,11 @@ func (s *MediaScanner) extractFolderStructureMetadata(path, rootFolder string) *
|
||||
metadata.Title = strings.ReplaceAll(possibleTitle, "_", " ")
|
||||
|
||||
// Try to extract series number from title
|
||||
titleNumMatch := regexp.MustCompile(`^(.*)\s+(\d+)$`).FindStringSubmatch(metadata.Title)
|
||||
titleNumMatch := regexp.MustCompile(`^(.*)\s+(\d+(?:\.\d+)?)$`).FindStringSubmatch(metadata.Title)
|
||||
if len(titleNumMatch) == 3 {
|
||||
metadata.Title = titleNumMatch[1]
|
||||
if seriesNum, err := strconv.ParseInt(titleNumMatch[2], 10, 32); err == nil {
|
||||
metadata.SeriesNumber = int32(seriesNum)
|
||||
if seriesNum, err := strconv.ParseFloat(titleNumMatch[2], 64); err == nil {
|
||||
metadata.SeriesNumber = seriesNum
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1009,7 +1013,9 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
createdItem, err := s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: libraryID,
|
||||
Title: metadata.Title,
|
||||
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
|
||||
Subtitle: pgtype.Text{String: metadata.Subtitle, Valid: metadata.Subtitle != ""},
|
||||
Authors: nilToEmpty(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
|
||||
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
|
||||
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
|
||||
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
|
||||
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
|
||||
@@ -1018,8 +1024,9 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
MimeType: pgtype.Text{String: s.getMimeType(path), Valid: true},
|
||||
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
|
||||
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
|
||||
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
||||
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
|
||||
SeriesNumber: pgtype.Float8{Float64: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
||||
Publishers: nilToEmpty(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
|
||||
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
|
||||
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
||||
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
|
||||
Contributors: metadata.Contributors,
|
||||
@@ -1027,6 +1034,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
Narrators: nilToEmpty(metadata.Narrators),
|
||||
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
||||
Abridged: metadata.Abridged,
|
||||
Explicit: metadata.Explicit,
|
||||
Tags: metadata.Tags,
|
||||
TagsSearch: tagsSearch,
|
||||
AddedByAdminID: s.adminID,
|
||||
@@ -1240,8 +1248,8 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
|
||||
}
|
||||
if seriesName != "" {
|
||||
metadata.Series = strings.TrimSpace(seriesName)
|
||||
if index, err := strconv.ParseFloat(strings.TrimSpace(seriesSequence), 32); err == nil {
|
||||
metadata.SeriesNumber = int32(index)
|
||||
if index, err := strconv.ParseFloat(strings.TrimSpace(seriesSequence), 64); err == nil {
|
||||
metadata.SeriesNumber = index
|
||||
}
|
||||
}
|
||||
if tags := append(append([]string{}, sidecar.Genres...), sidecar.Tags...); len(tags) > 0 {
|
||||
@@ -1400,7 +1408,7 @@ func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata
|
||||
metadata.Series = comicInfo.Series
|
||||
}
|
||||
if metadata.SeriesNumber == 0 && comicInfo.Number > 0 {
|
||||
metadata.SeriesNumber = int32(comicInfo.Number)
|
||||
metadata.SeriesNumber = float64(comicInfo.Number)
|
||||
}
|
||||
if metadata.Publisher == "" && comicInfo.Publisher != "" {
|
||||
metadata.Publisher = comicInfo.Publisher
|
||||
@@ -2221,7 +2229,7 @@ func parseOPFContent(content []byte) (*MediaMetadata, error) {
|
||||
if series, index := opf.readSeries(); series != "" {
|
||||
metadata.Series = series
|
||||
if index > 0 {
|
||||
metadata.SeriesNumber = int32(index)
|
||||
metadata.SeriesNumber = index
|
||||
}
|
||||
}
|
||||
// Calibre-specific meta tags (filter by name attribute)
|
||||
@@ -3329,20 +3337,24 @@ func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing
|
||||
params := database.UpdateMediaItemParams{
|
||||
ID: existing.ID,
|
||||
Title: metadata.Title,
|
||||
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
|
||||
Subtitle: pgtype.Text{String: metadata.Subtitle, Valid: metadata.Subtitle != ""},
|
||||
Authors: nilToEmpty(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
|
||||
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
|
||||
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
|
||||
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
|
||||
CoverImagePath: pgtype.Text{String: s.getRelativePath(metadata.CoverPath), Valid: metadata.CoverPath != ""},
|
||||
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
|
||||
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
||||
SeriesNumber: pgtype.Float8{Float64: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
||||
Narrators: nilToEmpty(metadata.Narrators),
|
||||
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
||||
Abridged: metadata.Abridged,
|
||||
Explicit: metadata.Explicit,
|
||||
Tags: metadata.Tags,
|
||||
TagsSearch: tagsSearch,
|
||||
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
|
||||
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
||||
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
|
||||
Publishers: nilToEmpty(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
|
||||
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
|
||||
Contributors: metadata.Contributors,
|
||||
ContributorsSearch: contributorsSearch,
|
||||
Language: pgtype.Text{String: metadata.Language, Valid: metadata.Language != ""},
|
||||
|
||||
@@ -437,8 +437,8 @@ func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) {
|
||||
if series := report.tag("grouping", "----:com.apple.itunes:series"); series != "" {
|
||||
metadata.Series = series
|
||||
if part := report.tag("----:com.apple.itunes:series-part"); part != "" {
|
||||
if idx, err := strconv.ParseFloat(strings.TrimSpace(part), 32); err == nil {
|
||||
metadata.SeriesNumber = int32(idx)
|
||||
if idx, err := strconv.ParseFloat(strings.TrimSpace(part), 64); err == nil {
|
||||
metadata.SeriesNumber = idx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ func TestApplyAudioTagFallbacks(t *testing.T) {
|
||||
t.Fatal("series not filled from tags")
|
||||
}
|
||||
if meta.SeriesNumber != 4 {
|
||||
t.Fatalf("series number = %d", meta.SeriesNumber)
|
||||
t.Fatalf("series number = %v", meta.SeriesNumber)
|
||||
}
|
||||
if meta.ASIN != "B0ASIN1234" {
|
||||
t.Fatalf("asin = %q", meta.ASIN)
|
||||
|
||||
@@ -536,7 +536,9 @@ func (s *MediaScanner) createAudioBookItem(ctx context.Context, libraryID pgtype
|
||||
return s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: libraryID,
|
||||
Title: metadata.Title,
|
||||
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
|
||||
Subtitle: pgtype.Text{String: metadata.Subtitle, Valid: metadata.Subtitle != ""},
|
||||
Authors: nilToEmpty(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
|
||||
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
|
||||
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
|
||||
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
|
||||
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
|
||||
@@ -545,14 +547,16 @@ func (s *MediaScanner) createAudioBookItem(ctx context.Context, libraryID pgtype
|
||||
MimeType: pgtype.Text{String: mimeType, Valid: mimeType != ""},
|
||||
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
|
||||
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
|
||||
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
||||
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
|
||||
SeriesNumber: pgtype.Float8{Float64: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
||||
Publishers: nilToEmpty(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
|
||||
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
|
||||
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
||||
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
|
||||
Contributors: utils.NormalizeContributors(metadata.Contributors),
|
||||
Narrators: nilToEmpty(metadata.Narrators),
|
||||
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
||||
Abridged: metadata.Abridged,
|
||||
Explicit: metadata.Explicit,
|
||||
Tags: utils.NormalizeTags(metadata.Tags),
|
||||
AddedByAdminID: s.adminID,
|
||||
CreatedAt: modTime,
|
||||
|
||||
@@ -139,8 +139,8 @@ func TestParseOPFContentCalibreSeries(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("parseOPFContent() error: %v", err)
|
||||
}
|
||||
if metadata.Series != "Great Series" || metadata.SeriesNumber != 2 {
|
||||
t.Errorf("Series = %q/%d, want Great Series/2", metadata.Series, metadata.SeriesNumber)
|
||||
if metadata.Series != "Great Series" || metadata.SeriesNumber != 2.5 {
|
||||
t.Errorf("Series = %q/%v, want Great Series/2.5", metadata.Series, metadata.SeriesNumber)
|
||||
}
|
||||
if metadata.ISBN == "" {
|
||||
t.Error("schemed ISBN not extracted")
|
||||
@@ -179,8 +179,8 @@ func TestExtractAudiobookshelfSidecar(t *testing.T) {
|
||||
if m.Title != "An Book" || m.Author != "Author One" {
|
||||
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
|
||||
}
|
||||
if m.Series != "The Series" || m.SeriesNumber != 4 {
|
||||
t.Errorf("Series = %q/%d, want The Series/4", m.Series, m.SeriesNumber)
|
||||
if m.Series != "The Series" || m.SeriesNumber != 4.5 {
|
||||
t.Errorf("Series = %q/%v, want The Series/4.5", m.Series, m.SeriesNumber)
|
||||
}
|
||||
if len(m.Tags) != 2 {
|
||||
t.Errorf("Tags = %v, want genres+tags merged", m.Tags)
|
||||
|
||||
@@ -171,8 +171,8 @@ func TestParseOPFContentTitleTypeAndSeries(t *testing.T) {
|
||||
if want := "The Main Title: The Subtitle"; metadata.Title != want {
|
||||
t.Errorf("Title = %q, want %q", metadata.Title, want)
|
||||
}
|
||||
if metadata.Series != "Great Series" || metadata.SeriesNumber != 4 {
|
||||
t.Errorf("Series = %q/%d, want Great Series/4", metadata.Series, metadata.SeriesNumber)
|
||||
if metadata.Series != "Great Series" || metadata.SeriesNumber != 4.5 {
|
||||
t.Errorf("Series = %q/%v, want Great Series/4.5", metadata.Series, metadata.SeriesNumber)
|
||||
}
|
||||
if metadata.ISBN == "" {
|
||||
t.Error("urn:isbn: identifier not extracted")
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestContinueSeriesRowToMediaItems_FieldsMappedCorrectly(t *testing.T) {
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
CoverImagePath: pgtype.Text{String: "/covers/test.jpg", Valid: true},
|
||||
Series: pgtype.Text{String: "Test Series", Valid: true},
|
||||
SeriesNumber: pgtype.Int4{Int32: 3, Valid: true},
|
||||
SeriesNumber: pgtype.Float8{Float64: 3, Valid: true},
|
||||
Tags: []string{"fantasy", "adventure"},
|
||||
FormatGroup: "epub",
|
||||
AddedByAdminID: pgtype.UUID{Bytes: adminUUID, Valid: true},
|
||||
@@ -43,7 +43,7 @@ func TestContinueSeriesRowToMediaItems_FieldsMappedCorrectly(t *testing.T) {
|
||||
assert.Equal(t, pgtype.Text{String: "application/epub+zip", Valid: true}, result.MimeType, "MimeType should match")
|
||||
assert.Equal(t, pgtype.Text{String: "/covers/test.jpg", Valid: true}, result.CoverImagePath, "CoverImagePath should match")
|
||||
assert.Equal(t, pgtype.Text{String: "Test Series", Valid: true}, result.Series, "Series should match")
|
||||
assert.Equal(t, pgtype.Int4{Int32: 3, Valid: true}, result.SeriesNumber, "SeriesNumber should match")
|
||||
assert.Equal(t, pgtype.Float8{Float64: 3, Valid: true}, result.SeriesNumber, "SeriesNumber should match")
|
||||
assert.Equal(t, []string{"fantasy", "adventure"}, result.Tags, "Tags should match")
|
||||
assert.Equal(t, "epub", result.FormatGroup, "FormatGroup should match")
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func TestContinueSeriesRowToMediaItems_NullFieldsHandled(t *testing.T) {
|
||||
MimeType: pgtype.Text{Valid: false},
|
||||
CoverImagePath: pgtype.Text{Valid: false},
|
||||
Series: pgtype.Text{Valid: false},
|
||||
SeriesNumber: pgtype.Int4{Valid: false},
|
||||
SeriesNumber: pgtype.Float8{Valid: false},
|
||||
Tags: nil,
|
||||
FormatGroup: "epub",
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestContinueSeriesRowToMediaItems_AllFieldsMapped(t *testing.T) {
|
||||
MimeType: pgtype.Text{String: "epub", Valid: true},
|
||||
CoverImagePath: pgtype.Text{String: "/cover.jpg", Valid: true},
|
||||
Series: pgtype.Text{String: "Series", Valid: true},
|
||||
SeriesNumber: pgtype.Int4{Int32: 1, Valid: true},
|
||||
SeriesNumber: pgtype.Float8{Float64: 1, Valid: true},
|
||||
Tags: []string{"tag1"},
|
||||
Asin: pgtype.Text{String: "ASIN", Valid: true},
|
||||
Publisher: pgtype.Text{String: "Pub", Valid: true},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user