From 76648e1a54adec6cad68931097e61378c3243fa1 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 29 Mar 2026 19:12:10 -0400 Subject: [PATCH] docs: add comprehensive comic metadata implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- BOOK_DETAIL_IMPLEMENTATION.md | 1076 ----------------- ...N_PLAN_MERGE_METADATA_READING_DIRECTION.md | 274 +++-- 2 files changed, 163 insertions(+), 1187 deletions(-) delete mode 100644 BOOK_DETAIL_IMPLEMENTATION.md diff --git a/BOOK_DETAIL_IMPLEMENTATION.md b/BOOK_DETAIL_IMPLEMENTATION.md deleted file mode 100644 index d25bc99..0000000 --- a/BOOK_DETAIL_IMPLEMENTATION.md +++ /dev/null @@ -1,1076 +0,0 @@ -# Book Detail Page Implementation Guide - -Complete implementation guide for the `/media/:uuid` book detail page. - -## ✅ Verified Backend Code - -All database queries, function signatures, and struct definitions used in this guide have been verified against the actual codebase: - -- `GetMediaItem` ✓ (line 179 in querier.go) -- `GetMediaRating` ✓ (line 201 in querier.go) -- `GetCollectionsForBook` ✓ (line 132 in querier.go) -- `GetReadingProgress` ✓ (line 213 in querier.go) -- `ListSyncConflictsByMediaItem` ✓ (line 267 in querier.go) -- `GetMediaNotes` ✓ (line 200 in querier.go) -- `GetMediaHighlights` ✓ (line 178 in querier.go) -- `database.MediaItems` struct ✓ (lines 182-235 in models.go) -- `database.Collections` struct ✓ (lines 20-34 in models.go) -- `database.MediaRatings` struct ✓ (lines 254-262 in models.go) -- `database.ReadingProgress` struct ✓ (lines 287-312 in models.go) - -## Overview - -- **Route**: `GET /media/:uuid` -- **Template**: SSR-first with Alpine.js for modal interactions -- **Data Structure**: Embeds `database.MediaItems` to avoid duplication -- **Features**: Cover + metadata, progress tracking, sync modal, collections, placeholder buttons - ---- - -## Files to Create - -### 1. MediaDetail Data Structure - -**Location**: `internal/handlers/media_detail.go` (new file) - -**Full file content**: - -```go -package handlers - -import ( - "bookhoard/internal/database" - "encoding/json" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" -) - -// MediaDetail embeds database.MediaItems for complete book metadata -// No field duplication - template gets direct access to all database fields -type MediaDetail struct { - database.MediaItems // Embedded - ALL book fields available - - // User-specific data - Rating *database.MediaRatings `json:"rating,omitempty"` - Collections []database.Collections `json:"collections"` - ReadingProgress *database.ReadingProgress `json:"reading_progress,omitempty"` - - // Conflict data (if exists) - ActiveConflict *ConflictDetailResponse `json:"active_conflict,omitempty"` - - // Computed counts - NotesCount int `json:"notes_count"` - HighlightsCount int `json:"highlights_count"} -} -``` - -**Note**: The actual handler is implemented in `internal/router/frontend.go` as an inline function (see step 5), following the pattern used by all other frontend routes in this codebase. - ---- - -### 2. `templates/book_detail.templ` - -**Location**: `templates/book_detail.templ` (new file) - -**Full file content**: - -```templ -package templates - -import "bookhoard/internal/handlers" - -templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) { - - - - - - { book.Title } - Bookhoard - - - - - @Header(user, "/media/{ uuidToString(book.ID) }") - -
- -
- -
- if book.CoverImagePath.Valid && book.CoverImagePath.String != "" { - { book.Title } - } else { - { book.Title } - } -
- - -
- -

{ book.Title }

