Files
bookhoard/internal/handlers/media.go
T
john-okeefe 1e05470fbb refactor(handlers): update all handlers for Echo v5 compatibility
Update all handler functions to use *echo.Context (pointer) instead of echo.Context (value) as required by Echo v5.

Changes across all handler files:
- analytics.go: Update handler signatures
- auth.go: Update authentication handler signatures
- book_matching.go: Update matching handler signatures
- collections.go: Update collection handler signatures
- collections_preview_test.go: Update test signatures
- commonhandlers.go: Update common handler signatures
- conflicts.go: Update conflict handler signatures
- context.go: Update context handler signatures
- dashboard.go: Update dashboard handler signatures
- devices.go: Update device handler signatures
- jobs.go: Update job handler signatures
- kobo.go: Update Kobo handler signatures
- koreader.go: Update Koreader handler signatures
- library.go: Update library handler signatures
- matching.go: Update matching handler signatures
- media.go: Update media handler signatures
- opds.go: Update OPDS handler signatures
- progress.go: Update progress handler signatures
- queue.go: Update queue handler signatures
- refresh_token.go: Update token handler signatures
- scanner.go: Update scanner handler signatures
- sidecar.go: Update sidecar handler signatures
- sync.go: Update sync handler signatures
- system_settings.go: Update settings handler signatures
- websocket.go: Update WebSocket handler signatures

All handlers now properly implement Echo v5's pointer-based context pattern.
This change is necessary for type safety and compatibility with Echo v5's
improved context handling and WebSocket support.
2026-03-06 14:00:28 -05:00

1554 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")
return c.File(fullPath)
}