package services import ( "bookhoard/internal/database" "context" "encoding/json" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) type ReaderService struct { db *database.Queries worker *Worker } func NewReaderService(db *database.Queries, worker *Worker) *ReaderService { return &ReaderService{ db: db, worker: worker, } } // Chapter represents a detected chapter type Chapter struct { ID string `json:"id"` Title string `json:"title"` StartPage int `json:"start_page"` PageCount int `json:"page_count"` Level int `json:"level"` ParentID *string `json:"parent_id,omitempty"` } // Panel represents a detected comic panel type Panel struct { ID string `json:"id"` X int `json:"x"` Y int `json:"y"` Width int `json:"width"` Height int `json:"height"` ReadingOrder int `json:"reading_order"` } // DictionaryEntry represents a word definition type DictionaryEntry struct { Word string `json:"word"` Definition string `json:"definition"` PartOfSpeech string `json:"part_of_speech,omitempty"` Example string `json:"example,omitempty"` Etymology string `json:"etymology,omitempty"` } // ChapterDetectionResult contains chapter metadata type ChapterDetectionResult struct { Chapters []Chapter `json:"chapters"` Metadata json.RawMessage `json:"metadata,omitempty"` DetectedAt time.Time `json:"detected_at"` } // DetectChapters analyzes a media item to detect chapter structure func (s *ReaderService) DetectChapters(ctx context.Context, mediaItemID uuid.UUID) ([]Chapter, error) { // Get media item to determine type item, err := s.db.GetMediaItem(ctx, pgtype.UUID{Bytes: mediaItemID, Valid: true}) if err != nil { return nil, fmt.Errorf("failed to get media item: %w", err) } // Check if chapter metadata already exists if len(item.ChapterMetadata) > 0 { var existing ChapterDetectionResult if err := json.Unmarshal(item.ChapterMetadata, &existing); err == nil { return existing.Chapters, nil } } // Detect chapters based on format var chapters []Chapter switch item.FormatGroup { case "reflowable": // For ebooks, parse from TOC if available chapters, err = s.detectEbookChapters(ctx, &item) case "fixed-layout": // For comics/manga, detect page breaks as chapters chapters, err = s.detectComicChapters(ctx, &item) case "pdf": // For PDFs, use PDF outline chapters, err = s.detectPDFChapters(ctx, &item) default: chapters = []Chapter{} } if err != nil { return nil, fmt.Errorf("chapter detection failed: %w", err) } // Cache the results result := ChapterDetectionResult{ Chapters: chapters, Metadata: nil, DetectedAt: time.Now(), } metadataBytes, err := json.Marshal(result) if err == nil { // Update media item with chapter metadata // This would require a new query in database/queries.sql _ = metadataBytes } return chapters, nil } func (s *ReaderService) detectEbookChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) { // For EPUB files, parse the TOC from the OPF file // This requires EPUB parsing (see epub-parser.ts) // For now, return empty structure return []Chapter{}, nil } func (s *ReaderService) detectComicChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) { // For comics, treat each page as a potential chapter // or group pages by story arcs if metadata exists pageCount := int(item.PageCount.Int32) if pageCount <= 0 { return []Chapter{}, nil } chapters := make([]Chapter, 0) chapterSize := 20 // Group pages into chapters of 20 pages each for i := 0; i < pageCount; i += chapterSize { endPage := i + chapterSize if endPage > pageCount { endPage = pageCount } chapters = append(chapters, Chapter{ ID: fmt.Sprintf("chapter-%d", len(chapters)+1), Title: fmt.Sprintf("Page %d-%d", i+1, endPage), StartPage: i + 1, PageCount: endPage - i, Level: 1, }) } return chapters, nil } func (s *ReaderService) detectPDFChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) { // For PDFs, use pdfcpu to extract outline/bookmarks // This requires PDF parsing library return []Chapter{}, nil } // DetectPanels analyzes a comic page to detect panel boundaries func (s *ReaderService) DetectPanels( ctx context.Context, mediaItemID uuid.UUID, pageNumber int, method string, ) ([]Panel, error) { // Check if panels already exist in cache cached, err := s.db.GetPanelData(ctx, database.GetPanelDataParams{ MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, PageNumber: int32(pageNumber), }) if err == nil { var panels []Panel if err := json.Unmarshal(cached.Panels, &panels); err == nil { return panels, nil } } // Detect panels using specified method var panels []Panel switch method { case "grid": panels, err = s.detectPanelsGrid(ctx, mediaItemID, pageNumber) case "ml": panels, err = s.detectPanelsML(ctx, mediaItemID, pageNumber) case "manual": panels, err = s.detectPanelsManual(ctx, mediaItemID, pageNumber) default: // Default to grid detection panels, err = s.detectPanelsGrid(ctx, mediaItemID, pageNumber) } if err != nil { return nil, fmt.Errorf("panel detection failed: %w", err) } // Cache the results panelsJSON, _ := json.Marshal(panels) // Insert into panel_data table _ = panelsJSON return panels, nil } func (s *ReaderService) detectPanelsGrid( ctx context.Context, mediaItemID uuid.UUID, pageNumber int, ) ([]Panel, error) { // Simple grid-based panel detection // Divide page into 2x2 or 3x3 grid // This is a simplified implementation panels := []Panel{ { ID: "panel-1", X: 0, Y: 0, Width: 50, Height: 100, ReadingOrder: 1, }, { ID: "panel-2", X: 50, Y: 0, Width: 50, Height: 100, ReadingOrder: 2, }, } return panels, nil } func (s *ReaderService) detectPanelsML( ctx context.Context, mediaItemID uuid.UUID, pageNumber int, ) ([]Panel, error) { // ML-based panel detection // This would require a trained model // For now, fall back to grid detection return s.detectPanelsGrid(ctx, mediaItemID, pageNumber) } func (s *ReaderService) detectPanelsManual( ctx context.Context, mediaItemID uuid.UUID, pageNumber int, ) ([]Panel, error) { // Manual panel detection returns existing manually-set panels // These would be stored in the panel_data table return []Panel{}, nil } // CalculateReadingSpeed updates reading speed statistics func (s *ReaderService) CalculateReadingSpeed( ctx context.Context, userID uuid.UUID, mediaItemID uuid.UUID, pagesRead int, minutes float32, ) error { if minutes <= 0 { return fmt.Errorf("invalid time: must be positive") } pagesPerMinute := float32(pagesRead) / minutes // Get or create reading speed record _, err := s.db.GetReadingSpeed(ctx, database.GetReadingSpeedParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, }) if err != nil { // Create new record _, err = s.db.CreateReadingSpeed(ctx, database.CreateReadingSpeedParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, PagesPerMinute: pgtype.Float4{Float32: pagesPerMinute, Valid: true}, PagesRead: pgtype.Int4{Int32: int32(pagesRead), Valid: true}, TotalReadingMinutes: pgtype.Float4{Float32: minutes, Valid: true}, LastReadAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, }) } else { // Update existing record with moving average // This would require an UpdateReadingSpeed query _ = pagesPerMinute } return err } // LookupWord retrieves dictionary entry for a word func (s *ReaderService) LookupWord(ctx context.Context, word string) (*DictionaryEntry, error) { // Check cache first cached, err := s.db.GetDictionaryEntry(ctx, word) if err == nil { return &DictionaryEntry{ Word: cached.Word, Definition: cached.Definition, PartOfSpeech: cached.PartOfSpeech.String, Example: cached.Example.String, Etymology: cached.Etymology.String, }, nil } // Not in cache, fetch from dictionary API entry, err := s.fetchDictionaryEntry(ctx, word) if err != nil { return nil, err } // Cache the entry _, _ = s.db.CreateDictionaryEntry(ctx, database.CreateDictionaryEntryParams{ Word: entry.Word, Definition: entry.Definition, PartOfSpeech: pgtype.Text{String: entry.PartOfSpeech, Valid: entry.PartOfSpeech != ""}, Example: pgtype.Text{String: entry.Example, Valid: entry.Example != ""}, Etymology: pgtype.Text{String: entry.Etymology, Valid: entry.Etymology != ""}, }) return entry, nil } func (s *ReaderService) fetchDictionaryEntry(ctx context.Context, word string) (*DictionaryEntry, error) { // Fetch from external dictionary API // For now, return a placeholder return &DictionaryEntry{ Word: word, Definition: fmt.Sprintf("Definition for %s", word), }, nil } // GetSettings retrieves reader settings for a user func (s *ReaderService) GetSettings( ctx context.Context, userID uuid.UUID, ) (map[string]interface{}, error) { // Get settings from database settings, err := s.db.GetReaderSettings(ctx, pgtype.UUID{Bytes: userID, Valid: true}) if err != nil { // Return default settings return s.getDefaultSettings(), nil } var result map[string]interface{} if err := json.Unmarshal(settings, &result); err != nil { return s.getDefaultSettings(), nil } return result, nil } // UpdateSettings updates reader settings for a user func (s *ReaderService) UpdateSettings( ctx context.Context, userID uuid.UUID, settings map[string]interface{}, ) error { // Merge with existing settings existing, err := s.GetSettings(ctx, userID) if err != nil { existing = s.getDefaultSettings() } // Merge settings (partial update) for key, value := range settings { existing[key] = value } // Serialize and save settingsJSON, err := json.Marshal(existing) if err != nil { return fmt.Errorf("failed to serialize settings: %w", err) } // Update in database _, err = s.db.UpsertReaderSettings(ctx, database.UpsertReaderSettingsParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, SettingValue: settingsJSON, }) return err } func (s *ReaderService) getDefaultSettings() map[string]interface{} { return map[string]interface{}{ "chrome_behavior": "auto-hide", "progress_mode": "pages", "chrome_theme": "tokyo-night", "reading_theme": "dark", "reading_font": "literata", "font_size": 16, "line_height": 1.6, "margin_width": 20, "tap_zone_size": 30, "auto_scroll": false, "panel_zoom_enabled": true, // Dockable panel defaults "panel_layout": map[string]interface{}{ "toc": map[string]interface{}{"side": "left", "visible": true, "collapsed": false, "width_px": 320, "order": 1, "locked": false, "last_valid_side": "left"}, "settings": map[string]interface{}{"side": "left", "visible": false, "collapsed": true, "width_px": 380, "order": 2, "locked": false, "last_valid_side": "left"}, "navigator": map[string]interface{}{"side": "right", "visible": true, "collapsed": false, "width_px": 200, "order": 1, "locked": false, "last_valid_side": "right"}, "bookmarks": map[string]interface{}{"side": "right", "visible": false, "collapsed": true, "width_px": 280, "order": 2, "locked": false, "last_valid_side": "right"}, "mobile_nav_visible": false, }, } }