docs: add comprehensive comic metadata implementation plan

Add detailed implementation plan for comic and manga metadata support including
reading direction detection, smart Calibre + ComicInfo.xml merging, and universal
metadata fields that apply to all media formats.

Implementation Plan (IMPLEMENTATION_PLAN_MERGE_METADATA_READING_DIRECTION.md):
- 10 phases covering database, data structures, merging logic, API, testing, and documentation
- Phase 1: Database schema (14 new columns, 8 indexes)
- Phase 2: Data structure updates (ComicInfo 18→29 fields, MediaMetadata +15 fields)
- Phase 3: Smart metadata merging (mergeMetadata, 8 helper functions)
- Phase 4: Media item creation (database integration)
- Phase 5: API layer updates (TypeScript types, Go handlers)
- Phase 6-10: Testing, Bruno tests, docs, build, commits
- Genre/tag processing strategy (Option A: reuse existing genre column)
- 5 universal fields apply to all formats: age_rating, series_count, volume, imprint, web_url
- Reading direction: manga_type (raw) + reading_direction (computed)

Key Design Decisions:
- Smart merging: metadata.opf priority + ComicInfo.xml fills gaps
- Genre processing: All <dc:subject> and comic genre tags go to tags array
- Reading direction computed from Manga field + language + genre heuristics
- Supports manga (RTL), webtoons/manhwa (vertical), Western comics (LTR)
- JSONB for alternate_info (flexible schema for alternate series data)

Documentation Deleted:
- BOOK_DETAIL_IMPLEMENTATION.md (superseded by new comprehensive plan)

Plan Status:
- Phases 1-5: COMPLETE
- Phases 6-10: Pending (testing, bruno tests, documentation, build, git commits)