- if book.Author.Valid && book.Author.String != "" { -

- by { book.Author.String } -

- } - - -
- - - - - if book.ActiveConflict != nil || book.ReadingProgress != nil { - - } - - - - - - -
- - - if book.Rating != nil { -
- - { renderStars(book.Rating.Rating) } - - - ({ fmt.Sprintf("%.1f", float64(book.Rating.Rating)/2.0) } / 5) - -
- } - - - if book.Series.Valid && book.Series.String != "" { -
- - { book.Series.String } - if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 { - #{ book.SeriesNumber.Int32 } - } - -
- } - - - if book.Description.Valid && book.Description.String != "" { -
-

Synopsis

-

{ book.Description.String }

-
- } -
-
- - - if book.ReadingProgress != nil { -
-

Reading Progress

- - if book.ActiveConflict != nil { -
-

- ⚠️ Progress conflict detected - Click "Sync Progress" to review and resolve -

-
- } - - -
-
-
-
-
- - -
-
-

Progress

-

- { fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64) }% -

-
- if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid { -
-

Page

-

{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }

-
- } - if book.ReadingProgress.LastReadAt.Valid { -
-

Last Read

-

{ book.ReadingProgress.LastReadAt.Time.Format("2006-01-02 15:04") }

-
- } - if book.ReadingProgress.LastSyncSource.Valid { -
-

Source

-

{ book.ReadingProgress.LastSyncSource.String }

-
- } -
-
- } - - -
-

Metadata

-
- - if book.Publisher.Valid && book.Publisher.String != "" { -
-

Publisher

-

{ book.Publisher.String }

-
- } - if book.DatePublished.Valid { -
-

Published

-

{ book.DatePublished.Time.Format("2006-01-02") }

-
- } - if book.ISBN.Valid && book.ISBN.String != "" { -
-

ISBN

-

{ book.ISBN.String }

-
- } - if book.Language.Valid && book.Language.String != "" { -
-

Language

-

{ book.Language.String }

-
- } - if book.Edition.Valid && book.Edition.String != "" { -
-

Edition

-

{ book.Edition.String }

-
- } - if book.PageCount.Valid && book.PageCount.Int32 > 0 { -
-

Pages

-

{ book.PageCount.Int32 }

-
- } - if book.Genre.Valid && book.Genre.String != "" { -
-

Genre

-

{ book.Genre.String }

-
- } - if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 { -
-

Copyright Year

-

{ book.CopyrightYear.Int32 }

-
- } - -
-

Format

-

{ book.MimeType.String }

