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:
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
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user