Total: 1,970 lines of detailed implementation guidance with code examples.
This commit is contained in:
2026-03-29 19:12:10 -04:00
parent 55ae91a147
commit 76648e1a54
2 changed files with 163 additions and 1187 deletions
File diff suppressed because it is too large Load Diff
@@ -721,88 +721,121 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
## Phase 4: Update Media Item Creation
### 4.1 Update `processNewMediaItem()` Function
### 4.1 Update CreateMediaItem SQL Query
**File:** `internal/database/queries/queries.sql`
**Location:** Line 131
**Current Query:**
```sql
-- name: CreateMediaItem :one
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)
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)
RETURNING *;
```
**Updated Query:**
```sql
-- name: CreateMediaItem :one
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, 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)
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)
RETURNING *;
```
**Changes:**
- Added 15 new columns to INSERT: `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`
- Added 15 new VALUES parameters: `$29-$42`
### 4.2 Regenerate sqlc Models
**Command:**
```bash
cd internal/database && sqlc generate
```
**Expected Changes:**
- `CreateMediaItemParams` struct will include 15 new fields
- All SELECT/RETURNING queries will include the new columns
### 4.3 Update CreateMediaItem Call
**File:** `internal/services/media_scanner.go`
**Location:** Lines 495-729 (in `processNewMediaItem()`)
**Location:** Lines 657-680 (after TODO comment removal)
**Find:** Database insertion call around line 672
**Current Code (lines 672-693):**
**Add to CreateMediaItem call:**
```go
createdItem, err := s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: libraryID,
Title: metadata.Title,
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
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 != ""},
FilePath: relativePath,
FileSize: pgtype.Int8{Int64: info.Size(), Valid: true},
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 != ""},
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
Contributors: metadata.Contributors,
ContributorsSearch: contributorsSearch,
Tags: metadata.Tags,
TagsSearch: tagsSearch,
AddedByAdminID: s.adminID,
CreatedAt: pgtype.Timestamptz{Time: fileModTime, Valid: true},
// ... existing fields ...
Language: pgtype.Text{String: metadata.Language, Valid: metadata.Language != ""},
// NEW: Reading direction and comic metadata fields
MangaType: pgtype.Text{String: metadata.MangaType, Valid: metadata.MangaType != ""},
ReadingDirection: pgtype.Text{String: metadata.ReadingDirection, Valid: metadata.ReadingDirection != ""},
SeriesCount: pgtype.Int4{Int32: metadata.SeriesCount, Valid: metadata.SeriesCount > 0},
Volume: pgtype.Int4{Int32: metadata.Volume, Valid: metadata.Volume > 0},
Imprint: pgtype.Text{String: metadata.Imprint, Valid: metadata.Imprint != ""},
AgeRating: pgtype.Text{String: metadata.AgeRating, Valid: metadata.AgeRating != ""},
WebUrl: pgtype.Text{String: metadata.WebURL, Valid: metadata.WebURL != ""},
StoryArc: pgtype.Text{String: metadata.StoryArc, Valid: metadata.StoryArc != ""},
IsBlackAndWhite: pgtype.Bool{Bool: metadata.IsBlackAndWhite, Valid: true},
MetadataNotes: pgtype.Text{String: metadata.MetadataNotes, Valid: metadata.MetadataNotes != ""},
AlternateInfo: func() []byte {
if metadata.AlternateInfo != "" {
return []byte(metadata.AlternateInfo)
}
return nil
}(),
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
CommunityRating: func() pgtype.Numeric {
if metadata.CommunityRating > 0 {
return pgtype.Numeric{Int64: int64(metadata.CommunityRating * 10), Valid: true}
}
return pgtype.Numeric{}
}(),
})
```
**Updated Code:**
**Important Type Conversions:**
- `AlternateInfo`: `string``[]byte` (JSONB in database)
- `CommunityRating`: `float64` (0-10 scale) → `pgtype.Numeric` with Int64 multiplication (DECIMAL(3,1) stores as integer)
- `WebUrl`: Field name is `WebUrl` (camelCase) not `WebURL`
### 4.4 Remove TODO Comment
**File:** `internal/services/media_scanner.go`
**Location:** Lines 714-719
**Remove:**
```go
createdItem, err := s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: libraryID,
Title: metadata.Title,
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
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 != ""},
FilePath: relativePath,
FileSize: pgtype.Int8{Int64: info.Size(), Valid: true},
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 != ""},
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
Contributors: metadata.Contributors,
ContributorsSearch: contributorsSearch,
Tags: metadata.Tags,
TagsSearch: tagsSearch,
AddedByAdminID: s.adminID,
CreatedAt: pgtype.Timestamptz{Time: fileModTime, Valid: true},
// NEW: Reading direction fields
MangaType: pgtype.Text{String: metadata.MangaType, Valid: metadata.MangaType != ""},
ReadingDirection: pgtype.Text{String: metadata.ReadingDirection, Valid: metadata.ReadingDirection != ""},
// NEW: Universal metadata fields (apply to ebooks, audiobooks, comics)
SeriesCount: pgtype.Int4{Int32: metadata.SeriesCount, Valid: metadata.SeriesCount > 0},
Volume: pgtype.Int4{Int32: metadata.Volume, Valid: metadata.Volume > 0},
Imprint: pgtype.Text{String: metadata.Imprint, Valid: metadata.Imprint != ""},
AgeRating: pgtype.Text{String: metadata.AgeRating, Valid: metadata.AgeRating != ""},
WebURL: pgtype.Text{String: metadata.WebURL, Valid: metadata.WebURL != ""},
MetadataNotes: pgtype.Text{String: metadata.MetadataNotes, Valid: metadata.MetadataNotes != ""},
CommunityRating: pgtype.Float8{Float64: metadata.CommunityRating, Valid: metadata.CommunityRating > 0},
// NEW: Comic-specific fields
StoryArc: pgtype.Text{String: metadata.StoryArc, Valid: metadata.StoryArc != ""},
IsBlackAndWhite: pgtype.Bool{Bool: metadata.IsBlackAndWhite, Valid: true},
AlternateInfo: pgtype.JSONB{Bytes: []byte(metadata.AlternateInfo), Valid: metadata.AlternateInfo != ""},
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
})
// TODO: Update comic metadata fields after regenerating sqlc code
// New fields: 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
// For now, these fields are extracted into metadata but not persisted to database
// They will be available after sqlc is run with the updated schema
```
**Reason:** The fields are now being persisted, so TODO is no longer needed.
### 4.5 Remove Duplicate Comic Metadata Extraction
**Note:** The entire block (lines 583-622) should be removed since `mergeMetadata()` now handles this.
**File:** `internal/services/media_scanner.go`
**Lines to Remove:** 583-622 (approximately 40 lines)
**Reason:** This block was extracting comic metadata and merging fields manually. The new `mergeMetadata()` function handles this more comprehensively with all 29 ComicInfo.xml fields.
### 4.2 Remove Duplicate Comic Metadata Extraction
**File:** `internal/services/media_scanner.go`
@@ -1089,70 +1122,89 @@ func TestMergeMetadata(t *testing.T) {
package tests
import (
"testing"
"github.com/stretchr/testify/require"
"context"
"net/http"
"testing"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestComicMetadataExtraction tests that ComicInfo.xml is parsed correctly
func TestComicMetadataExtraction(t *testing.T) {
ts := setupTestServer(t)
defer ts.cleanup()
setup := setupDeviceTest(t)
defer setup.Server.Close()
// Test case 1: CBZ with ComicInfo.xml containing Manga=YesAndRightToLeft
t.Run("CBZ with RTL manga", func(t *testing.T) {
// Create test CBZ file with ComicInfo.xml
// Upload to library
// Verify media_item has reading_direction='rtl'
})
// Create a comic library for testing
libraryID := setup.CreateLibrary(t, "Comic Test Library", "comic")
// Test case 2: CBZ with Manga=No (Western comic)
t.Run("CBZ with Western comic", func(t *testing.T) {
// Create test CBZ file with ComicInfo.xml
// Upload to library
// Verify media_item has reading_direction='ltr'
})
// Test case 1: CBZ with ComicInfo.xml containing Manga=YesAndRightToLeft
t.Run("CBZ with RTL manga", func(t *testing.T) {
// Create test CBZ file with ComicInfo.xml
// Upload to library
// Verify media_item has reading_direction='rtl'
})
// Test case 3: CBZ without ComicInfo.xml
t.Run("CBZ without metadata", func(t *testing.T) {
// Create test CBZ file without ComicInfo.xml
// Upload to library
// Verify media_item has reading_direction='ltr' (default)
})
// Test case 2: CBZ with Manga=No (Western comic)
t.Run("CBZ with Western comic", func(t *testing.T) {
// Create test CBZ file with ComicInfo.xml
// Upload to library
// Verify media_item has reading_direction='ltr'
})
// Test case 3: CBZ without ComicInfo.xml
t.Run("CBZ without metadata", func(t *testing.T) {
// Create test CBZ file without ComicInfo.xml
// Upload to library
// Verify media_item has reading_direction='ltr' (default)
})
}
// TestCalibreComicMerge tests smart merging of metadata.opf + ComicInfo.xml
func TestCalibreComicMerge(t *testing.T) {
ts := setupTestServer(t)
defer ts.cleanup()
setup := setupDeviceTest(t)
defer setup.Server.Close()
t.Run("Metadata OPF + ComicInfo XML", func(t *testing.T) {
// Create CBZ with ComicInfo.xml (Manga=YesAndRightToLeft)
// Create metadata.opf in same folder
// Upload to library
// Verify: title/author from metadata.opf, reading_direction from ComicInfo.xml
})
libraryID := setup.CreateLibrary(t, "Calibre Comic Test Library", "comic")
t.Run("Metadata OPF + ComicInfo XML", func(t *testing.T) {
// Create CBZ with ComicInfo.xml (Manga=YesAndRightToLeft)
// Create metadata.opf in same folder
// Upload to library
// Verify: title/author from metadata.opf, reading_direction from ComicInfo.xml
})
}
// TestReadingDirectionAPITests tests reading direction in API responses
func TestReadingDirectionAPI(t *testing.T) {
ts := setupTestServer(t)
defer ts.cleanup()
setup := setupDeviceTest(t)
defer setup.Server.Close()
t.Run("Search API includes reading_direction", func(t *testing.T) {
// Create manga with RTL reading direction
// Call search API
// Verify response includes reading_direction='rtl'
})
libraryID := setup.CreateLibrary(t, "Reading Direction Test Library", "comic")
t.Run("Media item detail API includes reading_direction", func(t *testing.T) {
// Create manga with RTL reading direction
// Call media item detail API
// Verify response includes manga_type and reading_direction
})
t.Run("Search API includes reading_direction", func(t *testing.T) {
// Create manga with RTL reading direction
// Call search API
// Verify response includes reading_direction='rtl'
})
t.Run("Media item detail API includes reading_direction", func(t *testing.T) {
// Create manga with RTL reading direction
// Call media item detail API
// Verify response includes manga_type and reading_direction
})
}
```
**Important:** Use `setupTestServer()` from `cmd/server/tests/test_helpers_test.go` - call ONCE per test function, not per subtest.
**Important:** Use `setupDeviceTest(t)` from `cmd/server/tests/test_helpers_test.go` (NOT `setupTestServer`)
- `setupDeviceTest` returns `*TestDeviceSetup` which has the `CreateLibrary()` helper method
- `setupTestServer` returns `*TestServerSetup` which does NOT have a CreateLibrary method
- Call `setupDeviceTest(t)` ONCE per test function, not per subtest
- The device setup provides user, auth tokens, and library creation helpers even though we don't need devices for comic tests
---