-
- if book.FileSize.Valid && book.FileSize.Int64 > 0 { -
-

File Size

-

{ formatFileSize(book.FileSize.Int64) }

-
- } -
- - - if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.ASIN.Valid || book.ISBN.Valid { -
-

External Links

-
- if book.GoodreadsID.Valid && book.GoodreadsID.String != "" { - - 📚 Goodreads - - } else { - - 📚 Goodreads - - } - if book.OpenlibraryID.Valid && book.OpenlibraryID.String != "" { - - 📖 Open Library - - } else { - - 📖 Open Library - - } - if book.GoogleBooksID.Valid && book.GoogleBooksID.String != "" { - - 🔍 Google Books - - } else { - - 🔍 Google Books - - } - if book.ASIN.Valid && book.ASIN.String != "" { - - 🛒 Amazon - - } else if book.ISBN.Valid && book.ISBN.String != "" { - - 🛒 Amazon - - } -
-
- } -
- - - if len(book.Collections) > 0 { -
-

Collections

-
- for _, col := range book.Collections { - - { col.Icon.String } - { col.Name } - - } -
-
- } -
- - - @ProgressSyncModal(book) - @NotesHighlightsModal(book) - - @ErrorToast(errorMessage) - - -} -``` - ---- - -### 3. `templates/book_detail_modals.templ` - -**Location**: `templates/book_detail_modals.templ` (new file) - -**Full file content**: - -```templ -package templates - -import "bookhoard/internal/handlers" - -// ProgressSyncModal shows progress from all devices for manual review -templ ProgressSyncModal(book handlers.MediaDetail) { - -} - -// NotesHighlightsModal - Placeholder for future feature -templ NotesHighlightsModal(book handlers.MediaDetail) { - -} -``` - ---- - -### 4. `web/src/book-detail.ts` - -**Location**: `web/src/book-detail.ts` (new file) - -**Full file content**: - -```typescript -import { Alpine } from "./alpine"; -import { showToast } from "./toast"; - -function showReaderPlaceholder(): void { - showToast("Ebook reader coming soon!", "info"); -} - -function showMetadataEditorPlaceholder(): void { - showToast("Metadata editor coming soon!", "info"); -} - -function showProgressSyncModal(): void { - const modal = document.getElementById("progress-sync-modal"); - if (modal) { - modal.classList.remove("hidden"); - } -} - -function showNotesModal(): void { - const modal = document.getElementById("notes-modal"); - if (modal) { - modal.classList.remove("hidden"); - } -} - -function hideProgressSyncModal(): void { - const modal = document.getElementById("progress-sync-modal"); - if (modal) { - modal.classList.add("hidden"); - } -} - -function hideNotesModal(): void { - const modal = document.getElementById("notes-modal"); - if (modal) { - modal.classList.add("hidden"); - } -} - -export { - showReaderPlaceholder, - showMetadataEditorPlaceholder, - showProgressSyncModal, - showNotesModal, - hideProgressSyncModal, - hideNotesModal, -}; - -Alpine.data("bookDetail", () => ({ - showReaderPlaceholder, - showMetadataEditorPlaceholder, - showProgressSyncModal, - showNotesModal, - hideProgressSyncModal, - hideNotesModal, -})); -``` - ---- - -## Files to Modify - -### 5. `internal/router/frontend.go` - Add Route and Import - -**Location**: `internal/router/frontend.go` - -#### Part A: Add Import - -**Find**: Line 19-21 (import section) - -**Add**: `encoding/json` import if not present - -**Surgical edit**: - -```go -// Around lines 1-22: - -package router - -import ( - "bytes" - "context" - "encoding/json" // ADD if not present - "log" - "net/http" - // ... rest of imports ... -``` - -#### Part B: Add Route Handler - -**Find**: Around line 970-980, after conflicts-page route, before devices-page route - -**Add**: Inline handler for book detail page - -**Surgical edit**: - -```go -// Around line 970-980 (after conflicts-page route): - - frontendProtected.GET("/conflicts-page", func(c *echo.Context) error { - user, err := getTemplateUserWithTheme(c, cfg) - if err != nil { - return renderErrorPage(c, "Error loading user", "user_load_error") - } - - var errorMsg string - var conflictsData []handlers.ConflictDetailResponse - var total, unresolved int - - conflictsData, total, unresolved, err = cfg.ConflictHandler.GetConflictsData(c) - // ... rest of conflicts handler ... - }) - - // ADD THESE LINES: - - // Book detail page - frontendProtected.GET("/media/:uuid", func(c *echo.Context) error { - user, err := getTemplateUserWithTheme(c, cfg) - if err != nil { - return renderErrorPage(c, "Error loading user", "user_load_error") - } - - // Parse media UUID from URL - mediaUUID, err := uuid.Parse(c.Param("uuid")) - if err != nil { - return renderErrorPage(c, "Invalid media ID", "invalid_id") - } - pgMediaUUID := uuidToPGType(mediaUUID) - - // Get user UUID for queries - userUUID, _ := uuid.Parse(user.ID) - pgUserID := uuidToPGType(userUUID) - - // Fetch media item (embeds ALL metadata) - mediaItem, err := cfg.Queries.GetMediaItem(c.Request().Context(), pgMediaUUID) - if err != nil { - if err.Error() == "no rows in result set" { - return renderErrorPage(c, "Book not found", "not_found") - } - return renderErrorPage(c, "Error loading book", "database_error") - } - - // Resolve cover image path - if mediaItem.CoverImagePath.Valid && mediaItem.CoverImagePath.String != "" { - resolvedPath := utils.ResolveMediaURL(mediaItem.LibraryID, mediaItem.CoverImagePath) - mediaItem.CoverImagePath = pgtype.Text{String: resolvedPath, Valid: true} - } - - // Fetch rating - var rating *database.MediaRatings - userRating, err := cfg.Queries.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - }) - if err == nil { - rating = &userRating - } - - // Fetch collections - collections, _ := cfg.Queries.GetCollectionsForBook(c.Request().Context(), pgMediaUUID) - - // Fetch reading progress - var progress *database.ReadingProgress - readingProgress, err := cfg.Queries.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - }) - if err == nil { - progress = &readingProgress - } - - // Fetch active conflict (if any) - var activeConflict *handlers.ConflictDetailResponse - conflicts, err := cfg.Queries.ListSyncConflictsByMediaItem(c.Request().Context(), - database.ListSyncConflictsByMediaItemParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - }) - if err == nil && len(conflicts) > 0 { - for _, conf := range conflicts { - if conf.ResolutionStatus.Valid && conf.ResolutionStatus.String == "unresolved" { - var conflictData map[string]handlers.ConflictSourceData - if err := json.Unmarshal(conf.ConflictData, &conflictData); err == nil { - activeConflict = &handlers.ConflictDetailResponse{ - ID: uuid.UUID(conf.ID.Bytes).String(), - MediaItemID: uuid.UUID(conf.MediaItemID.Bytes).String(), - MediaItemTitle: mediaItem.Title, - ConflictType: conf.ConflictType, - ConflictData: conflictData, - ResolutionStatus: conf.ResolutionStatus.String, - CreatedAt: conf.CreatedAt.Time, - } - } - break - } - } - } - - // Count notes and highlights - notes, _ := cfg.Queries.GetMediaNotes(c.Request().Context(), database.GetMediaNotesParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - }) - highlights, _ := cfg.Queries.GetMediaHighlights(c.Request().Context(), database.GetMediaHighlightsParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - }) - - // Assemble response (no field duplication!) - detail := handlers.MediaDetail{ - MediaItems: mediaItem, // Embedded - ALL fields available - Rating: rating, - Collections: collections, - ReadingProgress: progress, - ActiveConflict: activeConflict, - NotesCount: len(notes), - HighlightsCount: len(highlights), - } - - // Render template - var buf bytes.Buffer - err = templates.BookDetail(user, detail, "").Render(c.Request().Context(), &buf) - if err != nil { - return err - } - return c.HTML(http.StatusOK, buf.String()) - }) - - // END ADD - - // Devices page - frontendProtected.GET("/devices-page", func(c *echo.Context) error { - // ... existing devices handler starts around line 929 ... - }) -``` - ---- - -### 6. `web/src/main.ts` - Import Book Detail Module - -**Location**: `web/src/main.ts` - -**Find**: Around line 30, the import section - -**Add after**: `import "./bookshelf";` line (around line 10-15) - -**Surgical edit**: - -```go -// Around lines 10-30 in the import section: - -import "./analytics"; -import "./api"; -// ... existing imports ... -import "./bookshelf"; - -// ADD THIS LINE: - -import "./book-detail"; - -// END ADD - -import "./collection-rules"; -// ... rest of imports ... -``` - -**Full context (lines 1-40)**: - -```typescript -import "./alpine"; -import { Alpine } from "./alpine"; - -import "./admin"; -import "./analytics"; -import "./api"; -import "./api-explorer"; -import "./api-explorer-docs"; -import "./bookPicker"; -import "./bookshelf"; - -// ADD THIS LINE: - -import "./book-detail"; - -// END ADD - -import "./collection-rules"; -import "./collections"; -import "./conflicts"; -import "./custom-section-builder"; -import "./dashboard"; -import "./device-management"; -import "./docs"; -// ... rest of file ... -``` - ---- - -### 7. `templates/utils.go` - Add Helper Functions - -**Location**: `templates/utils.go` - -**Find**: End of file (after existing helper functions) - -**Add**: New helper functions at the end - -**Surgical edit**: - -```go -// At the end of templates/utils.go (after uuidToString function, before closing brace): - -// ADD THESE FUNCTIONS: - -// renderStars converts rating (1-10 scale) to star display -// Rating scale: 1-10 where odd numbers = half stars (1=0.5★, 3=1.5★, etc.) -func renderStars(rating int32) string { - stars := "" - fullStars := rating / 2 - hasHalf := rating % 2 != 0 - - for i := int32(0); i < fullStars; i++ { - stars += "★" - } - if hasHalf { - stars += "½" - } - - return stars -} - -// formatFileSize converts bytes to human-readable format -func formatFileSize(bytes int64) string { - const ( - KB = 1024 - MB = KB * 1024 - GB = MB * 1024 - ) - - switch { - case bytes >= GB: - return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB)) - case bytes >= MB: - return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB)) - case bytes >= KB: - return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB)) - default: - return fmt.Sprintf("%d B", bytes) - } -} - -// getExternalURL generates URL for external book services -// Priority: ID > ISBN > Title+Author search -func getExternalURL(service string, id string, isbn pgtype.Text, title string, author pgtype.Text) string { - baseURL := "" - searchTerm := "" - - // Determine search term: ID > ISBN > Title+Author - if id != "" { - searchTerm = id - } else if isbn.Valid && isbn.String != "" { - searchTerm = isbn.String - } else { - // Build title+author search query - if author.Valid && author.String != "" { - searchTerm = fmt.Sprintf("%s %s", title, author.String) - } else { - searchTerm = title - } - } - - // Build URL based on service - switch service { - case "goodreads": - if id != "" { - baseURL = "https://www.goodreads.com/book/show/" - } else { - baseURL = "https://www.goodreads.com/search?q=" - } - case "openlibrary": - if id != "" { - baseURL = "https://openlibrary.org/books/" - } else { - baseURL = "https://openlibrary.org/search?q=" - } - case "googlebooks": - if id != "" { - baseURL = "https://books.google.com/books?id=" - } else { - baseURL = "https://www.google.com/search?tbm=bks&q=" - } - case "amazon": - // Amazon doesn't have direct book IDs, always search - baseURL = "https://www.amazon.com/s?k=" - if isbn.Valid && isbn.String != "" { - searchTerm = isbn.String - } - } - - return baseURL + searchTerm -} - -// END ADD -``` - -**Part B: Verify/Update imports** - -**Find**: Top of `templates/utils.go` (lines 1-10) - -**Check if these imports exist, add if missing**: - -```go -// At the top of templates/utils.go: - -package templates - -import ( - "fmt" // Ensure this is present - "github.com/google/uuid" // Should already be present - "github.com/jackc/pgx/v5/pgtype" // ADD if not present - // ... other existing imports ... -) -``` - ---- - -## Verification Steps - -After implementing all changes: - -1. **Compile check**: - ```bash - go build ./... - ``` - -2. **Generate templ code**: - ```bash - templ generate - ``` - -3. **Build frontend**: - ```bash - cd web && npm run build - ``` - -4. **Start the application**: - ```bash - podman compose up -d - ``` - -5. **Test the page**: - - Navigate to any book: `http://localhost:8080/media/{uuid}` - - Test with a book that has: - - Cover image - - Rating - - Collections - - Reading progress - - Notes/highlights - - External IDs - ---- - -## Testing Checklist - -- [ ] Page loads without errors -- [ ] Cover image displays correctly (fallback to placeholder) -- [ ] All metadata fields display when present -- [ ] External links work (Goodreads, Open Library, Google Books, Amazon) -- [ ] Progress section displays correctly -- [ ] Sync Progress modal opens and shows device progress -- [ ] Notes & Highlights modal opens with placeholder message -- [ ] "Read Now" button shows toast -- [ ] "Edit Metadata" button shows toast -- [ ] Collections display as clickable badges -- [ ] Mobile responsive (stacks vertically) -- [ ] Theme switching works -- [ ] No console errors - ---- - -## Future Enhancements (Out of Scope) - -1. **Ebook Reader**: Integrate web-based EPUB/PDF reader -2. **Metadata Editor**: Form to edit book metadata with API endpoint -3. **Notes/Highlights Viewer**: Display all notes and highlights in modal -4. **Progress Resolution**: Allow resolving conflicts directly from modal (reuse conflicts page logic) -5. **Related Books**: Show other books in same series or by same author -6. **Reading Statistics**: Show reading history for this book - ---- - -## Notes - -- **No Database Migrations Required**: Uses existing database schema -- **No New API Endpoints**: Uses existing database queries -- **Type Safety**: Leverages sqlc-generated `database.MediaItems` struct -- **Progress Sync**: Modal shows comparison only - resolution via existing `/conflicts` page -- **External Links**: Smart fallback from ID → ISBN → title+author search -- **Responsive Design**: Mobile-first with TailwindCSS breakpoints diff --git a/IMPLEMENTATION_PLAN_MERGE_METADATA_READING_DIRECTION.md b/IMPLEMENTATION_PLAN_MERGE_METADATA_READING_DIRECTION.md index 1fa9d68..db3d5b0 100644 --- a/IMPLEMENTATION_PLAN_MERGE_METADATA_READING_DIRECTION.md +++ b/IMPLEMENTATION_PLAN_MERGE_METADATA_READING_DIRECTION.md @@ -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 ---