package handlers import ( "bookhoard/internal/database" "bookhoard/internal/services" wsync "bookhoard/internal/sync" "bookhoard/internal/utils" "context" "encoding/json" "errors" "fmt" "io" "log" "mime" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) // CreateMediaItemRequest represents the request for creating a media item type CreateMediaItemRequest struct { LibraryID uuid.UUID `json:"library_id" validate:"required"` Title string `json:"title" validate:"required,min=1,max=500"` Author string `json:"author"` ISBN string `json:"isbn"` Description string `json:"description"` FilePath string `json:"file_path" validate:"required"` FileSize int64 `json:"file_size" validate:"required,min=1"` MimeType string `json:"mime_type" validate:"required"` CoverImagePath string `json:"cover_image_path"` Series string `json:"series"` SeriesNumber int32 `json:"series_number"` Tags []string `json:"tags"` ASIN string `json:"asin"` DatePublished string `json:"date_published"` Publisher string `json:"publisher"` Contributors []string `json:"contributors"` // NEW: Reading direction and comic metadata fields MangaType string `json:"manga_type"` // 'unknown' | 'no' | 'yes' | 'yes_and_right_to_left' ReadingDirection string `json:"reading_direction"` // 'auto' | 'ltr' | 'rtl' | 'vertical' SeriesCount int32 `json:"series_count"` Volume int32 `json:"volume"` Imprint string `json:"imprint"` AgeRating string `json:"age_rating"` // 'Everyone' | 'Teen' | 'Mature' | 'Adult' WebURL string `json:"web_url"` MetadataNotes string `json:"metadata_notes"` CommunityRating float64 `json:"community_rating"` StoryArc string `json:"story_arc"` IsBlackAndWhite bool `json:"is_black_and_white"` AlternateInfo string `json:"alternate_info"` // JSON string ScanInformation string `json:"scan_information"` Summary string `json:"summary"` } // UpdateMediaItemRequest represents the request for updating a media item type UpdateMediaItemRequest struct { Title string `json:"title" validate:"required,min=1,max=500"` Author string `json:"author"` ISBN string `json:"isbn"` Description string `json:"description"` CoverImagePath string `json:"cover_image_path"` Series string `json:"series"` SeriesNumber int32 `json:"series_number"` Tags []string `json:"tags"` ASIN string `json:"asin"` DatePublished string `json:"date_published"` Publisher string `json:"publisher"` Contributors []string `json:"contributors"` // NEW: Reading direction and comic metadata fields MangaType string `json:"manga_type"` // 'unknown' | 'no' | 'yes' | 'yes_and_right_to_left' ReadingDirection string `json:"reading_direction"` // 'auto' | 'ltr' | 'rtl' | 'vertical' SeriesCount int32 `json:"series_count"` Volume int32 `json:"volume"` Imprint string `json:"imprint"` AgeRating string `json:"age_rating"` // 'Everyone' | 'Teen' | 'Mature' | 'Adult' WebURL string `json:"web_url"` MetadataNotes string `json:"metadata_notes"` CommunityRating float64 `json:"community_rating"` StoryArc string `json:"story_arc"` IsBlackAndWhite bool `json:"is_black_and_white"` AlternateInfo string `json:"alternate_info"` // JSON string ScanInformation string `json:"scan_information"` Summary string `json:"summary"` } // CreateMediaNoteRequest represents the request for creating a media note type CreateMediaNoteRequest struct { Content string `json:"content" validate:"required,min=1,max=10000"` Position string `json:"position"` } // UpdateMediaNoteRequest represents the request for updating a media note type UpdateMediaNoteRequest struct { Content string `json:"content" validate:"required,min=1,max=10000"` Position string `json:"position"` } // CreateMediaHighlightRequest represents the request for creating a media highlight type CreateMediaHighlightRequest struct { SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"` StartPosition string `json:"start_position" validate:"required,max=100"` EndPosition string `json:"end_position" validate:"required,max=100"` Color string `json:"color" validate:"omitempty,len=7"` NoteID string `json:"note_id"` } // UpdateMediaHighlightRequest represents the request for updating a media highlight type UpdateMediaHighlightRequest struct { SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"` StartPosition string `json:"start_position" validate:"required,max=100"` EndPosition string `json:"end_position" validate:"required,max=100"` Color string `json:"color" validate:"omitempty,len=7"` NoteID string `json:"note_id"` } type MediaHandler struct { db *database.Queries worker *services.Worker libraryService *services.LibraryService searchService *services.SearchService progressSvc *wsync.ProgressService } func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler { mh := &MediaHandler{ db: db, libraryService: libraryService, searchService: services.NewSearchService(db), } if len(worker) > 0 && worker[0] != nil { mh.worker = worker[0] } return mh } func (mh *MediaHandler) SetProgressService(svc *wsync.ProgressService) { mh.progressSvc = svc } func (h *MediaHandler) DownloadBook(c *echo.Context) error { bookUUID, err := uuid.Parse(c.Param("uuid")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book UUID"}) } pgBookUUID := pgtype.UUID{Bytes: bookUUID, Valid: true} mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgBookUUID) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"}) } // Resolve relative path to absolute filesystem path fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"}) } if _, err := os.Stat(fullPath); os.IsNotExist(err) { return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"}) } file, err := os.Open(fullPath) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open book file"}) } defer file.Close() mimeType := mediaItem.MimeType.String if !mediaItem.MimeType.Valid || mimeType == "" { mimeType = mime.TypeByExtension(filepath.Ext(mediaItem.FilePath)) } c.Response().Header().Set("Content-Type", mimeType) c.Response().Header().Set("Content-Disposition", "attachment; filename=\""+filepath.Base(mediaItem.FilePath)+"\"") if mediaItem.FileSize.Valid && mediaItem.FileSize.Int64 > 0 { c.Response().Header().Set("Content-Length", strconv.FormatInt(mediaItem.FileSize.Int64, 10)) } _, err = io.Copy(c.Response(), file) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to stream file"}) } return nil } // ExecuteSearch performs search and returns results with count // Public wrapper for shared search logic used by both JSON and HTML endpoints func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) { return h.searchService.ExecuteSearch(ctx, params) } type AddToShelfRequest struct { MediaItemIDs []string `json:"media_item_ids" validate:"required"` ShelfName string `json:"shelf_name"` ShelfPosition int `json:"shelf_position"` } func (h *MediaHandler) AddToShelf(c *echo.Context) error { deviceUUID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) } var req AddToShelfRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"}) } if req.ShelfName == "" { req.ShelfName = "Default" } pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true} addedCount := 0 for _, mediaID := range req.MediaItemIDs { mediaUUID, err := uuid.Parse(mediaID) if err != nil { continue } _, err = h.db.AddBookToKoboShelf(c.Request().Context(), database.AddBookToKoboShelfParams{ DeviceID: pgDeviceUUID, MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, ShelfName: pgtype.Text{String: req.ShelfName, Valid: true}, ShelfPosition: pgtype.Int4{Int32: int32(req.ShelfPosition), Valid: true}, }) if err == nil { addedCount++ req.ShelfPosition++ } } return c.JSON(http.StatusOK, map[string]interface{}{ "status": "success", "added_count": addedCount, "shelf_name": req.ShelfName, "total_count": len(req.MediaItemIDs), }) } func (h *MediaHandler) GetShelf(c *echo.Context) error { deviceUUID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) } shelfName := c.QueryParam("shelf") pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true} var shelfBooks []database.GetKoboShelfBooksByShelfNameRow if shelfName != "" { shelfBooks, err = h.db.GetKoboShelfBooksByShelfName(c.Request().Context(), database.GetKoboShelfBooksByShelfNameParams{ DeviceID: pgDeviceUUID, ShelfName: pgtype.Text{String: shelfName, Valid: true}, }) } else { var defaultBooks []database.GetKoboShelfBooksRow defaultBooks, err = h.db.GetKoboShelfBooks(c.Request().Context(), pgDeviceUUID) shelfBooks = make([]database.GetKoboShelfBooksByShelfNameRow, len(defaultBooks)) for i, b := range defaultBooks { shelfBooks[i] = database.GetKoboShelfBooksByShelfNameRow{ ID: b.ID, DeviceID: b.DeviceID, MediaItemID: b.MediaItemID, ShelfName: b.ShelfName, ShelfPosition: b.ShelfPosition, AddedAt: b.AddedAt, LastSyncedAt: b.LastSyncedAt, Title: b.Title, Author: b.Author, MimeType: b.MimeType, EntitlementID: b.EntitlementID, KoboContentID: b.KoboContentID, RevisionNumber: b.RevisionNumber, } } } if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to fetch shelf"}) } type ShelfBookResponse struct { ID string `json:"id"` MediaItemID string `json:"media_item_id"` Title string `json:"title"` Author string `json:"author"` FilePath string `json:"file_path"` MimeType string `json:"mime_type"` ShelfName string `json:"shelf_name"` ShelfPosition int `json:"shelf_position"` EntitlementID string `json:"entitlement_id,omitempty"` KoboContentID string `json:"kobo_content_id,omitempty"` RevisionNumber int `json:"revision_number"` AddedAt string `json:"added_at"` } response := []ShelfBookResponse{} for _, book := range shelfBooks { response = append(response, ShelfBookResponse{ ID: uuid.UUID(book.ID.Bytes).String(), MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(), Title: book.Title, Author: book.Author.String, MimeType: book.MimeType.String, ShelfName: book.ShelfName.String, ShelfPosition: int(book.ShelfPosition.Int32), EntitlementID: book.EntitlementID.String, KoboContentID: book.KoboContentID.String, RevisionNumber: int(book.RevisionNumber.Int32), AddedAt: book.AddedAt.Time.Format("2006-01-02T15:04:05Z07:00"), }) } return c.JSON(http.StatusOK, map[string]interface{}{ "status": "success", "device_id": deviceUUID.String(), "shelf_name": shelfName, "books": response, "total": len(response), }) } func (h *MediaHandler) RemoveFromShelf(c *echo.Context) error { deviceUUID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) } mediaUUID, err := uuid.Parse(c.QueryParam("media_item_id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item ID"}) } pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true} pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, Valid: true} err = h.db.RemoveBookFromKoboShelf(c.Request().Context(), database.RemoveBookFromKoboShelfParams{ DeviceID: pgDeviceUUID, MediaItemID: pgMediaUUID, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to remove from shelf"}) } return c.JSON(http.StatusOK, map[string]interface{}{ "status": "success", "message": "book removed from shelf", }) } func (h *MediaHandler) ClearShelf(c *echo.Context) error { deviceUUID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) } shelfName := c.QueryParam("shelf") pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true} var result string if shelfName != "" { err = h.db.ClearKoboShelfByName(c.Request().Context(), database.ClearKoboShelfByNameParams{ DeviceID: pgDeviceUUID, ShelfName: pgtype.Text{String: shelfName, Valid: true}, }) result = "shelf cleared" } else { err = h.db.ClearKoboShelf(c.Request().Context(), pgDeviceUUID) result = "all shelves cleared" } if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to clear shelf"}) } return c.JSON(http.StatusOK, map[string]interface{}{ "status": "success", "message": result, }) } // Bulk Operations // POST /api/media-items/bulk-delete // Bulk delete media items func (h *MediaHandler) HandleBulkDelete(c *echo.Context) error { var req struct { MediaItemIDs []string `json:"media_item_ids" validate:"required"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) } if len(req.MediaItemIDs) == 0 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "media_item_ids required"}) } results := make([]map[string]interface{}, 0) successCount := 0 failedCount := 0 for _, mediaIDStr := range req.MediaItemIDs { mediaID, err := uuid.Parse(mediaIDStr) if err != nil { results = append(results, map[string]interface{}{ "media_item_id": mediaIDStr, "status": "error", "error": "Invalid UUID", }) failedCount++ continue } mediaUUID := pgtype.UUID{Bytes: mediaID, Valid: true} media, err := h.db.GetMediaItem(c.Request().Context(), mediaUUID) if err != nil { results = append(results, map[string]interface{}{ "media_item_id": mediaIDStr, "status": "error", "error": "Media item not found", }) failedCount++ continue } err = h.db.DeleteMediaItem(c.Request().Context(), mediaUUID) if err != nil { results = append(results, map[string]interface{}{ "media_item_id": mediaIDStr, "status": "error", "error": err.Error(), }) failedCount++ continue } if media.FilePath != "" { if err := os.Remove(media.FilePath); err != nil { // Log file removal failure but don't fail the request // DB record is already deleted, which is the primary concern log.Printf("Warning: failed to remove file %s: %v", media.FilePath, err) } } results = append(results, map[string]interface{}{ "media_item_id": mediaIDStr, "status": "success", }) successCount++ } return c.JSON(http.StatusOK, map[string]interface{}{ "results": results, "total": len(req.MediaItemIDs), "deleted": successCount, "failed": failedCount, }) } // POST /api/media-items/bulk-update // Bulk update media item metadata func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error { var req struct { MediaItemUpdates []struct { MediaItemID string `json:"media_item_id" validate:"required"` Updates struct { Title *string `json:"title,omitempty"` Author *string `json:"author,omitempty"` Genre *string `json:"genre,omitempty"` Language *string `json:"language,omitempty"` Tags []string `json:"tags,omitempty"` } `json:"updates"` } `json:"media_item_updates" validate:"required"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) } if len(req.MediaItemUpdates) == 0 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "media_item_updates required"}) } for _, update := range req.MediaItemUpdates { if update.MediaItemID == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "media_item_id cannot be empty"}) } } results := make([]map[string]interface{}, 0) successCount := 0 failedCount := 0 for _, update := range req.MediaItemUpdates { mediaID, err := uuid.Parse(update.MediaItemID) if err != nil { results = append(results, map[string]interface{}{ "media_item_id": update.MediaItemID, "status": "error", "error": "Invalid UUID", }) failedCount++ continue } mediaUUID := pgtype.UUID{Bytes: mediaID, Valid: true} existingMedia, err := h.db.GetMediaItem(c.Request().Context(), mediaUUID) if err != nil { results = append(results, map[string]interface{}{ "media_item_id": update.MediaItemID, "status": "error", "error": "Media item not found", }) failedCount++ continue } updateParams := database.UpdateMediaItemParams{ ID: mediaUUID, Title: existingMedia.Title, Author: existingMedia.Author, Genre: existingMedia.Genre, Language: existingMedia.Language, Tags: existingMedia.Tags, TagsSearch: existingMedia.TagsSearch, Description: existingMedia.Description, Publisher: existingMedia.Publisher, CopyrightYear: existingMedia.CopyrightYear, Isbn: existingMedia.Isbn, Series: existingMedia.Series, SeriesNumber: existingMedia.SeriesNumber, Asin: existingMedia.Asin, DatePublished: existingMedia.DatePublished, Contributors: existingMedia.Contributors, ContributorsSearch: existingMedia.ContributorsSearch, Edition: existingMedia.Edition, PageCount: existingMedia.PageCount, GoodreadsID: existingMedia.GoodreadsID, OpenlibraryID: existingMedia.OpenlibraryID, CoverImagePath: existingMedia.CoverImagePath, } if update.Updates.Title != nil { updateParams.Title = *update.Updates.Title } if update.Updates.Author != nil { updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true} } if update.Updates.Genre != nil { updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true} } if update.Updates.Language != nil { updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true} } if len(update.Updates.Tags) > 0 { normalizedTags := utils.NormalizeTags(update.Updates.Tags) updateParams.Tags = normalizedTags tagsSearch := utils.NormalizeTagsSearch(update.Updates.Tags) updateParams.TagsSearch = tagsSearch } _, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams) if err != nil { results = append(results, map[string]interface{}{ "media_item_id": update.MediaItemID, "status": "error", "error": err.Error(), }) failedCount++ continue } results = append(results, map[string]interface{}{ "media_item_id": update.MediaItemID, "status": "success", }) successCount++ } return c.JSON(http.StatusOK, map[string]interface{}{ "results": results, "total": len(req.MediaItemUpdates), "updated": successCount, "failed": failedCount, }) } // ListMediaItems handles GET /api/media-items func (mh *MediaHandler) ListMediaItems(c *echo.Context) error { libraryID := c.QueryParam("library_id") sort := c.QueryParam("sort") limit, _ := strconv.Atoi(c.QueryParam("limit")) offset, _ := strconv.Atoi(c.QueryParam("offset")) if limit == 0 { limit = 50 } if sort == "" { sort = "created_at DESC" } allowedSorts := map[string]bool{ "created_at ASC": true, "created_at DESC": true, "title ASC": true, "title DESC": true, "author ASC": true, "author DESC": true, "series ASC": true, "series DESC": true, "date_published ASC": true, "date_published DESC": true, "copyright_year ASC": true, "copyright_year DESC": true, "page_count ASC": true, "page_count DESC": true, "genre ASC": true, "genre DESC": true, } if !allowedSorts[sort] { sort = "created_at DESC" } if limit > maxPaginationLimit { limit = maxPaginationLimit } if offset < 0 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "offset cannot be negative"}) } if libraryID != "" { libUUID, err := uuid.Parse(libraryID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"}) } items, err := mh.db.ListMediaItemsSorted(c.Request().Context(), database.ListMediaItemsSortedParams{ LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, Sort: pgtype.Text{String: sort, Valid: true}, Limit: pgtype.Int4{Int32: int32(limit), Valid: true}, Offset: pgtype.Int4{Int32: int32(offset), Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]interface{}{"data": items}) } items, err := mh.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{ Limit: int32(limit), Offset: int32(offset), }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } resolvedItems := make([]map[string]interface{}, len(items)) for i, item := range items { resolvedItems[i] = map[string]interface{}{ "id": uuid.UUID(item.ID.Bytes).String(), "library_id": uuid.UUID(item.LibraryID.Bytes).String(), "title": item.Title, "author": textToString(item.Author), "isbn": textToString(item.Isbn), "description": textToString(item.Description), "file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}), "file_size": item.FileSize, "mime_type": textToString(item.MimeType), "cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), "series": textToString(item.Series), "series_number": item.SeriesNumber, "tags": item.Tags, "asin": textToString(item.Asin), "date_published": item.DatePublished.Time.Format("2006-01-02"), "publisher": textToString(item.Publisher), "contributors": item.Contributors, "language": textToString(item.Language), "edition": textToString(item.Edition), "page_count": item.PageCount, "genre": textToString(item.Genre), "created_at": item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), "updated_at": item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), "format_group": item.FormatGroup, // already string // NEW: Reading direction fields "manga_type": textToString(item.MangaType), "reading_direction": textToString(item.ReadingDirection), // NEW: Universal metadata fields "series_count": item.SeriesCount, "volume": item.Volume, "imprint": textToString(item.Imprint), "age_rating": textToString(item.AgeRating), "web_url": textToString(item.WebUrl), "metadata_notes": textToString(item.MetadataNotes), "community_rating": numericToFloat(item.CommunityRating), // NEW: Comic-specific fields "story_arc": textToString(item.StoryArc), "is_black_and_white": item.IsBlackAndWhite, "alternate_info": jsonBytesToMap(item.AlternateInfo), "scan_information": textToString(item.ScanInformation), "summary": textToString(item.Summary), } } return c.JSON(http.StatusOK, map[string]interface{}{"data": resolvedItems}) } // GetMediaItem handles GET /api/media-items/:id func (mh *MediaHandler) GetMediaItem(c *echo.Context) error { mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]interface{}{ "id": uuid.UUID(item.ID.Bytes).String(), "library_id": uuid.UUID(item.LibraryID.Bytes).String(), "title": item.Title, "author": textToString(item.Author), "isbn": textToString(item.Isbn), "description": textToString(item.Description), "file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}), "file_size": item.FileSize, "mime_type": textToString(item.MimeType), "cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), "series": textToString(item.Series), "series_number": item.SeriesNumber, "tags": item.Tags, "asin": textToString(item.Asin), "date_published": item.DatePublished.Time.Format("2006-01-02"), "publisher": textToString(item.Publisher), "contributors": item.Contributors, "language": textToString(item.Language), "edition": textToString(item.Edition), "page_count": item.PageCount, "genre": textToString(item.Genre), "created_at": item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), "updated_at": item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), "format_group": item.FormatGroup, "format_mimetype": textToString(item.FormatMimetype), // NEW: Reading direction fields "manga_type": textToString(item.MangaType), "reading_direction": textToString(item.ReadingDirection), // NEW: Universal metadata fields "series_count": item.SeriesCount, "volume": item.Volume, "imprint": textToString(item.Imprint), "age_rating": textToString(item.AgeRating), "web_url": textToString(item.WebUrl), "metadata_notes": textToString(item.MetadataNotes), "community_rating": numericToFloat(item.CommunityRating), // NEW: Comic-specific fields "story_arc": textToString(item.StoryArc), "is_black_and_white": item.IsBlackAndWhite, "alternate_info": jsonBytesToMap(item.AlternateInfo), "scan_information": textToString(item.ScanInformation), "summary": textToString(item.Summary), "library_type_name": textPtrToString(item.LibraryTypeName), }) } // CreateMediaRating handles POST /api/media-items/:id/rating func (mh *MediaHandler) CreateMediaRating(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } var req struct { Rating int32 `json:"rating" validate:"required,min=1,max=10"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } rating, err := mh.db.CreateMediaRating(c.Request().Context(), database.CreateMediaRatingParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, Rating: req.Rating, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusCreated, rating) } // GetMediaRating handles GET /api/media-items/:id/rating func (mh *MediaHandler) GetMediaRating(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } rating, err := mh.db.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusOK, map[string]interface{}{"rating": nil}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, rating) } // UpdateMediaRating handles PUT /api/media-items/:id/rating func (mh *MediaHandler) UpdateMediaRating(c *echo.Context) error { return mh.CreateMediaRating(c) } // DeleteMediaRating handles DELETE /api/media-items/:id/rating func (mh *MediaHandler) DeleteMediaRating(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } err = mh.db.DeleteMediaRating(c.Request().Context(), database.DeleteMediaRatingParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]string{"message": "rating deleted"}) } // GetMediaReadingProgress handles GET /api/media-items/:id/progress func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } progress, err := mh.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusOK, map[string]interface{}{ "current_page": 0, "total_pages": nil, }) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } resp := map[string]interface{}{ "id": progress.ID, "media_item_id": progress.MediaItemID, "user_id": progress.UserID, "current_page": progress.CurrentPage, "total_pages": progress.TotalPages, "last_read_at": progress.LastReadAt, "percentage": progress.Percentage, "character_offset": progress.CharacterOffset, "epubcfi": progress.Epubcfi, "chapter": progress.Chapter, "chapter_progress": progress.ChapterProgress, "format_group": progress.FormatGroup, "total_characters": progress.TotalCharacters, "chapter_count": progress.ChapterCount, "last_sync_device": progress.LastSyncDevice, "last_sync_source": progress.LastSyncSource, "last_sync_timestamp": progress.LastSyncTimestamp, } return c.JSON(http.StatusOK, resp) } // UpdateMediaReadingProgress handles PUT /api/media-items/:id/progress func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } var req struct { CurrentPage *int32 `json:"current_page"` TotalPages *int32 `json:"total_pages"` Epubcfi *string `json:"epubcfi"` Percentage *float64 `json:"percentage"` Chapter *int `json:"chapter"` ChapterProgress *float64 `json:"chapter_progress"` CharacterOffset *int64 `json:"character_offset"` ReadingMode *string `json:"reading_mode"` ZoomLevel *float64 `json:"zoom_level"` ScrollX *float64 `json:"scroll_position_x"` ScrollY *float64 `json:"scroll_position_y"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if mh.progressSvc != nil { saveReq := wsync.SaveProgressRequest{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, Source: "web", DeviceID: pgtype.UUID{Bytes: userUUID, Valid: true}, Percentage: req.Percentage, Epubcfi: req.Epubcfi, CharacterOffset: req.CharacterOffset, Chapter: req.Chapter, ChapterProgress: req.ChapterProgress, CurrentPage: nil, TotalPages: nil, ZoomLevel: req.ZoomLevel, ScrollX: req.ScrollX, ScrollY: req.ScrollY, ReadingMode: req.ReadingMode, DeviceType: "web", DeviceName: "Web", Broadcast: true, } if req.CurrentPage != nil { cp := int(*req.CurrentPage) saveReq.CurrentPage = &cp } if req.TotalPages != nil { tp := int(*req.TotalPages) saveReq.TotalPages = &tp } result, err := mh.progressSvc.SaveProgress(c.Request().Context(), saveReq) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, result) } percentage := 0.0 if req.Percentage != nil { percentage = *req.Percentage } epubcfi := "" if req.Epubcfi != nil { epubcfi = *req.Epubcfi } currentPage := int32(0) if req.CurrentPage != nil { currentPage = *req.CurrentPage } totalPages := int32(0) if req.TotalPages != nil { totalPages = *req.TotalPages } progress, err := mh.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, Percentage: pgtype.Float8{Float64: percentage, Valid: true}, CharacterOffset: pgtype.Int8{Valid: false}, Epubcfi: pgtype.Text{String: epubcfi, Valid: epubcfi != ""}, Chapter: pgtype.Int4{Valid: false}, ChapterProgress: pgtype.Float8{Valid: false}, ViewportX: pgtype.Float8{Valid: false}, ViewportY: pgtype.Float8{Valid: false}, ZoomLevel: pgtype.Float8{Valid: false}, ScrollPositionX: pgtype.Float8{Valid: false}, ScrollPositionY: pgtype.Float8{Valid: false}, PanelNumber: pgtype.Int4{Valid: false}, ReadingMode: pgtype.Text{Valid: false}, LastSyncDevice: pgtype.Text{String: "web", Valid: true}, LastSyncSource: pgtype.Text{String: "web", Valid: true}, CurrentPage: pgtype.Int4{Int32: currentPage, Valid: true}, TotalPages: pgtype.Int4{Int32: totalPages, Valid: totalPages > 0}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, progress) } // DeleteMediaReadingProgress handles DELETE /api/media-items/:id/progress func (mh *MediaHandler) DeleteMediaReadingProgress(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } err = mh.db.DeleteReadingProgress(c.Request().Context(), database.DeleteReadingProgressParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]string{"message": "reading progress deleted"}) } // CreateMediaItem handles POST /api/media-items (admin only) func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error { user := MustGetAuthenticatedUser(c) if user.Role != "admin" { return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) } var req CreateMediaItemRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } if len(req.Tags) > 0 { req.Tags = utils.NormalizeTags(req.Tags) } if len(req.Contributors) > 0 { req.Contributors = utils.NormalizeContributors(req.Contributors) } tagsSearch := utils.NormalizeTagsSearch(req.Tags) contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors) // Validate and normalize ISBN normalizedISBN, err := utils.NormalizeISBN(req.ISBN) if err != nil && req.ISBN != "" { return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"}) } // Use normalized ISBN if valid, otherwise empty string isbnValue := normalizedISBN if err != nil { isbnValue = "" } _, err = mh.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } // Validate library has folders before allowing media items hasFolders, err := mh.libraryService.HasFolders(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true}) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to validate library folders"}) } if !hasFolders { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "Cannot add media items to a library with no folders. Please add at least one folder to the library first.", }) } mediaItemID, err := mh.db.CreateMediaItem(c.Request().Context(), database.CreateMediaItemParams{ LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true}, Title: req.Title, Author: pgtype.Text{String: req.Author, Valid: req.Author != ""}, Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""}, Description: pgtype.Text{String: req.Description, Valid: req.Description != ""}, FilePath: req.FilePath, FileSize: pgtype.Int8{Int64: req.FileSize, Valid: req.FileSize > 0}, MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""}, CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""}, Series: pgtype.Text{String: req.Series, Valid: req.Series != ""}, SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0}, Tags: req.Tags, TagsSearch: tagsSearch, Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""}, DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""}, Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""}, Contributors: req.Contributors, ContributorsSearch: contributorsSearch, AddedByAdminID: user.ID, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaItemID.ID.Bytes, Valid: true}) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusCreated, item) } // UpdateMediaItem handles PUT /api/media-items/:id (admin only) func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error { user := MustGetAuthenticatedUser(c) if user.Role != "admin" { return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } var req UpdateMediaItemRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } if len(req.Tags) > 0 { req.Tags = utils.NormalizeTags(req.Tags) } if len(req.Contributors) > 0 { req.Contributors = utils.NormalizeContributors(req.Contributors) } tagsSearch := utils.NormalizeTagsSearch(req.Tags) contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors) // Validate and normalize ISBN normalizedISBN, err := utils.NormalizeISBN(req.ISBN) if err != nil && req.ISBN != "" { return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"}) } // Use normalized ISBN if valid, otherwise empty string isbnValue := normalizedISBN if err != nil { isbnValue = "" } item, err := mh.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{ ID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, Title: req.Title, Author: pgtype.Text{String: req.Author, Valid: req.Author != ""}, Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""}, Description: pgtype.Text{String: req.Description, Valid: req.Description != ""}, CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""}, Series: pgtype.Text{String: req.Series, Valid: req.Series != ""}, SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0}, Tags: req.Tags, TagsSearch: tagsSearch, Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""}, DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""}, Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""}, Contributors: req.Contributors, ContributorsSearch: contributorsSearch, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, item) } // DeleteMediaItem handles DELETE /api/media-items/:id (admin only) func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error { user := MustGetAuthenticatedUser(c) if user.Role != "admin" { return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } err = mh.db.DeleteMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.NoContent(http.StatusNoContent) } // GetMediaNotes handles GET /api/media-items/:id/notes func (mh *MediaHandler) GetMediaNotes(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } notes, err := mh.db.GetMediaNotes(c.Request().Context(), database.GetMediaNotesParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, notes) } // CreateMediaNote handles POST /api/media-items/:id/notes func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } var req CreateMediaNoteRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } note, err := mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, Content: req.Content, Position: pgtype.Text{String: req.Position, Valid: req.Position != ""}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusCreated, note) } // GetMediaNote handles GET /api/media-items/:id/notes/:noteId func (mh *MediaHandler) GetMediaNote(c *echo.Context) error { noteID := c.Param("noteId") noteUUID, err := uuid.Parse(noteID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"}) } note, err := mh.db.GetMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusNotFound, map[string]string{"error": "note not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, note) } // UpdateMediaNote handles PUT /api/media-items/:id/notes/:noteId func (mh *MediaHandler) UpdateMediaNote(c *echo.Context) error { noteID := c.Param("noteId") noteUUID, err := uuid.Parse(noteID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"}) } var req UpdateMediaNoteRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } note, err := mh.db.UpdateMediaNote(c.Request().Context(), database.UpdateMediaNoteParams{ ID: pgtype.UUID{Bytes: noteUUID, Valid: true}, Content: req.Content, Position: pgtype.Text{String: req.Position, Valid: req.Position != ""}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, note) } // DeleteMediaNote handles DELETE /api/media-items/:id/notes/:noteId func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error { noteID := c.Param("noteId") noteUUID, err := uuid.Parse(noteID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"}) } err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.NoContent(http.StatusNoContent) } // GetMediaHighlights handles GET /api/media-items/:id/highlights func (mh *MediaHandler) GetMediaHighlights(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } highlights, err := mh.db.GetMediaHighlights(c.Request().Context(), database.GetMediaHighlightsParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, highlights) } // CreateMediaHighlight handles POST /api/media-items/:id/highlights func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } var req CreateMediaHighlightRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } var noteUUID pgtype.UUID if req.NoteID != "" { if noteID, err := uuid.Parse(req.NoteID); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"}) } else { noteUUID = pgtype.UUID{Bytes: noteID, Valid: true} } } color := "#ffff00" if req.Color != "" { color = req.Color } highlight, err := mh.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, SelectionText: req.SelectionText, StartPosition: pgtype.Text{String: req.StartPosition, Valid: true}, EndPosition: pgtype.Text{String: req.EndPosition, Valid: true}, Color: pgtype.Text{String: color, Valid: true}, NoteID: noteUUID, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusCreated, highlight) } // GetMediaHighlight handles GET /api/media-items/:id/highlights/:highlightId func (mh *MediaHandler) GetMediaHighlight(c *echo.Context) error { highlightID := c.Param("highlightId") highlightUUID, err := uuid.Parse(highlightID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"}) } highlight, err := mh.db.GetMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, highlight) } // UpdateMediaHighlight handles PUT /api/media-items/:id/highlights/:highlightId func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error { highlightID := c.Param("highlightId") highlightUUID, err := uuid.Parse(highlightID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"}) } var req UpdateMediaHighlightRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } var noteUUID pgtype.UUID if req.NoteID != "" { if noteID, err := uuid.Parse(req.NoteID); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"}) } else { noteUUID = pgtype.UUID{Bytes: noteID, Valid: true} } } color := "#ffff00" if req.Color != "" { color = req.Color } highlight, err := mh.db.UpdateMediaHighlight(c.Request().Context(), database.UpdateMediaHighlightParams{ ID: pgtype.UUID{Bytes: highlightUUID, Valid: true}, SelectionText: req.SelectionText, StartPosition: pgtype.Text{String: req.StartPosition, Valid: true}, EndPosition: pgtype.Text{String: req.EndPosition, Valid: true}, Color: pgtype.Text{String: color, Valid: true}, NoteID: noteUUID, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, highlight) } // DeleteMediaHighlight handles DELETE /api/media-items/:id/highlights/:highlightId func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error { highlightID := c.Param("highlightId") highlightUUID, err := uuid.Parse(highlightID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"}) } err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true}) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.NoContent(http.StatusNoContent) } // SearchMediaItems handles GET /api/media-items/search // Supports two modes: // 1. Autocomplete: author=value, genre=value, etc. → returns field values for dropdowns // 2. Search: q=value with optional filters → returns media items func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error { // Safely get user from context userID, ok := c.Get("user").(database.Users) if !ok { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"}) } // Detect autocomplete queries first (author=value, genre=value, etc.) if author := c.QueryParam("author"); author != "" { return mh.handleFieldValuesSearch(c, userID.ID, "author", author) } if genre := c.QueryParam("genre"); genre != "" { return mh.handleFieldValuesSearch(c, userID.ID, "genre", genre) } if tags := c.QueryParam("tags"); tags != "" { return mh.handleFieldValuesSearch(c, userID.ID, "tags", tags) } if series := c.QueryParam("series"); series != "" { return mh.handleFieldValuesSearch(c, userID.ID, "series", series) } if language := c.QueryParam("language"); language != "" { return mh.handleFieldValuesSearch(c, userID.ID, "language", language) } // Handle media item search with filters query := c.QueryParam("q") libraryID := c.QueryParam("library_id") // Validate library_id if provided var libUUID pgtype.UUID if libraryID != "" { lib, err := uuid.Parse(libraryID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) } libUUID = pgtype.UUID{Bytes: lib, Valid: true} } else { libUUID = pgtype.UUID{Valid: false} } c.Logger().Info("Search parameters", "query", query, "library_id", libraryID, "libUUID_valid", libUUID.Valid, "libUUID_bytes", libUUID.Bytes) limit, _ := strconv.Atoi(c.QueryParam("limit")) if limit == 0 { limit = 50 } offset, _ := strconv.Atoi(c.QueryParam("offset")) // Extract filter parameters authorFilter := c.QueryParam("author_filter") seriesFilter := c.QueryParam("series_filter") genreFilter := c.QueryParam("genre_filter") tagsFilter := c.QueryParam("tags_filter") languageFilter := c.QueryParam("language_filter") yearMin, _ := strconv.Atoi(c.QueryParam("year_min")) yearMax, _ := strconv.Atoi(c.QueryParam("year_max")) hasCover := false hasCoverValid := false if _, exists := c.QueryParams()["has_cover"]; exists { hasCover = c.QueryParam("has_cover") == "true" hasCoverValid = true } // Extract sort parameter sortParam := c.QueryParam("sort") if sortParam == "" { sortParam = "title ASC" // Default sort } // Build search params params := services.SearchParams{ UserID: userID.ID, LibraryID: libUUID, SearchQuery: query, AuthorFilter: authorFilter, SeriesFilter: seriesFilter, GenreFilter: genreFilter, TagsFilter: tagsFilter, LanguageFilter: languageFilter, YearMin: yearMin, YearMax: yearMax, HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid}, Sort: sortParam, Limit: limit, Offset: offset, } c.Logger().Info("SearchMediaItems called", "query", query, "library_id_provided", libraryID != "", "library_id_uuid", libUUID.Valid, "library_id_bytes", libUUID.Bytes) // Call shared search service results, _, err := mh.searchService.ExecuteSearch(c.Request().Context(), params) if err != nil { c.Logger().Error("search error", "error", err.Error()) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } if len(results) == 0 { return c.JSON(http.StatusNotFound, map[string]interface{}{ "error": "no results found", "query": query, "results": []interface{}{}, }) } return c.JSON(http.StatusOK, results) } // handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.) // Returns distinct field values with counts and similarity scores for dropdown population func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error { libraryID := c.QueryParam("library_id") if libraryID == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id is required"}) } libUUID, err := uuid.Parse(libraryID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) } limit, _ := strconv.Atoi(c.QueryParam("limit")) if limit == 0 { limit = 50 } offset, _ := strconv.Atoi(c.QueryParam("offset")) params := services.FieldSearchParams{ UserID: userID, LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, FieldType: fieldType, SearchQuery: searchQuery, Limit: limit, Offset: offset, } results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params) if err != nil { c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]interface{}{ "results": results, "total": len(results), }) } // getFullFilePath returns the absolute filesystem path for a media item // Uses LibraryService for resolution (one source of truth) func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) { if relativePath == "" { return "", fmt.Errorf("no file path") } // Check if already absolute (backward compatibility) if filepath.IsAbs(relativePath) { return relativePath, nil } // Use service for resolution (one source of truth) return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath) } // ServeFile serves files (covers or books) via /uploads/library-{id}/path // Requires JWT authentication func (mh *MediaHandler) ServeFile(c *echo.Context) error { // URL format: /uploads/library-{libraryID}/{relativePath} // Get library ID directly from route parameter libraryIDStr := c.Param("id") libraryUUID, err := uuid.Parse(libraryIDStr) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library ID"}) } // Get remaining path from URL rawPath := c.Param("*") relativePath, err := url.QueryUnescape(rawPath) if err != nil { relativePath = rawPath } if relativePath == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"}) } // Resolve using service fullPath, err := mh.getFullFilePath(c.Request().Context(), pgtype.UUID{Bytes: libraryUUID, Valid: true}, relativePath) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"}) } // Check if file exists if _, err := os.Stat(fullPath); os.IsNotExist(err) { return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"}) } ext := strings.ToLower(filepath.Ext(fullPath)) contentType := services.MimeTypes[ext] if contentType == "" { contentType = "application/octet-stream" } c.Response().Header().Set("Content-Type", contentType) c.Response().Header().Set("Cache-Control", "public, max-age=86400") http.ServeFile(c.Response(), c.Request(), fullPath) return nil } // numericToFloat converts pgtype.Numeric to float64, returning 0 if invalid func numericToFloat(n pgtype.Float8) float64 { if n.Valid { return n.Float64 } return 0 } // jsonBytesToMap converts []byte JSONB to map[string]interface{}, returning nil if invalid func jsonBytesToMap(b []byte) map[string]interface{} { if len(b) == 0 { return nil } var result map[string]interface{} if err := json.Unmarshal(b, &result); err != nil { return nil } return result }