Convert tags and contributors columns from comma-separated strings to PostgreSQL TEXT[] arrays for better data normalization and query performance. Database Changes: - schema.sql: Change tags/contributors from TEXT to TEXT[] - schema.sql: Add GIN indexes for fast array searches - queries.sql: Update search queries to use ANY() operator - queries.sql: Update fuzzy search with unnest() for arrays Generated Code (sqlc): - models.go: Auto-generated with []string types for tags/contributors - queries.sql.go: Auto-generated with proper array handling Handler Changes: - media.go: Update request structs to use []string for tags/contributors - media.go: Remove pgtype.Text wrapping, use direct array assignment - media.go: Add tag normalization in CreateMediaItemHandler - collections.go: Update tags evaluation to join arrays for comparison - collections.go: Add strings import for Join() function Service Changes: - ebook_scanner.go: Update EbookMetadata struct to use []string - ebook_scanner.go: Remove string Join(), assign arrays directly - collection_service.go: Update tags rule evaluation to join arrays - collection_service.go: Add strings import New Utilities: - internal/utils/tags.go: Create NormalizeTags(), JoinTags(), SplitTags() - Normalizes tags by trimming, lowercasing, removing duplicates/empties API Documentation: - bruno/media-items/Create Media Item.bru: Update examples to use arrays - bruno/media-items/Update Media Item.bru: Update examples to use arrays - Update docs: tags/contributors now array of string Breaking Change: - JSON format changes from "tags": "tag1,tag2" to "tags": ["tag1", "tag2"] - Tests already use array format (no changes needed) Benefits: - GIN indexes enable faster array searches - Normalization prevents data quality issues (case, duplicates) - Array operations use PostgreSQL native operators (ANY, &&, unnest) - Better separation of concerns (no string parsing in application)
1336 lines
44 KiB
Go
1336 lines
44 KiB
Go
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
|
|
}
|
|
|
|
func NewMediaHandler(db *database.Queries, worker ...*services.Worker) *MediaHandler {
|
|
mh := &MediaHandler{
|
|
db: db,
|
|
}
|
|
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,
|
|
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,
|
|
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 update.Updates.Tags != nil && len(update.Updates.Tags) > 0 {
|
|
updateParams.Tags = update.Updates.Tags
|
|
}
|
|
|
|
_, 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)
|
|
}
|
|
|
|
_, 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()})
|
|
}
|
|
|
|
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,
|
|
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,
|
|
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()})
|
|
}
|
|
|
|
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,
|
|
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,
|
|
})
|
|
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)
|
|
}
|