package handlers import ( "bookhoard/internal/database" "bookhoard/internal/services" "bookhoard/internal/utils" "io" "mime" "net/http" "os" "path/filepath" "strconv" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" ) // 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"` } // 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"` } // 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 } func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler { mh := &MediaHandler{ db: db, libraryService: libraryService, } if len(worker) > 0 && worker[0] != nil { mh.worker = worker[0] } return mh } 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"}) } if _, err := os.Stat(mediaItem.FilePath); os.IsNotExist(err) { return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"}) } file, err := os.Open(mediaItem.FilePath) 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 } 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, FilePath: b.FilePath, 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, FilePath: book.FilePath, 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/books/bulk-delete // Bulk delete books func (h *MediaHandler) HandleBulkDelete(c echo.Context) error { var req struct { BookIDs []string `json:"book_ids" validate:"required"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) } if len(req.BookIDs) == 0 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "book_ids required"}) } results := make([]map[string]interface{}, 0) successCount := 0 failedCount := 0 for _, bookIDStr := range req.BookIDs { bookID, err := uuid.Parse(bookIDStr) if err != nil { results = append(results, map[string]interface{}{ "book_id": bookIDStr, "status": "error", "error": "Invalid UUID", }) failedCount++ continue } bookUUID := pgtype.UUID{Bytes: bookID, Valid: true} book, err := h.db.GetMediaItem(c.Request().Context(), bookUUID) if err != nil { results = append(results, map[string]interface{}{ "book_id": bookIDStr, "status": "error", "error": "Book not found", }) failedCount++ continue } err = h.db.DeleteMediaItem(c.Request().Context(), bookUUID) if err != nil { results = append(results, map[string]interface{}{ "book_id": bookIDStr, "status": "error", "error": err.Error(), }) failedCount++ continue } if book.FilePath != "" { os.Remove(book.FilePath) } results = append(results, map[string]interface{}{ "book_id": bookIDStr, "status": "success", }) successCount++ } return c.JSON(http.StatusOK, map[string]interface{}{ "results": results, "total": len(req.BookIDs), "success": successCount, "failed": failedCount, }) } // POST /api/books/bulk-update // Bulk update book metadata func (h *MediaHandler) HandleBulkUpdate(c echo.Context) error { var req struct { Updates []struct { BookID string `json:"book_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:"updates" validate:"required"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) } if len(req.Updates) == 0 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "updates required"}) } results := make([]map[string]interface{}, 0) successCount := 0 failedCount := 0 for _, update := range req.Updates { bookID, err := uuid.Parse(update.BookID) if err != nil { results = append(results, map[string]interface{}{ "book_id": update.BookID, "status": "error", "error": "Invalid UUID", }) failedCount++ continue } bookUUID := pgtype.UUID{Bytes: bookID, Valid: true} existingBook, err := h.db.GetMediaItem(c.Request().Context(), bookUUID) if err != nil { results = append(results, map[string]interface{}{ "book_id": update.BookID, "status": "error", "error": "Book not found", }) failedCount++ continue } updateParams := database.UpdateMediaItemParams{ ID: bookUUID, Title: existingBook.Title, Author: existingBook.Author, Genre: existingBook.Genre, Language: existingBook.Language, Tags: existingBook.Tags, TagsSearch: existingBook.TagsSearch, Description: existingBook.Description, Publisher: existingBook.Publisher, CopyrightYear: existingBook.CopyrightYear, Isbn: existingBook.Isbn, Series: existingBook.Series, SeriesNumber: existingBook.SeriesNumber, Asin: existingBook.Asin, DatePublished: existingBook.DatePublished, Contributors: existingBook.Contributors, ContributorsSearch: existingBook.ContributorsSearch, Edition: existingBook.Edition, PageCount: existingBook.PageCount, GoodreadsID: existingBook.GoodreadsID, OpenlibraryID: existingBook.OpenlibraryID, CoverImagePath: existingBook.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{}{ "book_id": update.BookID, "status": "error", "error": err.Error(), }) failedCount++ continue } results = append(results, map[string]interface{}{ "book_id": update.BookID, "status": "success", }) successCount++ } return c.JSON(http.StatusOK, map[string]interface{}{ "results": results, "total": len(req.Updates), "success": 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()}) } return c.JSON(http.StatusOK, map[string]interface{}{"data": items}) } // 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 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, item) } // ListMediaItemsFiltered handles GET /api/media-items/filtered func (mh *MediaHandler) ListMediaItemsFiltered(c echo.Context) error { userID := c.Get("user_id").(string) libraryID := c.QueryParam("library_id") sort := c.QueryParam("sort") limit, _ := strconv.Atoi(c.QueryParam("limit")) offset, _ := strconv.Atoi(c.QueryParam("offset")) authorFilter := c.QueryParam("author_filter") seriesFilter := c.QueryParam("series_filter") genreFilter := c.QueryParam("genre_filter") languageFilter := c.QueryParam("language_filter") yearMin, _ := strconv.Atoi(c.QueryParam("year_min")) yearMax, _ := strconv.Atoi(c.QueryParam("year_max")) hasCover, _ := strconv.ParseBool(c.QueryParam("has_cover")) if limit == 0 { limit = 50 } if sort == "" { sort = "created_at DESC" } if limit > maxPaginationLimit { limit = maxPaginationLimit } libUUID, err := uuid.Parse(libraryID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"}) } userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) } items, err := mh.db.ListMediaItemsFiltered(c.Request().Context(), database.ListMediaItemsFilteredParams{ LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, AuthorFilter: pgtype.Text{String: authorFilter, Valid: true}, SeriesFilter: pgtype.Text{String: seriesFilter, Valid: true}, GenreFilter: pgtype.Text{String: genreFilter, Valid: true}, LanguageFilter: pgtype.Text{String: languageFilter, Valid: true}, YearMin: pgtype.Int4{Int32: int32(yearMin), Valid: true}, YearMax: pgtype.Int4{Int32: int32(yearMax), Valid: true}, HasCover: pgtype.Bool{Bool: hasCover, 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}) } // 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 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.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { if 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()}) } return c.JSON(http.StatusOK, progress) } // 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"` } 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()}) } progress, err := mh.db.UpdateReadingProgress(c.Request().Context(), database.UpdateReadingProgressParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, CurrentPage: pgtype.Int4{Int32: req.CurrentPage, Valid: true}, TotalPages: pgtype.Int4{Int32: req.TotalPages, Valid: req.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) _, err := mh.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true}) if err != nil { if 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: req.ISBN, 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) 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: req.ISBN, 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 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 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 func (mh *MediaHandler) SearchMediaItems(c echo.Context) error { query := c.QueryParam("q") userID := c.Get("user_id").(string) if query == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "query parameter 'q' is required"}) } userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) } limit := int32(50) offset := int32(0) searchPattern := "%" + query + "%" partialResults, err := mh.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{ SearchPattern: pgtype.Text{String: searchPattern, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, Limit: pgtype.Int4{Int32: limit, Valid: true}, Offset: pgtype.Int4{Int32: offset, Valid: true}, }) if err != nil && err != pgx.ErrNoRows { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } if len(partialResults) > 0 { return c.JSON(http.StatusOK, partialResults) } fuzzyResults, err := mh.db.SearchMediaItemsFuzzy(c.Request().Context(), database.SearchMediaItemsFuzzyParams{ SearchQuery: pgtype.Text{String: query, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, Limit: pgtype.Int4{Int32: limit, Valid: true}, Offset: pgtype.Int4{Int32: offset, Valid: true}, }) if err != nil && err != pgx.ErrNoRows { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } if len(fuzzyResults) == 0 { return c.JSON(http.StatusNotFound, map[string]interface{}{ "error": "no results found", "query": query, "results": []interface{}{}, }) } return c.JSON(http.StatusOK, fuzzyResults) }