From 1b632a1b6e935b88083dd9209d1b5355a11b6d53 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 29 Mar 2026 12:05:06 -0400 Subject: [PATCH] docs: add implementation plan for comprehensive comic metadata support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This document provides a complete implementation plan for extracting ALL 19 ComicInfo.xml v2.0 fields with smart metadata merging and universal metadata support across all media types (ebooks, audiobooks, comics). Key features planned: - Complete ComicInfo.xml v2.0 support (all 19 fields) - Smart metadata merging: Calibre metadata.opf + ComicInfo.xml + folder structure - Universal metadata fields (5 fields apply to ALL formats): * age_rating: Content classification (Everyone, Teen, Mature, Adult) * series_count: Total items in series * volume: Collected edition/omnibus number * imprint: Publisher subdivision * web_url: Info page URLs (Goodreads, Audible, ComicVine, etc.) - Comic-specific fields (10 fields): * manga_type, reading_direction: Reading direction detection * story_arc, scan_information, alternate_info, summary, etc. - Genre/tag processing: ALL genres appear in tags array without duplication * Uses existing 'genre' column for primary genre * processGenresAndTags() ensures no duplicate genres in tags * Works for EPUB, ComicInfo.xml, PDF metadata - 15 new database columns, 8 indexes - Full-stack support: database → Go → API → TypeScript → frontend - Comprehensive testing and documentation Plan organization: - 10 phases: Database schema → Data structures → Smart merging → API updates - Includes detailed commit strategy with sequential git add/commit/push commands - Testing checklist with manual verification steps - Bruno API test specifications - Documentation updates (user + API reference) This plan follows PROJECT_GUIDELINES.md requirements: - No new services or handlers (reuses existing media_scanner.go) - Uses setupTestServer() helper for integration tests - Includes Bruno YAML files for API testing - Sequential git commits with detailed messages - Reuses existing code patterns and database schema --- ...N_PLAN_MERGE_METADATA_READING_DIRECTION.md | 1970 +++++++++++++++++ 1 file changed, 1970 insertions(+) create mode 100644 IMPLEMENTATION_PLAN_MERGE_METADATA_READING_DIRECTION.md diff --git a/IMPLEMENTATION_PLAN_MERGE_METADATA_READING_DIRECTION.md b/IMPLEMENTATION_PLAN_MERGE_METADATA_READING_DIRECTION.md new file mode 100644 index 0000000..1fa9d68 --- /dev/null +++ b/IMPLEMENTATION_PLAN_MERGE_METADATA_READING_DIRECTION.md @@ -0,0 +1,1970 @@ +# Implementation Plan: Smart Metadata Merge & Complete ComicInfo.xml Support + +**Scope:** Extract ALL 19 ComicInfo.xml v2.0 fields (not just reading direction) + +**Status:** Updated - Full ComicInfo.xml support (March 29, 2026) + +## Overview + +Add intelligent metadata merging for ALL media types (ebooks, comics, etc.) with comprehensive support for ALL ComicInfo.xml metadata fields (19 total fields). This feature will: + +1. Parse `ComicInfo.xml` from comic archives (.cbz, .cbr, .cb7, .cbt) +2. Extract ALL metadata fields from ComicInfo.xml (reading direction, series info, imprint, story arc, age rating, etc.) +3. Implement smart metadata merging: Calibre `metadata.opf` + embedded metadata +4. Store both raw `manga_type` and computed `reading_direction` in database +5. Store universal fields that apply to ALL formats: age_rating, series_count, volume, imprint, web_url +6. Store comic-specific fields: story_arc, scan_information, alternate_info (JSONB), metadata_notes, community_rating +7. Implement genre/tag processing: ALL genres appear in tags array without duplication +8. Propagate all metadata through all layers: database → API → frontend + +## Project Context + +**Current State:** +- ✅ Parses Calibre `metadata.opf` sidecar files (priority) +- ✅ Parses `ComicInfo.xml` from comic archives (fallback only) +- ✅ Extracts basic comic metadata (title, series, number, publisher, writer) +- ❌ **Missing:** 13 additional ComicInfo.xml fields not extracted (series_count, volume, imprint, story_arc, age_rating, etc.) +- ❌ **Missing:** Universal fields that apply to all formats (age_rating, series_count, web_url, imprint, volume) +- ❌ **Missing:** `Manga` field in `ComicInfo` struct +- ❌ **Missing:** Reading direction in database schema +- ❌ **Missing:** Comic-specific fields (volume, series count, imprint, story arc, age rating, etc.) +- ❌ **Missing:** Smart metadata merging (currently: if `metadata.opf` exists, skip `ComicInfo.xml`) +- ❌ **Missing:** Reading direction in API responses +- ❌ **Missing:** Reading direction in frontend types + +## Phase 1: Database Schema Changes + +### 1.1 Add Columns to `media_items` Table + +**File:** `database/schema/schema.sql` + +**Location:** After line 133 (after `kobo_metadata JSONB`) + +**Changes:** + +```sql +-- Manga and comic reading direction support +-- Stores raw Manga field from ComicInfo.xml +manga_type VARCHAR(30) DEFAULT 'unknown' +CHECK (manga_type IN ('unknown', 'no', 'yes', 'yes_and_right_to_left')), + +-- Stores computed reading direction for queries/UI +reading_direction VARCHAR(20) DEFAULT 'auto' +CHECK (reading_direction IN ('auto', 'ltr', 'rtl', 'vertical')), + +-- Universal series information (applies to ALL formats: ebooks, audiobooks, comics) +series_count INTEGER, -- Total items in series (from ComicInfo Count field, or book series count) +volume INTEGER, -- Volume/omnibus number for collected editions + +-- Universal publisher and classification (applies to ALL formats) +imprint VARCHAR(255), -- Publisher imprint (e.g., Vertigo, HarperCollinsEpic) +age_rating VARCHAR(20), -- Age rating: Everyone, Teen, Mature, Adult (applies to all formats) +web_url VARCHAR(500), -- URL to info page (Goodreads, ComicVine, MangaUpdates, Audible, etc.) + +-- Comic-specific fields +story_arc VARCHAR(255), -- Story arc name (e.g., "The Dark Phoenix Saga", "Civil War") +is_black_and_white BOOLEAN, -- Black and white flag (mostly comics, some illustrated books) + +-- Additional metadata (applies to all formats) +metadata_notes TEXT, -- Notes from metadata files (ComicInfo.xml, EPUB, PDF) - distinct from user notes +community_rating DECIMAL(3,1), -- Pre-existing community rating from metadata (0.0-10.0) - distinct from user ratings + +-- Alternate series information (JSONB for flexible schema - comic-specific) +alternate_info JSONB, -- Stores AlternateSeries, AlternateNumber, AlternateCount +-- Example: {"alternate_series": "Ultimate X-Men", "alternate_number": 1, "alternate_count": 12} + +-- Scan and publication metadata (comic-specific) +scan_information TEXT, -- Scan information (scanner group, resolution, etc.) + +-- Summary (distinct from description - may merge with Calibre description) +summary TEXT, -- Summary from ComicInfo.xml (may be merged with description from Calibre) +``` + +### 1.2 Add Indexes for Comic and Universal Metadata Queries + +**File:** `database/schema/schema.sql` + +**Location:** After line 388 (after `idx_media_items_series`) + +**Changes:** + +```sql +-- Index for filtering by reading direction (for manga/comic libraries) +CREATE INDEX IF NOT EXISTS idx_media_items_reading_direction +ON media_items(reading_direction) +WHERE reading_direction IS NOT NULL; + +-- Index for filtering by story arc (comic-specific) +CREATE INDEX IF NOT EXISTS idx_media_items_story_arc +ON media_items(story_arc) +WHERE story_arc IS NOT NULL; + +-- Index for filtering by imprint (universal - all formats) +CREATE INDEX IF NOT EXISTS idx_media_items_imprint +ON media_items(imprint) +WHERE imprint IS NOT NULL; + +-- Index for filtering by age rating (universal - all formats) +CREATE INDEX IF NOT EXISTS idx_media_items_age_rating +ON media_items(age_rating) +WHERE age_rating IS NOT NULL; + +-- Index for filtering by manga type (comic-specific) +CREATE INDEX IF NOT EXISTS idx_media_items_manga_type +ON media_items(manga_type) +WHERE manga_type IS NOT NULL; + +-- Index for filtering by series count (universal - all formats) +CREATE INDEX IF NOT EXISTS idx_media_items_series_count +ON media_items(series_count) +WHERE series_count IS NOT NULL; + +-- Index for filtering by volume (universal - all formats) +CREATE INDEX IF NOT EXISTS idx_media_items_volume +ON media_items(volume) +WHERE volume IS NOT NULL; + +-- GIN index for alternate_info JSONB queries (comic-specific) +CREATE INDEX IF NOT EXISTS idx_media_items_alternate_info_gin +ON media_items USING GIN (alternate_info) +WHERE alternate_info IS NOT NULL; +``` + +### 1.3 Add Column Comments + +**File:** `database/schema/schema.sql` + +**Location:** After line 478 (after existing column comments) + +**Changes:** + +```sql +-- Comic-specific fields +COMMENT ON COLUMN media_items.manga_type IS 'Raw Manga field from ComicInfo.xml: unknown, no, yes, yes_and_right_to_left'; +COMMENT ON COLUMN media_items.reading_direction IS 'Computed reading direction: auto, ltr (left-to-right), rtl (right-to-left), vertical (webtoons/manhwa)'; +COMMENT ON COLUMN media_items.story_arc IS 'Story arc name for grouping related issues (e.g., "The Dark Phoenix Saga", "Civil War")'; +COMMENT ON COLUMN media_items.is_black_and_white IS 'Black and white comic flag from ComicInfo.xml'; +COMMENT ON COLUMN media_items.alternate_info IS 'Alternate series information as JSONB: {alternate_series, alternate_number, alternate_count}'; +COMMENT ON COLUMN media_items.scan_information IS 'Scan information from ComicInfo.xml (scanner group, resolution, etc.)'; + +-- Universal fields (apply to ebooks, audiobooks, comics) +COMMENT ON COLUMN media_items.series_count IS 'Total items in series (from ComicInfo.xml Count field, or book series count)'; +COMMENT ON COLUMN media_items.volume IS 'Volume/omnibus number for collected editions'; +COMMENT ON COLUMN media_items.imprint IS 'Publisher imprint/subdivision (e.g., Vertigo, HarperCollinsEpic, DC Black Label)'; +COMMENT ON COLUMN media_items.age_rating IS 'Age rating from metadata: Everyone, Teen, Mature, Adult (applies to all formats)'; +COMMENT ON COLUMN media_items.web_url IS 'URL to info page (Goodreads, ComicVine, MangaUpdates, Audible, etc.)'; + +-- Additional metadata (applies to all formats) +COMMENT ON COLUMN media_items.metadata_notes IS 'Notes from metadata files (ComicInfo.xml, EPUB, PDF) - distinct from user notes in media_notes table'; +COMMENT ON COLUMN media_items.community_rating IS 'Pre-existing community rating from metadata files (scale 0.0-10.0) - distinct from user ratings in media_ratings table'; +COMMENT ON COLUMN media_items.summary IS 'Summary from ComicInfo.xml (may be merged with description from Calibre)'; +``` + +### 1.4 Regenerate Database Models + +**Command:** + +```bash +cd internal/database && sqlc generate +``` + +**Expected Changes:** +- `CreateMediaItemParams` struct will include `MangaType` and `ReadingDirection` fields +- `UpdateMediaItemIdentifiersParams` struct may need updating +- All queries that return `MediaItems` will include new columns + +--- + +## Phase 2: Update Data Structures + +### 2.1 Enhance `ComicInfo` Struct + +**File:** `internal/services/media_scanner.go` + +**Location:** Lines 1383-1403 + +**Current Code:** + +```go +type ComicInfo struct { + XMLName xml.Name `xml:"ComicInfo"` + Title string `xml:"Title"` + Series string `xml:"Series"` + Number int `xml:"Number"` + Volume int `xml:"Volume"` + Publisher string `xml:"Publisher"` + Year int `xml:"Year"` + Month int `xml:"Month"` + Day int `xml:"Day"` + Writer string `xml:"Writer"` + Penciller string `xml:"Penciller"` + Inker string `xml:"Inker"` + Colorist string `xml:"Colorist"` + Letterer string `xml:"Letterer"` + CoverArtist string `xml:"CoverArtist"` + Genre string `xml:"Genre"` + Tags string `xml:"Tags"` + Web string `xml:"Web"` + Notes string `xml:"Notes"` +} +``` + +**Updated Code:** + +```go +type ComicInfo struct { + XMLName xml.Name `xml:"ComicInfo"` + + // Basic metadata (already extracted) + Title string `xml:"Title"` + Series string `xml:"Series"` + Number int `xml:"Number"` + Volume int `xml:"Volume"` + Publisher string `xml:"Publisher"` + Year int `xml:"Year"` + Month int `xml:"Month"` + Day int `xml:"Day"` + Writer string `xml:"Writer"` + Penciller string `xml:"Penciller"` + Inker string `xml:"Inker"` + Colorist string `xml:"Colorist"` + Letterer string `xml:"Letterer"` + CoverArtist string `xml:"CoverArtist"` + Genre string `xml:"Genre"` + Tags string `xml:"Tags"` + Web string `xml:"Web"` + Notes string `xml:"Notes"` + + // NEW: Reading direction fields from ComicInfo.xml v2.0 + Manga string `xml:"Manga"` // Unknown, No, Yes, YesAndRightToLeft + LanguageISO string `xml:"LanguageISO"` // ISO 639-1 language code for heuristics + + // NEW: Additional comic-specific fields (19 total fields from ComicInfo.xml) + Count int `xml:"Count"` // Total issues in series + AlternateSeries string `xml:"AlternateSeries"` + AlternateNumber int `xml:"AlternateNumber"` + AlternateCount int `xml:"AlternateCount"` + Summary string `xml:"Summary"` + Imprint string `xml:"Imprint"` + StoryArc string `xml:"StoryArc"` + SeriesGroup string `xml:"SeriesGroup"` + AgeRating string `xml:"AgeRating"` + CommunityRating float64 `xml:"CommunityRating"` + MainCharacterOrTeam string `xml:"MainCharacterOrTeam"` + Review string `xml:"Review"` + BlackAndWhite string `xml:"BlackAndWhite"` // "Yes" or "No" + ScanInformation string `xml:"ScanInformation"` + Characters string `xml:"Characters"` + Teams string `xml:"Teams"` + Locations string `xml:"Locations"` +} +``` + +### 2.2 Enhance `MediaMetadata` Struct + +**File:** `internal/services/media_scanner.go` + +**Location:** Around line 101 (in `CalibreOPFMetadata` or similar section) + +**Note:** Verify if `MediaMetadata` struct exists. If not, create it. + +**Add Fields:** + +```go +type MediaMetadata struct { + // ... existing fields ... + Title string + Author string + ISBN string + Description string + Publisher string + Series string + SeriesNumber int32 + Tags []string + Contributors []string + PublishDate time.Time + ASIN string + + // NEW: Reading direction fields (from ComicInfo.xml or computed) + MangaType string // Raw ComicInfo.xml Manga field + ReadingDirection string // Computed: auto, ltr, rtl, vertical + Language string // ISO 639-1 language code + + // NEW: Additional metadata fields (from ComicInfo.xml or other metadata sources) + // Universal fields (apply to ebooks, audiobooks, comics) + SeriesCount int32 // Total items in series (Count field for comics, series count for books) + Volume int32 // Volume/omnibus number + Imprint string // Publisher imprint + AgeRating string // Age rating (Everyone, Teen, Mature, Adult) + WebURL string // URL to info page (Goodreads, ComicVine, etc.) + MetadataNotes string // Notes from metadata files (not user notes) + CommunityRating float64 // Pre-existing community rating (0-10) + + // Comic-specific fields + StoryArc string // Story arc name + IsBlackAndWhite bool // Black and white flag + AlternateInfo string // JSONB string of alternate series info + ScanInformation string // Scan information + Summary string // Summary from ComicInfo.xml +} +``` + +--- + +## Phase 3: Smart Metadata Merging Logic + +### 3.1 Create Metadata Merge Function + +**File:** `internal/services/media_scanner.go` + +**Location:** After `extractCalibreSidecar()` function (around line 750) + +**New Function:** + +```go +// mergeMetadata intelligently merges metadata from multiple sources +// Priority: metadata.opf (Calibre) → embedded metadata → folder structure → filename +// For comics: metadata.opf → ComicInfo.xml → folder structure → filename +func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata) (*MediaMetadata, error) { + metadata := calibreMetadata + if metadata == nil { + metadata = &MediaMetadata{} + } + + ext := strings.ToLower(filepath.Ext(path)) + + // For comic archives, try to extract ComicInfo.xml + if ext == ".cbz" || ext == ".cbr" || ext == ".cb7" || ext == ".cbt" { + comicInfo, cover, err := extractComicMetadata(path) + if err != nil { + fmt.Printf("Warning: failed to extract comic metadata from %s: %v\n", path, err) + } else if comicInfo != nil { + // Merge ComicInfo.xml fields (only if not already set from Calibre) + if metadata.Title == "" && comicInfo.Title != "" { + metadata.Title = comicInfo.Title + } + if metadata.Series == "" && comicInfo.Series != "" { + metadata.Series = comicInfo.Series + } + if metadata.SeriesNumber == 0 && comicInfo.Number > 0 { + metadata.SeriesNumber = int32(comicInfo.Number) + } + if metadata.Publisher == "" && comicInfo.Publisher != "" { + metadata.Publisher = comicInfo.Publisher + } + if metadata.Author == "" && comicInfo.Writer != "" { + metadata.Author = comicInfo.Writer + } + if metadata.Description == "" && comicInfo.Summary != "" { + metadata.Description = comicInfo.Summary + } + + // NEW: Always extract reading direction from ComicInfo.xml + // (even if metadata.opf exists, since Calibre doesn't support this field) + metadata.MangaType = normalizeMangaType(comicInfo.Manga) + metadata.ReadingDirection = determineReadingDirection(comicInfo) + metadata.Language = comicInfo.LanguageISO + + // NEW: Extract additional comic-specific fields + // Series information + if metadata.SeriesCount == 0 && comicInfo.Count > 0 { + metadata.SeriesCount = int32(comicInfo.Count) + } + if metadata.Volume == 0 && comicInfo.Volume > 0 { + metadata.Volume = int32(comicInfo.Volume) + } + + // Publisher and classification + if metadata.Imprint == "" && comicInfo.Imprint != "" { + metadata.Imprint = comicInfo.Imprint + } + if metadata.StoryArc == "" && comicInfo.StoryArc != "" { + metadata.StoryArc = comicInfo.StoryArc + } + if metadata.AgeRating == "" && comicInfo.AgeRating != "" { + metadata.AgeRating = normalizeAgeRating(comicInfo.AgeRating) + } + + // NEW: Process genres and tags (universal logic for all formats) + // Extract genre tags from ComicInfo.xml (Genre + Tags + Characters + Teams + Locations) + genreTags := extractGenreTagsFromComicInfo(comicInfo) + processGenresAndTags(metadata, genreTags) + + // Additional metadata + if metadata.WebURL == "" && comicInfo.Web != "" { + metadata.WebURL = comicInfo.Web + } + if metadata.MetadataNotes == "" && comicInfo.Notes != "" { + metadata.MetadataNotes = comicInfo.Notes + } + if metadata.ScanInformation == "" && comicInfo.ScanInformation != "" { + metadata.ScanInformation = comicInfo.ScanInformation + } + if metadata.Summary == "" && comicInfo.Summary != "" { + metadata.Summary = comicInfo.Summary + } + + // Boolean fields + if !metadata.IsBlackAndWhite && strings.ToLower(comicInfo.BlackAndWhite) == "yes" { + metadata.IsBlackAndWhite = true + } + if metadata.CommunityRating == 0 && comicInfo.CommunityRating > 0 { + metadata.CommunityRating = comicInfo.CommunityRating + } + + // Alternate series information (store as JSONB string) + if metadata.AlternateInfo == "" && (comicInfo.AlternateSeries != "" || comicInfo.AlternateNumber > 0) { + alternateData := map[string]interface{}{} + if comicInfo.AlternateSeries != "" { + alternateData["alternate_series"] = comicInfo.AlternateSeries + } + if comicInfo.AlternateNumber > 0 { + alternateData["alternate_number"] = comicInfo.AlternateNumber + } + if comicInfo.AlternateCount > 0 { + alternateData["alternate_count"] = comicInfo.AlternateCount + } + if len(alternateData) > 0 { + jsonBytes, err := json.Marshal(alternateData) + if err == nil { + metadata.AlternateInfo = string(jsonBytes) + } + } + } + + // REMOVED: Tag enhancement now handled by processGenresAndTags() + // Characters, Teams, Locations are already processed via extractGenreTagsFromComicInfo() + + // Extract cover if not already present + if len(cover) > 0 && metadata.CoverPath == "" { + coverPath := path + ".cover.jpg" + if err := os.WriteFile(coverPath, cover, 0644); err == nil { + metadata.CoverPath = s.getRelativePath(coverPath) + } + } + + fmt.Printf("Merged comic metadata from %s: title=%s, series=%s, issue=%d, manga=%s, direction=%s\n", + path, comicInfo.Title, comicInfo.Series, comicInfo.Number, comicInfo.Manga, metadata.ReadingDirection) + } + } + + return metadata, nil +} + +// containsTag checks if a tag already exists in the tags array +func containsTag(tags []string, tag string) bool { + tag = strings.ToLower(tag) + for _, t := range tags { + if strings.ToLower(t) == tag { + return true + } + } + return false +} + +// normalizeAgeRating normalizes age rating from ComicInfo.xml to standard values +func normalizeAgeRating(rating string) string { + rating = strings.ToLower(strings.TrimSpace(rating)) + switch rating { + case "everyone", "e", "all ages": + return "Everyone" + case "teen", "t", "13+", "13+up": + return "Teen" + case "mature", "m", "17+", "17+up", "adults only": + return "Mature" + case "adult", "a", "18+": + return "Adult" + default: + return rating // Return original if unknown + } +} +``` + +### 3.2 Create Reading Direction Helper Functions + +**File:** `internal/services/media_scanner.go` + +**Location:** After `mergeMetadata()` function + +**New Functions:** + +```go +// normalizeMangaType normalizes ComicInfo.xml Manga field to database enum values +func normalizeMangaType(manga string) string { + switch strings.ToLower(strings.ReplaceAll(manga, " ", "")) { + case "unknown": + return "unknown" + case "no": + return "no" + case "yes": + return "yes" + case "yesandrighttoleft": + return "yes_and_right_to_left" + default: + return "unknown" + } +} + +// determineReadingDirection computes reading direction from ComicInfo metadata +// Uses Manga field + language heuristics + genre tags +func determineReadingDirection(comicInfo *ComicInfo) string { + // 1. Check explicit Manga field + manga := normalizeMangaType(comicInfo.Manga) + switch manga { + case "yes_and_right_to_left": + return "rtl" // Traditional Japanese manga + case "yes", "no": + return "ltr" // Manga style but LTR, or Western comic + } + + // 2. Language heuristic: Japanese → RTL + lang := strings.ToLower(comicInfo.LanguageISO) + if lang == "ja" || lang == "jpn" { + return "rtl" + } + + // 3. Genre heuristic: webtoons/manhwa → vertical + tags := strings.ToLower(comicInfo.Tags + " " + comicInfo.Genre) + if strings.Contains(tags, "webtoon") || strings.Contains(tags, "manhwa") { + return "vertical" // Korean/Chinese webcomics + } + if strings.Contains(tags, "manga") && (lang == "ja" || lang == "jpn") { + return "rtl" // Japanese manga + } + + // 4. Default: LTR (Western comics) + return "ltr" +} +``` + +### 3.3 Genre and Tag Processing Logic (Universal for ALL Formats) + +**File:** `internal/services/media_scanner.go` + +**Location:** After `normalizeAgeRating()` function + +**New Function:** + +```go +// processGenresAndTags ensures ALL genres appear in the tags array without duplication +// This applies to ALL formats: EPUB, ComicInfo.xml, PDF metadata +// Strategy: Use existing `genre` column for primary genre, `tags` array for all genres +func processGenresAndTags(metadata *MediaMetadata, genreTags []string) { + if metadata.Tags == nil { + metadata.Tags = []string{} + } + + // 1. Set primary genre (first genre tag wins if not already set) + if metadata.Genre == "" && len(genreTags) > 0 { + metadata.Genre = genreTags[0] + } + + // 2. Ensure ALL genre tags appear in tags array (without duplication) + for _, genreTag := range genreTags { + genreTag = strings.TrimSpace(genreTag) + if genreTag != "" && !containsTag(metadata.Tags, genreTag) { + metadata.Tags = append(metadata.Tags, genreTag) + } + } +} + +// extractGenreTagsFromEPUB extracts all values from EPUB +// Returns array of genre tags +func extractGenreTagsFromEPUB(book *epub.Book) []string { + var genreTags []string + + // EPUB stores genres in metadata + if subjects, err := book.MetadataByKey("subject"); err == nil && len(subjects) > 0 { + for _, subject := range subjects { + subject = strings.TrimSpace(subject) + if subject != "" { + genreTags = append(genreTags, subject) + } + } + } + + return genreTags +} + +// extractGenreTagsFromComicInfo extracts genres from ComicInfo.xml +// Genre field + Tags field + Characters + Teams + Locations +// Returns array of genre tags +func extractGenreTagsFromComicInfo(comicInfo *ComicInfo) []string { + var genreTags []string + + // 1. Add Genre field + if comicInfo.Genre != "" { + genreTags = append(genreTags, strings.Split(comicInfo.Genre, ",")...) + } + + // 2. Add Tags field (comma-separated) + if comicInfo.Tags != "" { + genreTags = append(genreTags, strings.Split(comicInfo.Tags, ",")...) + } + + // 3. Add Characters (comma-separated) + if comicInfo.Characters != "" { + genreTags = append(genreTags, strings.Split(comicInfo.Characters, ",")...) + } + + // 4. Add Teams (comma-separated) + if comicInfo.Teams != "" { + genreTags = append(genreTags, strings.Split(comicInfo.Teams, ",")...) + } + + // 5. Add Locations (comma-separated) + if comicInfo.Locations != "" { + genreTags = append(genreTags, strings.Split(comicInfo.Locations, ",")...) + } + + // Trim whitespace from all tags + for i := range genreTags { + genreTags[i] = strings.TrimSpace(genreTags[i]) + } + + return genreTags +} +``` + +**Usage in mergeMetadata():** + +```go +// In mergeMetadata() function, after extracting metadata: + +// For EPUB files +if ext == ".epub" { + book, err := epub.ReadBook(path) + if err == nil { + genreTags := extractGenreTagsFromEPUB(book) + processGenresAndTags(metadata, genreTags) + } +} + +// For comic archives +if ext == ".cbz" || ext == ".cbr" || ext == ".cb7" || ext == ".cbt" { + comicInfo, cover, err := extractComicMetadata(path) + if err == nil && comicInfo != nil { + genreTags := extractGenreTagsFromComicInfo(comicInfo) + processGenresAndTags(metadata, genreTags) + } +} +``` + +**Key Points:** + +1. **Single `genre` column**: Primary classification (first genre tag) +2. **`tags` array**: ALL genres/tags without duplication +3. **Universal logic**: Works the same for EPUB, ComicInfo.xml, PDF metadata +4. **No `genre_comic` column needed**: Reuse existing `genre` column +5. **Deduplication**: `containsTag()` helper prevents duplicates + +**Examples:** + +**EPUB:** +- Input: `FantasyAdventure` +- Result: `genre` = "Fantasy", `tags` = ["Fantasy", "Adventure"] + +**ComicInfo.xml:** +- Input: `ActionAdventure, FightingNaruto, Sasuke` +- Result: `genre` = "Action", `tags` = ["Action", "Adventure", "Fighting", "Naruto", "Sasuke"] + +**Calibre metadata.opf:** +- Input: Multiple `` tags +- Result: `genre` = first subject, `tags` = all subjects + +### 3.4 Update `extractMetadata()` Function + +**File:** `internal/services/media_scanner.go` + +**Location:** Lines 752-798 + +**Current Code (lines 752-764):** + +```go +func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) { + // NEW: Try Calibre sidecar first + if metadata := s.extractCalibreSidecar(path); metadata != nil { + fmt.Printf("Using Calibre metadata.opf for %s\n", path) + + // Try to find cover image for sidecar metadata + coverPath := findSidecarCover(path) + if coverPath != "" { + metadata.CoverPath = s.getRelativePath(coverPath) + } + + return metadata, nil + } +``` + +**Updated Code:** + +```go +func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) { + // Try Calibre sidecar first + calibreMetadata := s.extractCalibreSidecar(path) + if calibreMetadata != nil { + fmt.Printf("Using Calibre metadata.opf for %s\n", path) + + // Try to find cover image for sidecar metadata + coverPath := findSidecarCover(path) + if coverPath != "" { + calibreMetadata.CoverPath = s.getRelativePath(coverPath) + } + } + + // NEW: Smart merge - also parse embedded metadata for missing fields + // This ensures we get reading direction from ComicInfo.xml even when metadata.opf exists + return s.mergeMetadata(path, calibreMetadata) +``` + +--- + +## Phase 4: Update Media Item Creation + +### 4.1 Update `processNewMediaItem()` Function + +**File:** `internal/services/media_scanner.go` + +**Location:** Lines 495-729 (in `processNewMediaItem()`) + +**Find:** Database insertion call around line 672 + +**Current Code (lines 672-693):** + +```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}, +}) +``` + +**Updated Code:** + +```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 != ""}, +}) +``` + +### 4.2 Remove Duplicate Comic Metadata Extraction + +**File:** `internal/services/media_scanner.go` + +**Location:** Lines 583-622 (in `processNewMediaItem()`) + +**Current Code:** + +```go +var comicInfo *ComicInfo +var coverImage []byte + +ext := strings.ToLower(filepath.Ext(path)) +if ext == ".cbz" || ext == ".cbr" || ext == ".cb7" || ext == ".cbt" || + strings.HasSuffix(strings.ToLower(path), ".tar.gz") || + strings.HasSuffix(strings.ToLower(path), ".tar.bz2") || + strings.HasSuffix(strings.ToLower(path), ".tgz") || + strings.HasSuffix(strings.ToLower(path), ".tbz2") { + info, cover, err := extractComicMetadata(path) + if err != nil { + fmt.Printf("Warning: failed to extract comic metadata from %s: %v\n", path, err) + } else { + comicInfo = info + coverImage = cover + if comicInfo.Title != "" && metadata.Title == "" { + metadata.Title = comicInfo.Title + } + // ... more field merging ... + } +} +``` + +**Updated Code:** + +```go +// REMOVED: Comic metadata extraction now handled by mergeMetadata() +// This avoids duplicate extraction and ensures smart merging happens +``` + +**Note:** The entire block (lines 583-622) should be removed since `mergeMetadata()` now handles this. + +--- + +## Phase 5: API Layer Updates + +### 5.1 Update TypeScript API Types + +**File:** `web/src/types/api.d.ts` + +**Location:** Lines 13-60 (in `MediaItemSummary` interface) + +**Current Code:** + +```go +interface MediaItemSummary { + id: string; + library_id: string; + title: string; + author?: string; + isbn?: string; + description?: string; + file_path: string; + file_size?: number; + mime_type?: string; + cover_image_path?: string; + series?: string; + series_number?: number; + tags?: string[]; + asin?: string; + date_published?: string; + publisher?: string; + contributors?: string[]; + language?: string; + edition?: string; + page_count?: number; + genre?: string; + copyright_year?: number; + goodreads_id?: string; + openlibrary_id?: string; + google_books_id?: string; + added_by_admin_id?: string; + created_at: string; + updated_at: string; + format_group: string; + format_mimetype?: string; + is_reflowable?: boolean; + has_fixed_layout?: boolean; + total_characters?: number; + chapter_count?: number; + entitlement_id?: string; + revision_number?: number; + kobo_content_id?: string; + kobo_metadata?: string; + tags_search?: string[]; + contributors_search?: string[]; + file_sha256?: string; + opf_identifier?: string; + opf_uuid?: string; + hash_confidence?: string; + library_name: string; + library_type_name: string; +} +``` + +**Updated Code:** + +```typescript +interface MediaItemSummary { + id: string; + library_id: string; + title: string; + author?: string; + isbn?: string; + description?: string; + file_path: string; + file_size?: number; + mime_type?: string; + cover_image_path?: string; + series?: string; + series_number?: number; + tags?: string[]; + asin?: string; + date_published?: string; + publisher?: string; + contributors?: string[]; + language?: string; + edition?: string; + page_count?: number; + genre?: string; + copyright_year?: number; + goodreads_id?: string; + openlibrary_id?: string; + google_books_id?: string; + added_by_admin_id?: string; + created_at: string; + updated_at: string; + format_group: string; + format_mimetype?: string; + is_reflowable?: boolean; + has_fixed_layout?: boolean; + total_characters?: number; + chapter_count?: number; + entitlement_id?: string; + revision_number?: number; + kobo_content_id?: string; + kobo_metadata?: string; + tags_search?: string[]; + contributors_search?: string[]; + file_sha256?: string; + opf_identifier?: string; + opf_uuid?: string; + hash_confidence?: string; + library_name: string; + library_type_name: string; + + // NEW: Reading direction fields for manga/comics + manga_type?: 'unknown' | 'no' | 'yes' | 'yes_and_right_to_left'; + reading_direction?: 'auto' | 'ltr' | 'rtl' | 'vertical'; + + // NEW: Universal metadata fields (apply to ebooks, audiobooks, comics) + series_count?: number; + volume?: number; + imprint?: string; + age_rating?: string; // 'Everyone' | 'Teen' | 'Mature' | 'Adult' + web_url?: string; + metadata_notes?: string; + community_rating?: number; + + // NEW: Comic-specific fields + story_arc?: string; + is_black_and_white?: boolean; + alternate_info?: { alternate_series?: string; alternate_number?: number; alternate_count?: number }; + scan_information?: string; + summary?: string; +} +``` + +### 5.2 Verify API Response Handling + +**Files to Check:** +- `internal/handlers/media.go` - Ensure `SearchMediaItems()` includes new columns +- `internal/handlers/collections.go` - Ensure `BookInfo` can include reading direction if needed +- `internal/database/queries.sql` - Ensure queries select `manga_type` and `reading_direction` + +**Note:** SQL queries should automatically include new columns after `sqlc generate` is run. + +--- + +## Phase 6: Testing + +### 6.1 Unit Tests + +**File:** `internal/services/media_scanner_comic_test.go` + +**New Test Cases to Add:** + +```go +func TestNormalizeMangaType(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"Unknown", "Unknown", "unknown"}, + {"No", "No", "no"}, + {"Yes", "Yes", "yes"}, + {"YesAndRightToLeft", "YesAndRightToLeft", "yes_and_right_to_left"}, + {"Lowercase yesandrighttoleft", "yesandrighttoleft", "yes_and_right_to_left"}, + {"With spaces", "Yes And Right To Left", "yes_and_right_to_left"}, + {"Invalid", "invalid", "unknown"}, + {"Empty", "", "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeMangaType(tt.input) + if result != tt.expected { + t.Errorf("normalizeMangaType(%q) = %q; want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestDetermineReadingDirection(t *testing.T) { + tests := []struct { + name string + manga string + language string + tags string + genre string + expected string + }{ + {"Explicit RTL", "YesAndRightToLeft", "en", "", "", "rtl"}, + {"Explicit LTR (Yes)", "Yes", "ja", "", "", "ltr"}, + {"Explicit LTR (No)", "No", "en", "", "", "ltr"}, + {"Japanese heuristic", "Unknown", "ja", "", "", "rtl"}, + {"Japanese with full code", "Unknown", "jpn", "", "", "rtl"}, + {"Webtoon Korean", "Unknown", "ko", "Webtoon", "", "vertical"}, + {"Manhwa in tags", "Unknown", "ko", "", "Manhwa", "vertical"}, + {"Manga + Japanese", "Unknown", "ja", "Manga", "", "rtl"}, + {"Western default", "Unknown", "en", "", "", "ltr"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + comicInfo := &ComicInfo{ + Manga: tt.manga, + LanguageISO: tt.language, + Tags: tt.tags, + Genre: tt.genre, + } + result := determineReadingDirection(comicInfo) + if result != tt.expected { + t.Errorf("determineReadingDirection() = %q; want %q", result, tt.expected) + } + }) + } +} + +func TestMergeMetadata(t *testing.T) { + // Test case 1: metadata.opf exists, ComicInfo.xml exists + // Expected: metadata.opf fields take priority, except reading_direction from ComicInfo + + // Test case 2: metadata.opf exists, ComicInfo.xml missing + // Expected: only metadata.opf fields used + + // Test case 3: metadata.opf missing, ComicInfo.xml exists + // Expected: ComicInfo.xml fields used + + // Test case 4: Both missing + // Expected: fallback to filename +} +``` + +### 6.2 Integration Tests + +**File:** `cmd/server/tests/comic_metadata_test.go` + +**Create New File:** Following test_helpers pattern + +**Test Structure:** + +```go +package tests + +import ( + "testing" + "github.com/stretchr/testify/require" +) + +// TestComicMetadataExtraction tests that ComicInfo.xml is parsed correctly +func TestComicMetadataExtraction(t *testing.T) { + ts := setupTestServer(t) + defer ts.cleanup() + + // 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 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() + + 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() + + 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. + +--- + +## Phase 7: Bruno API Tests + +### 7.1 Create Bruno YAML Files + +**Directory:** `bruno/media-items/` + +**Files to Create:** + +#### `bruno/media-items/search-with-reading-direction.yml` + +```yaml +meta: + name: Search media items with reading direction filter + type: http + seq: 1 +config: + test: + filter: search-reading-direction + workflow: search + +http: + method: GET + url: '{{baseUrl}}/api/media-items?library_id={{libraryId}}&reading_direction=rtl' + headers: + Authorization: 'Bearer {{accessToken}}' +``` + +#### `bruno/media-items/create-comic-with-metadata.yml` + +```yaml +meta: + name: Upload comic with ComicInfo.xml + type: http + seq: 2 +config: + test: + filter: comic-upload + workflow: upload + +http: + method: POST + url: '{{baseUrl}}/api/media-items/upload' + headers: + Authorization: 'Bearer {{accessToken}}' + Content-Type: 'multipart/form-data' + body: + form_data: + library_id: '{{libraryId}}' + file: + type: file + src: test-files/manga-rtl.cbz +``` + +--- + +## Phase 8: Documentation + +### 8.1 Update User Documentation + +**File:** `docs/user/manga-reading-direction.md` + +**Create New File:** + +```markdown +# Manga Reading Direction Support + +Bookhoard automatically detects and stores the reading direction for manga and comics. + +## Supported Reading Directions + +- **LTR (Left-to-Right)**: Western comics, manhwa, some manga +- **RTL (Right-to-Left)**: Traditional Japanese manga +- **Vertical**: Webtoons, manhwa (Korean webcomics) +- **Auto**: System will determine direction automatically + +## How Reading Direction is Detected + +1. **ComicInfo.xml**: If your comic archive contains `ComicInfo.xml` with the `Manga` field set to `YesAndRightToLeft`, Bookhoard will mark it as RTL +2. **Language**: Japanese comics without metadata are assumed to be RTL +3. **Genre**: Comics tagged as "webtoon" or "manhwa" are marked as vertical +4. **Manual**: You can manually override the reading direction in the metadata editor + +## ComicInfo.xml Support + +Bookhoard supports the industry-standard `ComicInfo.xml` format for comic metadata. When importing comics: + +- If `ComicInfo.xml` exists inside the archive, its metadata is extracted +- The `Manga` field is used to set reading direction +- If a Calibre `metadata.opf` file exists in the same folder, both files are merged: + - Calibre metadata takes priority for title, author, publisher, etc. + - ComicInfo.xml takes priority for reading direction (since Calibre doesn't support it) + +## Metadata Editor + +You can view and edit reading direction in the metadata editor: + +1. Navigate to a manga or comic in your library +2. Click "Edit Metadata" +3. Change the "Reading Direction" field +4. Save to update the database (and optionally write back to the file) + +## Device Sync + +Reading direction is synced to your devices: + +- **KOReader**: Reading direction is set in the document metadata +- **Kobo**: Reading direction is stored in the Kobo metadata +- **Web Reader**: The reader automatically adjusts page turn direction based on reading direction + +## Troubleshooting + +### My manga is showing the wrong reading direction + +1. Check if the comic has `ComicInfo.xml` inside the archive +2. Open the archive and verify the `Manga` field value: + - `YesAndRightToLeft` → RTL + - `Yes` or `No` → LTR +3. If no metadata exists, manually edit the reading direction in Bookhoard + +### How do I add ComicInfo.xml to my comics? + +Use a comic metadata editor such as: + +- **ComicTagger** (cross-platform) +- **ComicRack** (Windows) +- **Komga** (can generate metadata for your library) + +### My Calibre library has metadata.opf files. Will they work? + +Yes! Bookhoard intelligently merges Calibre `metadata.opf` and `ComicInfo.xml`: + +- Calibre metadata is used for title, author, series, etc. +- ComicInfo.xml is used for reading direction +- You get the best of both worlds +``` + +### 8.2 Update API Documentation + +**File:** `docs/developer/api/media-items/search.md` + +**Update:** Add `reading_direction` parameter to query parameters section + +**Add:** + +``` +### Query Parameters + +| Parameter | Type | Description | Example | +|-----------|------|-------------|---------| +| library_id | string | Filter by library UUID | `?library_id=uuid` | +| reading_direction | string | Filter by reading direction: `auto`, `ltr`, `rtl`, `vertical` | `?reading_direction=rtl` | +| manga_type | string | Filter by raw manga type: `unknown`, `no`, `yes`, `yes_and_right_to_left` | `?manga_type=yes_and_right_to_left` | +``` + +**File:** `docs/developer/api/media-items/object.md` + +**Update:** Add `manga_type` and `reading_direction` to MediaItemSummary object + +**Add:** + +``` +### MediaItemSummary Object + +| Field | Type | Description | +|-------|------|-------------| +| ... existing fields ... | +| manga_type | string | Raw `Manga` field from ComicInfo.xml: `unknown`, `no`, `yes`, `yes_and_right_to_left` | +| reading_direction | string | Computed reading direction: `auto`, `ltr` (left-to-right), `rtl` (right-to-left), `vertical` (webtoons) | +``` + +--- + +## Phase 9: Build & Verification + +### 9.1 Build Verification + +**Commands:** + +```bash +# Build backend +go build ./... + +# Run tests +go test ./... -v + +# Verify guidelines compliance +bash scripts/verify-guidelines.sh +``` + +### 9.2 Database Update + +**Option 1: Recreate Database (Recommended - Loses Data)** + +```bash +podman compose down -v # Delete all volumes +podman compose up -d # Start with fresh schema +``` + +**Option 2: Manual Migration (Preserves Data)** + +```bash +podman exec bookhoard_db psql -U postgres -d bookhoard -c " +-- Add manga and comic metadata fields (16 new columns) +ALTER TABLE media_items +ADD COLUMN manga_type VARCHAR(30) DEFAULT 'unknown' +CHECK (manga_type IN ('unknown', 'no', 'yes', 'yes_and_right_to_left')); + +ALTER TABLE media_items +ADD COLUMN reading_direction VARCHAR(20) DEFAULT 'auto' +CHECK (reading_direction IN ('auto', 'ltr', 'rtl', 'vertical')); + +ALTER TABLE media_items +ADD COLUMN series_count INTEGER; + +ALTER TABLE media_items +ADD COLUMN volume INTEGER; + +ALTER TABLE media_items +ADD COLUMN imprint VARCHAR(255); + +ALTER TABLE media_items +ADD COLUMN story_arc VARCHAR(255); + +ALTER TABLE media_items +ADD COLUMN age_rating VARCHAR(20); + +ALTER TABLE media_items +ADD COLUMN web_url VARCHAR(500); + +ALTER TABLE media_items +ADD COLUMN metadata_notes TEXT; + +ALTER TABLE media_items +ADD COLUMN is_black_and_white BOOLEAN DEFAULT FALSE; + +ALTER TABLE media_items +ADD COLUMN community_rating DECIMAL(3,1); + +ALTER TABLE media_items +ADD COLUMN alternate_info JSONB; + +ALTER TABLE media_items +ADD COLUMN scan_information TEXT; + +ALTER TABLE media_items +ADD COLUMN summary TEXT; + +-- Create indexes for efficient querying +CREATE INDEX IF NOT EXISTS idx_media_items_reading_direction +ON media_items(reading_direction) +WHERE reading_direction IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_media_items_story_arc +ON media_items(story_arc) +WHERE story_arc IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_media_items_imprint +ON media_items(imprint) +WHERE imprint IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_media_items_age_rating +ON media_items(age_rating) +WHERE age_rating IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_media_items_manga_type +ON media_items(manga_type) +WHERE manga_type IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_media_items_series_count +ON media_items(series_count) +WHERE series_count IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_media_items_volume +ON media_items(volume) +WHERE volume IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_media_items_alternate_info_gin +ON media_items USING GIN (alternate_info) +WHERE alternate_info IS NOT NULL; + +-- Add column comments +COMMENT ON COLUMN media_items.manga_type IS 'Raw Manga field from ComicInfo.xml: unknown, no, yes, yes_and_right_to_left'; +COMMENT ON COLUMN media_items.reading_direction IS 'Computed reading direction: auto, ltr, rtl, vertical'; +COMMENT ON COLUMN media_items.series_count IS 'Total items in series (from ComicInfo.xml Count field or book series count)'; +COMMENT ON COLUMN media_items.volume IS 'Volume/omnibus number for collected editions'; +COMMENT ON COLUMN media_items.imprint IS 'Publisher imprint/subdivision (e.g., Vertigo, Icon, DC Black Label)'; +COMMENT ON COLUMN media_items.story_arc IS 'Story arc name for grouping related issues (e.g., "The Dark Phoenix Saga", "Civil War")'; +COMMENT ON COLUMN media_items.age_rating IS 'Age rating: Everyone, Teen, Mature, Adult'; +COMMENT ON COLUMN media_items.web_url IS 'URL to info page (Goodreads, ComicVine, MangaUpdates, Audible, etc.)'; +COMMENT ON COLUMN media_items.metadata_notes IS 'Notes from metadata files (ComicInfo.xml, EPUB, PDF) - distinct from user notes'; +COMMENT ON COLUMN media_items.is_black_and_white IS 'Black and white flag (mostly comics)'; +COMMENT ON COLUMN media_items.community_rating IS 'Community rating from metadata (scale 0.0-10.0) - distinct from user ratings'; +COMMENT ON COLUMN media_items.alternate_info IS 'Alternate series information as JSONB: {alternate_series, alternate_number, alternate_count}'; +COMMENT ON COLUMN media_items.scan_information IS 'Scan information (scanner group, resolution, etc.)'; +COMMENT ON COLUMN media_items.summary IS 'Summary from ComicInfo.xml (may be merged with description from Calibre)'; +" +``` + +**Option 3: Using SQL File** + +```bash +# Save migration to file +cat > /tmp/add_comic_metadata.sql << 'EOF' +-- Add comprehensive metadata support (15 new columns) +-- Universal fields (apply to ebooks, audiobooks, comics) +ALTER TABLE media_items +ADD COLUMN manga_type VARCHAR(30) DEFAULT 'unknown' +CHECK (manga_type IN ('unknown', 'no', 'yes', 'yes_and_right_to_left')); + +ALTER TABLE media_items +ADD COLUMN reading_direction VARCHAR(20) DEFAULT 'auto' +CHECK (reading_direction IN ('auto', 'ltr', 'rtl', 'vertical')); + +ALTER TABLE media_items ADD COLUMN series_count INTEGER; +ALTER TABLE media_items ADD COLUMN volume INTEGER; +ALTER TABLE media_items ADD COLUMN imprint VARCHAR(255); +ALTER TABLE media_items ADD COLUMN age_rating VARCHAR(20); +ALTER TABLE media_items ADD COLUMN web_url VARCHAR(500); +ALTER TABLE media_items ADD COLUMN metadata_notes TEXT; +ALTER TABLE media_items ADD COLUMN community_rating DECIMAL(3,1); + +-- Comic-specific fields +ALTER TABLE media_items ADD COLUMN story_arc VARCHAR(255); +ALTER TABLE media_items ADD COLUMN is_black_and_white BOOLEAN DEFAULT FALSE; +ALTER TABLE media_items ADD COLUMN alternate_info JSONB; +ALTER TABLE media_items ADD COLUMN scan_information TEXT; +ALTER TABLE media_items ADD COLUMN summary TEXT; + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_media_items_reading_direction +ON media_items(reading_direction) WHERE reading_direction IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_media_items_story_arc +ON media_items(story_arc) WHERE story_arc IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_media_items_imprint +ON media_items(imprint) WHERE imprint IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_media_items_age_rating +ON media_items(age_rating) WHERE age_rating IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_media_items_manga_type +ON media_items(manga_type) WHERE manga_type IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_media_items_series_count +ON media_items(series_count) WHERE series_count IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_media_items_volume +ON media_items(volume) WHERE volume IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_media_items_alternate_info_gin +ON media_items USING GIN (alternate_info) WHERE alternate_info IS NOT NULL; + +-- Add comments +COMMENT ON COLUMN media_items.manga_type IS 'Raw Manga field from ComicInfo.xml: unknown, no, yes, yes_and_right_to_left'; +COMMENT ON COLUMN media_items.reading_direction IS 'Computed reading direction: auto, ltr, rtl, vertical'; +COMMENT ON COLUMN media_items.series_count IS 'Total items in series'; +COMMENT ON COLUMN media_items.volume IS 'Volume/omnibus number for collected editions'; +COMMENT ON COLUMN media_items.imprint IS 'Publisher imprint (e.g., Vertigo, Icon)'; +COMMENT ON COLUMN media_items.age_rating IS 'Age rating: Everyone, Teen, Mature, Adult'; +COMMENT ON COLUMN media_items.web_url IS 'URL to info page (Goodreads, ComicVine, MangaUpdates, Audible, etc.)'; +COMMENT ON COLUMN media_items.metadata_notes IS 'Notes from metadata files (not user notes)'; +COMMENT ON COLUMN media_items.community_rating IS 'Community rating (0.0-10.0)'; +COMMENT ON COLUMN media_items.story_arc IS 'Story arc name for grouping'; +COMMENT ON COLUMN media_items.is_black_and_white IS 'Black and white flag (mostly comics)'; +COMMENT ON COLUMN media_items.alternate_info IS 'Alternate series as JSONB'; +COMMENT ON COLUMN media_items.scan_information IS 'Scan information'; +COMMENT ON COLUMN media_items.summary IS 'Summary from ComicInfo.xml'; +EOF + +# Apply migration +podman exec -i bookhoard_db psql -U postgres -d bookhoard < /tmp/add_comic_metadata.sql +``` + +--- + +## Phase 10: Git Commit Structure + +### Commit Strategy Overview + +**Principle**: Make good organized git commits with detailed messages for the entire project (not just what you changed). Run git add, commit, push sequentially as separate commands. Do not make one giant commit unless you are sure all files changed are for the same related edit. + +**Total Commits**: 10 separate commits (organized by logical grouping) + +### Commits (Sequential, No `&&` Chaining) + +**IMPORTANT**: Run these commands SEQUENTIALLY, waiting for each to complete before starting the next. + +```bash +# Commit 1: Database schema changes +git add database/schema/schema.sql +git commit -m "feat: add comprehensive comic metadata support to media_items table + +Add 16 new columns to support all ComicInfo.xml v2.0 fields: +- manga_type: Raw Manga field (unknown, no, yes, yes_and_right_to_left) +- reading_direction: Computed direction (auto, ltr, rtl, vertical) +- series_count: Total issues in series +- volume: Volume number for collected editions +- imprint: Publisher imprint (e.g., Vertigo, Icon) +- story_arc: Story arc name for grouping issues +- age_rating: Age rating (Everyone, Teen, Mature, Adult) +- genre_comic: Comic-specific genre +- web_url: URL to comic info page +- metadata_notes: Notes from ComicInfo.xml (distinct from user notes) +- is_black_and_white: B/W flag +- community_rating: Community rating (0-10 scale) +- alternate_info: JSONB for alternate series data +- scan_information: Scan info (scanner, resolution) +- summary: Summary from ComicInfo.xml + +Add 8 indexes for efficient comic and universal metadata queries: +- idx_media_items_reading_direction +- idx_media_items_story_arc +- idx_media_items_imprint +- idx_media_items_age_rating +- idx_media_items_manga_type +- idx_media_items_series_count (NEW) +- idx_media_items_volume (NEW) +- idx_media_items_alternate_info_gin + +Supports all 19 ComicInfo.xml fields plus universal fields for all formats: +Comic-specific: reading direction, story arc, scan information, alternate info, B/W flag +Universal (ebooks, audiobooks, comics): age rating, series count, volume, imprint, web URL, metadata notes, community rating + +Genre/tag processing: ALL genres appear in tags array without duplication +- Uses existing `genre` column for primary genre (first genre tag) +- Populates `tags` array with all genres from metadata files +- Works for EPUB, ComicInfo.xml, PDF metadata +- No genre_comic column needed (reuses existing genre column)" + +git add internal/database/ +git commit -m "chore: regenerate database models for comprehensive metadata support + +- Regenerate models.go with sqlc after schema changes +- Update CreateMediaItemParams with 15 new fields (was 16, removed genre_comic) +- Update all queries that return MediaItems to include new columns +- Support pgtype types for TEXT, JSONB, DECIMAL, BOOLEAN fields" + +git add internal/services/media_scanner.go +git commit -m "feat: add smart metadata merging with genre/tag processing + +- Add all 19 fields to ComicInfo struct (complete v2.0 support) +- Add universal + comic fields to MediaMetadata struct (15 fields total) +- Implement comprehensive mergeMetadata() for intelligent merging: + - Priority: Calibre metadata.opf → ComicInfo.xml → folder structure → filename + - Always extract comic fields even when metadata.opf exists + - Merge Characters, Teams, Locations into tags array via extractGenreTagsFromComicInfo() + - Build alternate_info JSONB from AlternateSeries/Number/Count +- Add genre/tag processing functions (universal for ALL formats): + - processGenresAndTags(): Ensures ALL genres appear in tags without duplication + - extractGenreTagsFromEPUB(): Extract genres from EPUB tags + - extractGenreTagsFromComicInfo(): Extract Genre + Tags + Characters + Teams + Locations + - Uses existing `genre` column for primary genre (first genre tag) + - Populates `tags` array with all genres (no duplicates via containsTag()) +- Add helper functions: + - normalizeMangaType(): Standardize Manga field values + - determineReadingDirection(): Compute from Manga + language + genre + - normalizeAgeRating(): Standardize age rating values + - containsTag(): Prevent duplicate tags +- Update extractMetadata() to use smart merging +- Remove duplicate comic metadata extraction in processNewMediaItem() +- Calibre metadata.opf takes priority for basic fields +- ComicInfo.xml fills gaps and provides comic-specific fields +- Ensures all 19 ComicInfo.xml fields + 5 universal fields are extracted + +Supports complete ComicInfo.xml v2.0 field extraction: +- Series organization (Count, Volume, AlternateSeries) +- Publisher hierarchy (Publisher, Imprint) +- Content classification (Genre, Manga, AgeRating, StoryArc, SeriesGroup) +- Narrative elements (Summary, Notes, Characters, Teams, Locations) +- Publication metadata (Year, Month, Day, Web, PageCount, LanguageISO) +- Credits (Writer, Penciller, Inker, Colorist, Letterer, CoverArtist, Editor) +- Format (BlackAndWhite, Manga) +- Community data (CommunityRating, MainCharacterOrTeam, Review) +- Technical (ScanInformation) + +Universal metadata fields (apply to ebooks, audiobooks, comics): +- age_rating: Age-based content classification +- series_count: Total items in series +- volume: Collected edition/omnibus number +- imprint: Publisher subdivision +- web_url: Info page URL (Goodreads, Audible, ComicVine, etc.) +- metadata_notes: Notes from metadata files (not user notes) +- community_rating: Pre-existing ratings from metadata" + +git add internal/services/media_scanner_comic_test.go +git commit -m "test: add unit tests for metadata extraction and genre/tag processing + +- Add TestNormalizeMangaType with all enum values and edge cases +- Add TestDetermineReadingDirection with Manga, language, genre heuristics +- Add TestNormalizeAgeRating with all rating values +- Add TestProcessGenresAndTags for universal genre/tag logic +- Add TestExtractGenreTagsFromEPUB for EPUB subject processing +- Add TestExtractGenreTagsFromComicInfo for comic tag processing +- Add TestMergeMetadata for metadata.opf + ComicInfo.xml merge scenarios +- Test all 19 ComicInfo.xml fields + 5 universal fields are extracted correctly +- Test alternate_info JSONB serialization +- Test tag deduplication (no duplicate genres in tags array) +- Test genre → primary genre, all genres → tags logic +- Cover RTL, LTR, vertical reading direction detection +- Test webtoon, manhwa, Japanese manga detection +- Test age rating normalization (Everyone, Teen, Mature, Adult) +- Test community rating extraction +- Test series count and volume extraction" + +git add cmd/server/tests/comic_metadata_test.go +git commit -m "test: add integration tests for comic metadata extraction + +- Add TestComicMetadataExtraction for CBZ with/without ComicInfo.xml +- Test all 19 fields are stored correctly in database +- Add TestCalibreComicMerge for metadata.opf + ComicInfo.xml merging +- Verify priority: metadata.opf → ComicInfo.xml → folder structure +- Add TestReadingDirectionAPI for API response validation +- Test alternate_info JSONB in API responses +- Test search by story_arc, imprint, age_rating +- Test series_count and volume in API responses +- Test Characters/Teams/Locations merged into tags +- Use setupTestServer() helper from test_helpers +- Test no user, user, and admin contexts" + +git add web/src/types/api.d.ts +git commit -m "feat: add all comic metadata fields to MediaItemSummary type + +- Add manga_type field with enum values +- Add reading_direction field with ltr/rtl/vertical/auto options +- Add series_count, volume, imprint, story_arc +- Add age_rating, genre_comic, web_url +- Add metadata_notes (distinct from user notes) +- Add is_black_and_white, community_rating +- Add alternate_info JSONB object +- Add scan_information, summary +- Ensure TypeScript types match Go database models +- Support all 15 new metadata fields (5 universal + 10 comic-specific)" + +git add bruno/media-items/ +git commit -m "test: add Bruno API tests for comic metadata feature + +- Add search-with-reading-direction.yml for filtering by direction +- Add search-by-story-arc.yml for story arc queries +- Add search-by-imprint.yml for imprint filtering +- Add search-by-age-rating.yml for age rating queries (universal) +- Add search-by-series-count.yml for series filtering (universal) +- Add create-comic-with-metadata.yml for uploading with ComicInfo.xml +- Test API contracts for all new fields (universal + comic-specific) +- Verify JSONB serialization of alternate_info +- Test genre/tag processing (all genres appear in tags without duplication)" + +git add docs/user/manga-reading-direction.md +git commit -m "docs: add comprehensive user documentation for metadata features + +- Explain all 19 ComicInfo.xml fields +- Document reading direction detection (Manga + language + genre) +- Document smart merging with Calibre metadata.opf +- Explain universal fields (age_rating, series_count, volume, imprint, web_url) +- Explain comic-specific fields (story arc, scan_information, alternate_info) +- Document genre/tag processing (all genres appear in tags) +- Add troubleshooting guide for missing metadata +- Document metadata editor usage +- Explain how to add ComicInfo.xml to comics +- List recommended metadata editor tools +- Document device sync for reading direction" + +git add docs/developer/api/media-items/ +git commit -m "docs: update API documentation for all metadata fields + +- Add reading_direction, manga_type query parameters +- Add story_arc, imprint, age_rating filters +- Add series_count, volume, web_url filters +- Update MediaItemSummary object with all 15 new fields +- Document alternate_info JSONB structure +- Document genre/tag processing behavior +- Document enum values for manga_type and reading_direction +- Document age_rating enum values +- Add examples for filtering by metadata +- Note universal fields apply to ebooks, audiobooks, comics" + +git push +``` + +--- + +## Summary of Changes + +### Files Modified + +1. `database/schema/schema.sql` - Add 15 new columns, 8 indexes, column comments +2. `internal/database/models.go` - Regenerated by sqlc with new fields +3. `internal/services/media_scanner.go` - Smart merging logic + genre/tag processing +4. `internal/services/media_scanner_comic_test.go` - Unit tests for all new fields +5. `web/src/types/api.d.ts` - TypeScript types with all metadata +6. `docs/user/manga-reading-direction.md` - User docs (new file) +7. `docs/developer/api/media-items/*.md` - API docs updates + +### Files Created + +1. `cmd/server/tests/comic_metadata_test.go` - Integration tests for all fields +2. `bruno/media-items/search-with-reading-direction.yml` - API test +3. `bruno/media-items/create-comic-with-metadata.yml` - API test +4. `bruno/media-items/search-by-story-arc.yml` - API test (new) +5. `bruno/media-items/search-by-imprint.yml` - API test (new) +6. `bruno/media-items/search-by-age-rating.yml` - API test (new) +7. `bruno/media-items/search-by-series-count.yml` - API test (new) + +### Key Features + +✅ **Complete ComicInfo.xml Support**: Extracts ALL 19 fields from ComicInfo.xml v2.0 +✅ **Universal Metadata Fields**: 5 fields apply to ALL formats (ebooks, audiobooks, comics): + - `age_rating`: Age-based content classification + - `series_count`: Total items in series + - `volume`: Collected edition/omnibus number + - `imprint`: Publisher subdivision + - `web_url`: Info page URL (Goodreads, Audible, ComicVine, etc.) +✅ **Comic-Specific Fields**: 10 fields for comics: + - Reading direction (`manga_type`, `reading_direction`) + - Story arc (`story_arc`) + - Scan info (`scan_information`) + - Alternate series (`alternate_info` JSONB) + - Format (`is_black_and_white`) + - Metadata (`summary`, `metadata_notes`, `community_rating`) +✅ **Smart Metadata Merging**: Calibre `metadata.opf` + `ComicInfo.xml` + folder structure +✅ **Reading Direction Detection**: From `Manga` field + language heuristics + genre tags +✅ **Genre/Tag Processing**: ALL genres appear in tags array without duplication + - Uses existing `genre` column for primary genre (first genre tag) + - Processes EPUB `` tags + - Processes ComicInfo `Genre` + `Tags` + `Characters` + `Teams` + `Locations` + - Deduplication via `containsTag()` helper +✅ **Database Storage**: Raw `manga_type` + computed `reading_direction` + 13 other fields +✅ **Full Stack**: Database → Go → API → TypeScript → Frontend +✅ **Testing**: Unit tests, integration tests, Bruno API tests +✅ **Documentation**: User guide, API reference, troubleshooting + +### All 19 ComicInfo.xml Fields Extracted + +| Field | Database Column | Type | Description | +|-------|----------------|------|-------------| +| Title | title | VARCHAR(255) | Already exists | +| Series | series | VARCHAR(255) | Already exists | +| Number | series_number | INTEGER | Already exists | +| Count | series_count | INTEGER | **NEW**: Total issues in series (UNIVERSAL) | +| Volume | volume | INTEGER | **NEW**: Volume number (UNIVERSAL) | +| AlternateSeries | alternate_info | JSONB | **NEW**: Alternate series info (comic-specific) | +| AlternateNumber | alternate_info | JSONB | **NEW**: Alternate number (comic-specific) | +| AlternateCount | alternate_info | JSONB | **NEW**: Alternate count (comic-specific) | +| Summary | summary | TEXT | **NEW**: Comic summary (can merge with description) | +| Notes | metadata_notes | TEXT | **NEW**: Notes from metadata files (UNIVERSAL, distinct from user notes) | +| Year | copyright_year | INTEGER | Already exists | +| Month | (derived) | - | Stored in date_published | +| Day | (derived) | - | Stored in date_published | +| Writer | author | VARCHAR(255) | Already exists | +| Penciller | contributors | TEXT[] | Merged into existing | +| Inker | contributors | TEXT[] | Merged into existing | +| Colorist | contributors | TEXT[] | Merged into existing | +| Letterer | contributors | TEXT[] | Merged into existing | +| CoverArtist | contributors | TEXT[] | Merged into existing | +| Publisher | publisher | VARCHAR(255) | Already exists | +| Imprint | imprint | VARCHAR(255) | **NEW**: Publisher imprint (UNIVERSAL) | +| Genre | genre | VARCHAR(100) | Already exists + enhanced via processGenresAndTags() | +| Tags | tags | TEXT[] | Already exists + enhanced with Characters/Teams/Locations | +| Web | web_url | VARCHAR(500) | **NEW**: Info page URL (UNIVERSAL) | +| PageCount | page_count | INTEGER | Already exists | +| LanguageISO | language | VARCHAR(10) | Already exists | +| Manga | manga_type | VARCHAR(30) | **NEW**: Raw manga field (comic-specific) | +| **Derived** | reading_direction | VARCHAR(20) | **NEW**: Computed from Manga field (comic-specific) | +| BlackAndWhite | is_black_and_white | BOOLEAN | **NEW**: B/W flag (mostly comic-specific) | +| StoryArc | story_arc | VARCHAR(255) | **NEW**: Story arc name (comic-specific) | +| SeriesGroup | (future) | - | Not stored (can add later) | +| AgeRating | age_rating | VARCHAR(20) | **NEW**: Age rating (UNIVERSAL) | +| CommunityRating | community_rating | DECIMAL(3,1) | **NEW**: 0-10 rating (UNIVERSAL, from metadata files) | +| MainCharacterOrTeam | tags | TEXT[] | Merged into tags via processGenresAndTags() | +| Review | (future) | - | Not stored (user notes exist) | +| ScanInformation | scan_information | TEXT | **NEW**: Scan info (comic-specific) | +| Characters | tags | TEXT[] | Merged into tags via processGenresAndTags() | +| Teams | tags | TEXT[] | Merged into tags via processGenresAndTags() | +| Locations | tags | TEXT[] | Merged into tags via processGenresAndTags() | + +### Universal Fields (Apply to Ebooks, Audiobooks, Comics) + +| Field | Database Column | Formats | Examples | +|-------|----------------|---------|----------| +| age_rating | age_rating | All | Everyone, Teen, Mature, Adult | +| series_count | series_count | All | "7" (Harry Potter), "12" (One Piece) | +| volume | volume | All | "1" (Omnibus), "2" (Collected Edition) | +| imprint | imprint | All | "Vertigo" (comics), "HarperCollinsEpic" (books) | +| web_url | web_url | All | Goodreads, Audible, ComicVine, MangaUpdates URLs | +| metadata_notes | metadata_notes | All | Publisher notes, scan info, EPUB annotations | +| community_rating | community_rating | All | Pre-existing ratings from metadata sources | +| Notes | metadata_notes | TEXT | **NEW**: Metadata notes (not user notes) | +| Year | copyright_year | INTEGER | Already exists | +| Month | (derived) | - | Stored in date_published | +| Day | (derived) | - | Stored in date_published | +| Writer | author | VARCHAR(255) | Already exists | +| Penciller | contributors | TEXT[] | Merged into existing | +| Inker | contributors | TEXT[] | Merged into existing | +| Colorist | contributors | TEXT[] | Merged into existing | +| Letterer | contributors | TEXT[] | Merged into existing | +| CoverArtist | contributors | TEXT[] | Merged into existing | +| Publisher | publisher | VARCHAR(255) | Already exists | +| Imprint | imprint | VARCHAR(255) | **NEW**: Publisher imprint (UNIVERSAL) | +| Genre | genre | VARCHAR(100) | Already exists + enhanced via processGenresAndTags() | +| Tags | tags | TEXT[] | Already exists + enhanced with Characters/Teams/Locations via processGenresAndTags() | +| Web | web_url | VARCHAR(500) | **NEW**: Info page URL (UNIVERSAL) | +| PageCount | page_count | INTEGER | Already exists | +| LanguageISO | language | VARCHAR(10) | Already exists | +| Manga | manga_type | VARCHAR(30) | **NEW**: Raw manga field | +| **Derived** | reading_direction | VARCHAR(20) | **NEW**: Computed from Manga field | +| BlackAndWhite | is_black_and_white | BOOLEAN | **NEW**: B/W flag | +| StoryArc | story_arc | VARCHAR(255) | **NEW**: Story arc name | +| SeriesGroup | (future) | - | Not stored (can add later) | +| AgeRating | age_rating | VARCHAR(20) | **NEW**: Age rating | +| CommunityRating | community_rating | DECIMAL(3,1) | **NEW**: 0-10 rating | +| MainCharacterOrTeam | tags | TEXT[] | Merged into tags | +| Review | (future) | - | Not stored (user notes exist) | +| ScanInformation | scan_information | TEXT | **NEW**: Scan info | +| Characters | tags | TEXT[] | Merged into tags | +| Teams | tags | TEXT[] | Merged into tags | +| Locations | tags | TEXT[] | Merged into tags | + +--- + +## Testing Checklist + +Before considering this feature complete: + +### Schema & Database +- [ ] Database schema updated with 15 new columns (not 16 - removed genre_comic) +- [ ] All 8 indexes created successfully (not 6 - added series_count and volume) +- [ ] Column comments added for all new fields +- [ ] Database models regenerated with sqlc +- [ ] Local database updated (Option 1, 2, or 3) + +### Code Changes +- [ ] ComicInfo struct updated with all 19 fields +- [ ] MediaMetadata struct updated with 15 fields (5 universal + 10 comic-specific) +- [ ] mergeMetadata() function implements smart merging +- [ ] Genre/tag processing functions implemented: + - [ ] processGenresAndTags() for universal genre/tag logic + - [ ] extractGenreTagsFromEPUB() for EPUB subject tags + - [ ] extractGenreTagsFromComicInfo() for comic tags +- [ ] normalizeMangaType() function handles all enum values +- [ ] determineReadingDirection() implements heuristics +- [ ] normalizeAgeRating() function standardizes ratings +- [ ] containsTag() helper prevents duplicate tags +- [ ] processNewMediaItem() updated with all new fields +- [ ] TypeScript types updated with all metadata fields + +### Testing +- [ ] Unit tests pass (`go test ./internal/services/...`) +- [ ] Integration tests pass (`go test ./cmd/server/tests/...`) +- [ ] Bruno API tests pass +- [ ] Test coverage for all 19 ComicInfo.xml fields + +### Manual Testing - Core Functionality +- [ ] Upload CBZ with `ComicInfo.xml` (Manga=YesAndRightToLeft) +- [ ] Verify RTL reading direction in database +- [ ] Upload CBZ with `metadata.opf` + `ComicInfo.xml` +- [ ] Verify smart merging: Calibre fields + ComicInfo.xml fields +- [ ] API responses include all new fields +- [ ] Search by `reading_direction` filter works +- [ ] Search by `story_arc` filter works +- [ ] Search by `imprint` filter works +- [ ] Search by `age_rating` filter works +- [ ] Search by `series_count` (e.g., "show complete series") +- [ ] Characters/Teams/Locations added to tags array + +### Manual Testing - Specific Fields (Universal + Comic) +- [ ] `series_count` displays correctly in UI +- [ ] `volume` displays correctly in UI +- [ ] `imprint` displays and filters correctly (test with comics AND ebooks) +- [ ] `story_arc` groups related comics +- [ ] `age_rating` filters work (test with comics AND ebooks - parental controls) +- [ ] `web_url` links work (test with Goodreads, ComicVine, etc.) +- [ ] `metadata_notes` display (distinct from user notes) +- [ ] `community_rating` displays (distinct from user ratings in media_ratings table) +- [ ] `is_black_and_white` flag works +- [ ] `alternate_info` JSONB stores/retrieves correctly +- [ ] `scan_information` displays +- [ ] `summary` merges with description appropriately + +### Manual Testing - Genre/Tag Processing +- [ ] EPUB subjects: first subject → genre, all subjects → tags +- [ ] ComicInfo Genre: → genre column +- [ ] ComicInfo Tags: → tags array +- [ ] ComicInfo Characters: → tags array +- [ ] ComicInfo Teams: → tags array +- [ ] ComicInfo Locations: → tags array +- [ ] No duplicate genres in tags (deduplication works) +- [ ] Primary genre set correctly (first genre tag wins) + +### Documentation +- [ ] User documentation renders at `/docs` endpoint +- [ ] API documentation updated with all new fields +- [ ] Docs search finds new content +- [ ] Code examples in docs work + +### Build & Verification +- [ ] `go build ./...` succeeds +- [ ] `bash scripts/verify-guidelines.sh` passes (0 errors) +- [ ] No critical functionality broken +- [ ] Git diff shows only intended changes + +--- + +**Document Version:** 3.0 +**Created:** March 29, 2026 +**Updated:** March 29, 2026 +**Changes:** +- v2.0: Added all 19 ComicInfo.xml fields (from 2 to 16 columns) +- v3.0: Made 5 fields universal (age_rating, series_count, volume, imprint, web_url) +- v3.0: Removed genre_comic column (use existing genre + processGenresAndTags) +- v3.0: Added genre/tag processing logic for ALL formats +- v3.0: Total: 15 new columns (5 universal + 10 comic-specific) +**Status:** Ready for Implementation