Files
bookhoard/internal/handlers/media.go
T
john-okeefe 77f473e090 fix(handlers): use http.ServeFile for better static file serving
Replace echo's c.File() with standard library http.ServeFile() in the
ServeFile handler. This provides more reliable static file serving and
better handles edge cases in file delivery.
2026-03-06 20:18:23 -05:00

1555 lines
52 KiB
Go

package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"bookhoard/internal/utils"
"context"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
// CreateMediaItemRequest represents the request for creating a media item
type CreateMediaItemRequest struct {
LibraryID uuid.UUID `json:"library_id" validate:"required"`
Title string `json:"title" validate:"required,min=1,max=500"`
Author string `json:"author"`
ISBN string `json:"isbn"`
Description string `json:"description"`
FilePath string `json:"file_path" validate:"required"`
FileSize int64 `json:"file_size" validate:"required,min=1"`
MimeType string `json:"mime_type" validate:"required"`
CoverImagePath string `json:"cover_image_path"`
Series string `json:"series"`
SeriesNumber int32 `json:"series_number"`
Tags []string `json:"tags"`
ASIN string `json:"asin"`
DatePublished string `json:"date_published"`
Publisher string `json:"publisher"`
Contributors []string `json:"contributors"`
}
// 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"})
}
// Resolve relative path to absolute filesystem path
fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"})
}
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"})
}
file, err := os.Open(fullPath)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open book file"})
}
defer file.Close()
mimeType := mediaItem.MimeType.String
if !mediaItem.MimeType.Valid || mimeType == "" {
mimeType = mime.TypeByExtension(filepath.Ext(mediaItem.FilePath))
}
c.Response().Header().Set("Content-Type", mimeType)
c.Response().Header().Set("Content-Disposition", "attachment; filename=\""+filepath.Base(mediaItem.FilePath)+"\"")
if mediaItem.FileSize.Valid && mediaItem.FileSize.Int64 > 0 {
c.Response().Header().Set("Content-Length", strconv.FormatInt(mediaItem.FileSize.Int64, 10))
}
_, err = io.Copy(c.Response(), file)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to stream file"})
}
return nil
}
type AddToShelfRequest struct {
MediaItemIDs []string `json:"media_item_ids" validate:"required"`
ShelfName string `json:"shelf_name"`
ShelfPosition int `json:"shelf_position"`
}
func (h *MediaHandler) AddToShelf(c *echo.Context) error {
deviceUUID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"})
}
var req AddToShelfRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
}
if req.ShelfName == "" {
req.ShelfName = "Default"
}
pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true}
addedCount := 0
for _, mediaID := range req.MediaItemIDs {
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
continue
}
_, err = h.db.AddBookToKoboShelf(c.Request().Context(), database.AddBookToKoboShelfParams{
DeviceID: pgDeviceUUID,
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
ShelfName: pgtype.Text{String: req.ShelfName, Valid: true},
ShelfPosition: pgtype.Int4{Int32: int32(req.ShelfPosition), Valid: true},
})
if err == nil {
addedCount++
req.ShelfPosition++
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"status": "success",
"added_count": addedCount,
"shelf_name": req.ShelfName,
"total_count": len(req.MediaItemIDs),
})
}
func (h *MediaHandler) GetShelf(c *echo.Context) error {
deviceUUID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"})
}
shelfName := c.QueryParam("shelf")
pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true}
var shelfBooks []database.GetKoboShelfBooksByShelfNameRow
if shelfName != "" {
shelfBooks, err = h.db.GetKoboShelfBooksByShelfName(c.Request().Context(), database.GetKoboShelfBooksByShelfNameParams{
DeviceID: pgDeviceUUID,
ShelfName: pgtype.Text{String: shelfName, Valid: true},
})
} else {
var defaultBooks []database.GetKoboShelfBooksRow
defaultBooks, err = h.db.GetKoboShelfBooks(c.Request().Context(), pgDeviceUUID)
shelfBooks = make([]database.GetKoboShelfBooksByShelfNameRow, len(defaultBooks))
for i, b := range defaultBooks {
shelfBooks[i] = database.GetKoboShelfBooksByShelfNameRow{
ID: b.ID,
DeviceID: b.DeviceID,
MediaItemID: b.MediaItemID,
ShelfName: b.ShelfName,
ShelfPosition: b.ShelfPosition,
AddedAt: b.AddedAt,
LastSyncedAt: b.LastSyncedAt,
Title: b.Title,
Author: b.Author,
MimeType: b.MimeType,
EntitlementID: b.EntitlementID,
KoboContentID: b.KoboContentID,
RevisionNumber: b.RevisionNumber,
}
}
}
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to fetch shelf"})
}
type ShelfBookResponse struct {
ID string `json:"id"`
MediaItemID string `json:"media_item_id"`
Title string `json:"title"`
Author string `json:"author"`
FilePath string `json:"file_path"`
MimeType string `json:"mime_type"`
ShelfName string `json:"shelf_name"`
ShelfPosition int `json:"shelf_position"`
EntitlementID string `json:"entitlement_id,omitempty"`
KoboContentID string `json:"kobo_content_id,omitempty"`
RevisionNumber int `json:"revision_number"`
AddedAt string `json:"added_at"`
}
response := []ShelfBookResponse{}
for _, book := range shelfBooks {
response = append(response, ShelfBookResponse{
ID: uuid.UUID(book.ID.Bytes).String(),
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
Title: book.Title,
Author: book.Author.String,
MimeType: book.MimeType.String,
ShelfName: book.ShelfName.String,
ShelfPosition: int(book.ShelfPosition.Int32),
EntitlementID: book.EntitlementID.String,
KoboContentID: book.KoboContentID.String,
RevisionNumber: int(book.RevisionNumber.Int32),
AddedAt: book.AddedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"status": "success",
"device_id": deviceUUID.String(),
"shelf_name": shelfName,
"books": response,
"total": len(response),
})
}
func (h *MediaHandler) RemoveFromShelf(c *echo.Context) error {
deviceUUID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"})
}
mediaUUID, err := uuid.Parse(c.QueryParam("media_item_id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item ID"})
}
pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true}
pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
err = h.db.RemoveBookFromKoboShelf(c.Request().Context(), database.RemoveBookFromKoboShelfParams{
DeviceID: pgDeviceUUID,
MediaItemID: pgMediaUUID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to remove from shelf"})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"status": "success",
"message": "book removed from shelf",
})
}
func (h *MediaHandler) ClearShelf(c *echo.Context) error {
deviceUUID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"})
}
shelfName := c.QueryParam("shelf")
pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true}
var result string
if shelfName != "" {
err = h.db.ClearKoboShelfByName(c.Request().Context(), database.ClearKoboShelfByNameParams{
DeviceID: pgDeviceUUID,
ShelfName: pgtype.Text{String: shelfName, Valid: true},
})
result = "shelf cleared"
} else {
err = h.db.ClearKoboShelf(c.Request().Context(), pgDeviceUUID)
result = "all shelves cleared"
}
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to clear shelf"})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"status": "success",
"message": result,
})
}
// Bulk Operations
// POST /api/media-items/bulk-delete
// Bulk delete media items
func (h *MediaHandler) HandleBulkDelete(c *echo.Context) error {
var req struct {
MediaItemIDs []string `json:"media_item_ids" validate:"required"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
if len(req.MediaItemIDs) == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "media_item_ids required"})
}
results := make([]map[string]interface{}, 0)
successCount := 0
failedCount := 0
for _, mediaIDStr := range req.MediaItemIDs {
mediaID, err := uuid.Parse(mediaIDStr)
if err != nil {
results = append(results, map[string]interface{}{
"media_item_id": mediaIDStr,
"status": "error",
"error": "Invalid UUID",
})
failedCount++
continue
}
mediaUUID := pgtype.UUID{Bytes: mediaID, Valid: true}
media, err := h.db.GetMediaItem(c.Request().Context(), mediaUUID)
if err != nil {
results = append(results, map[string]interface{}{
"media_item_id": mediaIDStr,
"status": "error",
"error": "Media item not found",
})
failedCount++
continue
}
err = h.db.DeleteMediaItem(c.Request().Context(), mediaUUID)
if err != nil {
results = append(results, map[string]interface{}{
"media_item_id": mediaIDStr,
"status": "error",
"error": err.Error(),
})
failedCount++
continue
}
if media.FilePath != "" {
os.Remove(media.FilePath)
}
results = append(results, map[string]interface{}{
"media_item_id": mediaIDStr,
"status": "success",
})
successCount++
}
return c.JSON(http.StatusOK, map[string]interface{}{
"results": results,
"total": len(req.MediaItemIDs),
"deleted": successCount,
"failed": failedCount,
})
}
// POST /api/media-items/bulk-update
// Bulk update media item metadata
func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
var req struct {
MediaItemUpdates []struct {
MediaItemID string `json:"media_item_id" validate:"required"`
Updates struct {
Title *string `json:"title,omitempty"`
Author *string `json:"author,omitempty"`
Genre *string `json:"genre,omitempty"`
Language *string `json:"language,omitempty"`
Tags []string `json:"tags,omitempty"`
} `json:"updates"`
} `json:"media_item_updates" validate:"required"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
if len(req.MediaItemUpdates) == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "media_item_updates required"})
}
for _, update := range req.MediaItemUpdates {
if update.MediaItemID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "media_item_id cannot be empty"})
}
}
results := make([]map[string]interface{}, 0)
successCount := 0
failedCount := 0
for _, update := range req.MediaItemUpdates {
mediaID, err := uuid.Parse(update.MediaItemID)
if err != nil {
results = append(results, map[string]interface{}{
"media_item_id": update.MediaItemID,
"status": "error",
"error": "Invalid UUID",
})
failedCount++
continue
}
mediaUUID := pgtype.UUID{Bytes: mediaID, Valid: true}
existingMedia, err := h.db.GetMediaItem(c.Request().Context(), mediaUUID)
if err != nil {
results = append(results, map[string]interface{}{
"media_item_id": update.MediaItemID,
"status": "error",
"error": "Media item not found",
})
failedCount++
continue
}
updateParams := database.UpdateMediaItemParams{
ID: mediaUUID,
Title: existingMedia.Title,
Author: existingMedia.Author,
Genre: existingMedia.Genre,
Language: existingMedia.Language,
Tags: existingMedia.Tags,
TagsSearch: existingMedia.TagsSearch,
Description: existingMedia.Description,
Publisher: existingMedia.Publisher,
CopyrightYear: existingMedia.CopyrightYear,
Isbn: existingMedia.Isbn,
Series: existingMedia.Series,
SeriesNumber: existingMedia.SeriesNumber,
Asin: existingMedia.Asin,
DatePublished: existingMedia.DatePublished,
Contributors: existingMedia.Contributors,
ContributorsSearch: existingMedia.ContributorsSearch,
Edition: existingMedia.Edition,
PageCount: existingMedia.PageCount,
GoodreadsID: existingMedia.GoodreadsID,
OpenlibraryID: existingMedia.OpenlibraryID,
CoverImagePath: existingMedia.CoverImagePath,
}
if update.Updates.Title != nil {
updateParams.Title = *update.Updates.Title
}
if update.Updates.Author != nil {
updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true}
}
if update.Updates.Genre != nil {
updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true}
}
if update.Updates.Language != nil {
updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true}
}
if len(update.Updates.Tags) > 0 {
normalizedTags := utils.NormalizeTags(update.Updates.Tags)
updateParams.Tags = normalizedTags
tagsSearch := utils.NormalizeTagsSearch(update.Updates.Tags)
updateParams.TagsSearch = tagsSearch
}
_, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams)
if err != nil {
results = append(results, map[string]interface{}{
"media_item_id": update.MediaItemID,
"status": "error",
"error": err.Error(),
})
failedCount++
continue
}
results = append(results, map[string]interface{}{
"media_item_id": update.MediaItemID,
"status": "success",
})
successCount++
}
return c.JSON(http.StatusOK, map[string]interface{}{
"results": results,
"total": len(req.MediaItemUpdates),
"updated": successCount,
"failed": failedCount,
})
}
// ListMediaItems handles GET /api/media-items
func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
libraryID := c.QueryParam("library_id")
sort := c.QueryParam("sort")
limit, _ := strconv.Atoi(c.QueryParam("limit"))
offset, _ := strconv.Atoi(c.QueryParam("offset"))
if limit == 0 {
limit = 50
}
if sort == "" {
sort = "created_at DESC"
}
allowedSorts := map[string]bool{
"created_at ASC": true,
"created_at DESC": true,
"title ASC": true,
"title DESC": true,
"author ASC": true,
"author DESC": true,
"series ASC": true,
"series DESC": true,
"date_published ASC": true,
"date_published DESC": true,
"copyright_year ASC": true,
"copyright_year DESC": true,
"page_count ASC": true,
"page_count DESC": true,
"genre ASC": true,
"genre DESC": true,
}
if !allowedSorts[sort] {
sort = "created_at DESC"
}
if limit > maxPaginationLimit {
limit = maxPaginationLimit
}
if offset < 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "offset cannot be negative"})
}
if libraryID != "" {
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
}
items, err := mh.db.ListMediaItemsSorted(c.Request().Context(), database.ListMediaItemsSortedParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Sort: pgtype.Text{String: sort, Valid: true},
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(offset), Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"data": items})
}
items, err := mh.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
resolvedItems := make([]map[string]interface{}, len(items))
for i, item := range items {
resolvedItems[i] = map[string]interface{}{
"id": uuid.UUID(item.ID.Bytes).String(),
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
"title": item.Title,
"author": textToString(item.Author),
"isbn": textToString(item.Isbn),
"description": textToString(item.Description),
"file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}),
"file_size": item.FileSize,
"mime_type": textToString(item.MimeType),
"cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
"series": textToString(item.Series),
"series_number": item.SeriesNumber,
"tags": item.Tags,
"asin": textToString(item.Asin),
"date_published": item.DatePublished.Time.Format("2006-01-02"),
"publisher": textToString(item.Publisher),
"contributors": item.Contributors,
"language": textToString(item.Language),
"edition": textToString(item.Edition),
"page_count": item.PageCount,
"genre": textToString(item.Genre),
"created_at": item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
"updated_at": item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
"format_group": item.FormatGroup, // already string
}
}
return c.JSON(http.StatusOK, map[string]interface{}{"data": resolvedItems})
}
// GetMediaItem handles GET /api/media-items/:id
func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"id": uuid.UUID(item.ID.Bytes).String(),
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
"title": item.Title,
"author": textToString(item.Author),
"isbn": textToString(item.Isbn),
"description": textToString(item.Description),
"file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}),
"file_size": item.FileSize,
"mime_type": textToString(item.MimeType),
"cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
"series": textToString(item.Series),
"series_number": item.SeriesNumber,
"tags": item.Tags,
"asin": textToString(item.Asin),
"date_published": item.DatePublished.Time.Format("2006-01-02"),
"publisher": textToString(item.Publisher),
"contributors": item.Contributors,
"language": textToString(item.Language),
"edition": textToString(item.Edition),
"page_count": item.PageCount,
"genre": textToString(item.Genre),
"created_at": item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
"updated_at": item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
"format_group": item.FormatGroup,
"format_mimetype": textToString(item.FormatMimetype),
})
}
// 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 offset < 0 {
offset = 0
}
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)
// Validate and normalize ISBN
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
if err != nil && req.ISBN != "" {
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"})
}
// Use normalized ISBN if valid, otherwise empty string
isbnValue := normalizedISBN
if err != nil {
isbnValue = ""
}
_, err = mh.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// Validate library has folders before allowing media items
hasFolders, err := mh.libraryService.HasFolders(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to validate library folders"})
}
if !hasFolders {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Cannot add media items to a library with no folders. Please add at least one folder to the library first.",
})
}
mediaItemID, err := mh.db.CreateMediaItem(c.Request().Context(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
FilePath: req.FilePath,
FileSize: pgtype.Int8{Int64: req.FileSize, Valid: req.FileSize > 0},
MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""},
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
Tags: req.Tags,
TagsSearch: tagsSearch,
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
Contributors: req.Contributors,
ContributorsSearch: contributorsSearch,
AddedByAdminID: user.ID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaItemID.ID.Bytes, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, item)
}
// UpdateMediaItem handles PUT /api/media-items/:id (admin only)
func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
var req UpdateMediaItemRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if len(req.Tags) > 0 {
req.Tags = utils.NormalizeTags(req.Tags)
}
if len(req.Contributors) > 0 {
req.Contributors = utils.NormalizeContributors(req.Contributors)
}
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
// Validate and normalize ISBN
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
if err != nil && req.ISBN != "" {
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"})
}
// Use normalized ISBN if valid, otherwise empty string
isbnValue := normalizedISBN
if err != nil {
isbnValue = ""
}
item, err := mh.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
Tags: req.Tags,
TagsSearch: tagsSearch,
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
Contributors: req.Contributors,
ContributorsSearch: contributorsSearch,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, item)
}
// DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
err = mh.db.DeleteMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
// GetMediaNotes handles GET /api/media-items/:id/notes
func (mh *MediaHandler) GetMediaNotes(c *echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
notes, err := mh.db.GetMediaNotes(c.Request().Context(), database.GetMediaNotesParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, notes)
}
// CreateMediaNote handles POST /api/media-items/:id/notes
func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
var req CreateMediaNoteRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
note, err := mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Content: req.Content,
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, note)
}
// GetMediaNote handles GET /api/media-items/:id/notes/:noteId
func (mh *MediaHandler) GetMediaNote(c *echo.Context) error {
noteID := c.Param("noteId")
noteUUID, err := uuid.Parse(noteID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
}
note, err := mh.db.GetMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
if err != nil {
if 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")
// Safely get user from context
userID, ok := c.Get("user").(database.Users)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
}
libraryID := c.QueryParam("library_id")
if query == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "query parameter 'q' is required"})
}
// Validate library_id if provided
var libUUID pgtype.UUID
if libraryID != "" {
lib, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
}
limit := int32(50)
offset := int32(0)
searchPattern := "%" + query + "%"
// Build params - conditionally and library_id filter
partialParams := database.SearchMediaItemsParams{
SearchPattern: pgtype.Text{String: searchPattern, Valid: true},
UserID: userID.ID,
Limit: pgtype.Int4{Int32: limit, Valid: true},
Offset: pgtype.Int4{Int32: offset, Valid: true},
LibraryID: libUUID, // May be invalid (empty)
}
partialResults, err := mh.db.SearchMediaItems(c.Request().Context(), partialParams)
if err != nil && err != pgx.ErrNoRows {
c.Logger().Error("search error", "error", err.Error(), "query", query, "library_id", libraryID)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if len(partialResults) > 0 {
return c.JSON(http.StatusOK, partialResults)
}
fuzzyParams := database.SearchMediaItemsFuzzyParams{
SearchQuery: pgtype.Text{String: query, Valid: true},
UserID: userID.ID,
Limit: pgtype.Int4{Int32: limit, Valid: true},
Offset: pgtype.Int4{Int32: offset, Valid: true},
LibraryID: libUUID,
}
fuzzyResults, err := mh.db.SearchMediaItemsFuzzy(c.Request().Context(), fuzzyParams)
if err != nil && err != pgx.ErrNoRows {
c.Logger().Error("fuzzy search error", "error", err.Error(), "query", query)
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)
}
// getFullFilePath returns the absolute filesystem path for a media item
// Uses LibraryService for resolution (one source of truth)
func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
if relativePath == "" {
return "", fmt.Errorf("no file path")
}
// Check if already absolute (backward compatibility)
if filepath.IsAbs(relativePath) {
return relativePath, nil
}
// Use service for resolution (one source of truth)
return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath)
}
// ServeFile serves files (covers or books) via /uploads/library-{id}/path
// Requires JWT authentication
func (mh *MediaHandler) ServeFile(c *echo.Context) error {
// URL format: /uploads/library-{libraryID}/{relativePath}
// Get library ID directly from route parameter
libraryIDStr := c.Param("id")
libraryUUID, err := uuid.Parse(libraryIDStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library ID"})
}
// Get remaining path from URL
rawPath := c.Param("*")
relativePath, err := url.QueryUnescape(rawPath)
if err != nil {
relativePath = rawPath
}
if relativePath == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"})
}
// Resolve using service
fullPath, err := mh.getFullFilePath(c.Request().Context(), pgtype.UUID{Bytes: libraryUUID, Valid: true}, relativePath)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
}
// Check if file exists
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
}
ext := strings.ToLower(filepath.Ext(fullPath))
contentType := services.MimeTypes[ext]
if contentType == "" {
contentType = "application/octet-stream"
}
c.Response().Header().Set("Content-Type", contentType)
c.Response().Header().Set("Cache-Control", "public, max-age=86400")
http.ServeFile(c.Response(), c.Request(), fullPath)
return nil
}