Complete the annotation sync pipeline across all ingest and serve paths.
Previously, annotations sent inline with KOReader progress pushes were
silently discarded, and no annotations were ever served back to devices.
INGEST (device → server):
KOReader (koreader.go):
- Add processBookAnnotations helper that processes inline highlights,
notes, and bookmarks from every progress push (immediate + checkpoint)
- Highlights get CRE→CFI position conversion before SaveHighlight
- KOReader 'notes' (text + notes) stored as highlights with NoteText
to ensure correct round-trip classification
- Bookmarks routed through SaveBookmark with device sync data
- Called from both updateProgressForBook and handleCheckpointSync
Kobo (kobo.go):
- Markup handler: annotations and bookmarks route through
AnnotationService (SaveHighlight/SaveBookmark)
- Bookmark handler: same routing with device sync data
- SyncFromServer handler: same routing
- All handlers fall back to direct DB calls when annotationSvc == nil
Web reader (media.go):
- CreateMediaHighlight → SaveHighlight (Source="web", ModifiedAt=now)
- CreateMediaNote → SaveNote (Source="web")
- DeleteMediaHighlight → TombstoneHighlightByID
- DeleteMediaNote → TombstoneNoteByID (was hard delete, now tombstone)
- All fall back to old behavior when annotationSvc == nil
SERVE (server → device):
KOReader GetMetadata (koreader.go):
- Query and serve bookmarks from media_bookmarks table (was missing)
- Serve deleted_highlights and deleted_bookmarks arrays containing
device_sync_data + dedup_key for client-side deletion
- Highlights/notes already served with reverse CFI conversion
Kobo Markup handler (kobo.go):
- Track processed books during sync
- Query tombstones per book, extract bookmark_id from device_sync_data
- Return DeletedAnnotations array in KoboSyncStatus response
Conflict resolution (conflicts.go):
- Enable annotation conflict types in ResolveConflict handler
- Add applyAnnotationResolution dispatching to:
applyHighlightResolution / applyBookmarkResolution / applyNoteResolution
- Each looks up by dedup_key and applies winner's fields
- Allow manual override of auto_resolved conflicts
(changed check from != "unresolved" to == "user_resolved")
Infrastructure:
- AnnotationService field + SetAnnotationService in router Config
- Inject AnnotationService into KOReader, Kobo, Media handlers
- Start tombstone purger goroutine in main.go (24h interval)
- Test helpers: construct AnnotationService in test setup
1967 lines
69 KiB
Go
1967 lines
69 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/services"
|
|
wsync "bookhoard/internal/sync"
|
|
"bookhoard/internal/utils"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
// CreateMediaItemRequest represents the request for creating a media item
|
|
type CreateMediaItemRequest struct {
|
|
LibraryID uuid.UUID `json:"library_id" validate:"required"`
|
|
Title string `json:"title" validate:"required,min=1,max=500"`
|
|
Author string `json:"author"`
|
|
ISBN string `json:"isbn"`
|
|
Description string `json:"description"`
|
|
FilePath string `json:"file_path" validate:"required"`
|
|
FileSize int64 `json:"file_size" validate:"required,min=1"`
|
|
MimeType string `json:"mime_type" validate:"required"`
|
|
CoverImagePath string `json:"cover_image_path"`
|
|
Series string `json:"series"`
|
|
SeriesNumber int32 `json:"series_number"`
|
|
Tags []string `json:"tags"`
|
|
ASIN string `json:"asin"`
|
|
DatePublished string `json:"date_published"`
|
|
Publisher string `json:"publisher"`
|
|
Contributors []string `json:"contributors"`
|
|
// NEW: Reading direction and comic metadata fields
|
|
MangaType string `json:"manga_type"` // 'unknown' | 'no' | 'yes' | 'yes_and_right_to_left'
|
|
ReadingDirection string `json:"reading_direction"` // 'auto' | 'ltr' | 'rtl' | 'vertical'
|
|
SeriesCount int32 `json:"series_count"`
|
|
Volume int32 `json:"volume"`
|
|
Imprint string `json:"imprint"`
|
|
AgeRating string `json:"age_rating"` // 'Everyone' | 'Teen' | 'Mature' | 'Adult'
|
|
WebURL string `json:"web_url"`
|
|
MetadataNotes string `json:"metadata_notes"`
|
|
CommunityRating float64 `json:"community_rating"`
|
|
StoryArc string `json:"story_arc"`
|
|
IsBlackAndWhite bool `json:"is_black_and_white"`
|
|
AlternateInfo string `json:"alternate_info"` // JSON string
|
|
ScanInformation string `json:"scan_information"`
|
|
Summary string `json:"summary"`
|
|
}
|
|
|
|
// UpdateMediaItemRequest represents the request for updating a media item
|
|
type UpdateMediaItemRequest struct {
|
|
Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
|
|
Author string `form:"author" json:"author"`
|
|
ISBN string `form:"isbn" json:"isbn"`
|
|
Description string `form:"description" json:"description"`
|
|
CoverImagePath string `form:"cover_image_path" json:"cover_image_path"`
|
|
CoverAction string `form:"cover_action" json:"cover_action"`
|
|
Series string `form:"series" json:"series"`
|
|
SeriesNumber int32 `form:"series_number" json:"series_number"`
|
|
Tags []string `form:"tags" json:"tags"`
|
|
ASIN string `form:"asin" json:"asin"`
|
|
DatePublished string `form:"date_published" json:"date_published"`
|
|
Publisher string `form:"publisher" json:"publisher"`
|
|
Contributors []string `form:"contributors" json:"contributors"`
|
|
Language string `form:"language" json:"language"`
|
|
Edition string `form:"edition" json:"edition"`
|
|
PageCount int32 `form:"page_count" json:"page_count"`
|
|
Genre string `form:"genre" json:"genre"`
|
|
CopyrightYear int32 `form:"copyright_year" json:"copyright_year"`
|
|
GoodreadsID string `form:"goodreads_id" json:"goodreads_id"`
|
|
OpenlibraryID string `form:"openlibrary_id" json:"openlibrary_id"`
|
|
GoogleBooksID string `form:"google_books_id" json:"google_books_id"`
|
|
MangaType string `form:"manga_type" json:"manga_type"`
|
|
ReadingDirection string `form:"reading_direction" json:"reading_direction"`
|
|
SeriesCount int32 `form:"series_count" json:"series_count"`
|
|
Volume int32 `form:"volume" json:"volume"`
|
|
Imprint string `form:"imprint" json:"imprint"`
|
|
AgeRating string `form:"age_rating" json:"age_rating"`
|
|
WebURL string `form:"web_url" json:"web_url"`
|
|
MetadataNotes string `form:"metadata_notes" json:"metadata_notes"`
|
|
CommunityRating float64 `form:"community_rating" json:"community_rating"`
|
|
StoryArc string `form:"story_arc" json:"story_arc"`
|
|
IsBlackAndWhite bool `form:"is_black_and_white" json:"is_black_and_white"`
|
|
AlternateInfo string `form:"alternate_info" json:"alternate_info"`
|
|
ScanInformation string `form:"scan_information" json:"scan_information"`
|
|
Summary string `form:"summary" json:"summary"`
|
|
}
|
|
|
|
// CreateMediaNoteRequest represents the request for creating a media note
|
|
type CreateMediaNoteRequest struct {
|
|
Content string `json:"content" validate:"required,min=1,max=10000"`
|
|
Position string `json:"position"`
|
|
}
|
|
|
|
// UpdateMediaNoteRequest represents the request for updating a media note
|
|
type UpdateMediaNoteRequest struct {
|
|
Content string `json:"content" validate:"required,min=1,max=10000"`
|
|
Position string `json:"position"`
|
|
}
|
|
|
|
// CreateMediaHighlightRequest represents the request for creating a media highlight
|
|
type CreateMediaHighlightRequest struct {
|
|
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
|
|
StartPosition string `json:"start_position" validate:"required,max=100"`
|
|
EndPosition string `json:"end_position" validate:"required,max=100"`
|
|
Color string `json:"color" validate:"omitempty,len=7"`
|
|
NoteID string `json:"note_id"`
|
|
}
|
|
|
|
// UpdateMediaHighlightRequest represents the request for updating a media highlight
|
|
type UpdateMediaHighlightRequest struct {
|
|
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
|
|
StartPosition string `json:"start_position" validate:"required,max=100"`
|
|
EndPosition string `json:"end_position" validate:"required,max=100"`
|
|
Color string `json:"color" validate:"omitempty,len=7"`
|
|
NoteID string `json:"note_id"`
|
|
}
|
|
|
|
type MediaHandler struct {
|
|
db *database.Queries
|
|
worker *services.Worker
|
|
libraryService *services.LibraryService
|
|
searchService *services.SearchService
|
|
progressSvc *wsync.ProgressService
|
|
annotationSvc *wsync.AnnotationService
|
|
}
|
|
|
|
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler {
|
|
mh := &MediaHandler{
|
|
db: db,
|
|
libraryService: libraryService,
|
|
searchService: services.NewSearchService(db),
|
|
}
|
|
if len(worker) > 0 && worker[0] != nil {
|
|
mh.worker = worker[0]
|
|
}
|
|
return mh
|
|
}
|
|
|
|
func (mh *MediaHandler) SetProgressService(svc *wsync.ProgressService) {
|
|
mh.progressSvc = svc
|
|
}
|
|
|
|
func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
|
mh.annotationSvc = svc
|
|
}
|
|
|
|
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
|
|
bookUUID, err := uuid.Parse(c.Param("uuid"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book UUID"})
|
|
}
|
|
|
|
pgBookUUID := pgtype.UUID{Bytes: bookUUID, Valid: true}
|
|
|
|
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgBookUUID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
|
}
|
|
|
|
// Resolve relative path to absolute filesystem path
|
|
fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"})
|
|
}
|
|
|
|
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"})
|
|
}
|
|
|
|
file, err := os.Open(fullPath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open book file"})
|
|
}
|
|
defer file.Close()
|
|
|
|
mimeType := mediaItem.MimeType.String
|
|
if !mediaItem.MimeType.Valid || mimeType == "" {
|
|
mimeType = mime.TypeByExtension(filepath.Ext(mediaItem.FilePath))
|
|
}
|
|
|
|
c.Response().Header().Set("Content-Type", mimeType)
|
|
c.Response().Header().Set("Content-Disposition", "attachment; filename=\""+filepath.Base(mediaItem.FilePath)+"\"")
|
|
|
|
if mediaItem.FileSize.Valid && mediaItem.FileSize.Int64 > 0 {
|
|
c.Response().Header().Set("Content-Length", strconv.FormatInt(mediaItem.FileSize.Int64, 10))
|
|
}
|
|
|
|
_, err = io.Copy(c.Response(), file)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to stream file"})
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ExecuteSearch performs search and returns results with count
|
|
// Public wrapper for shared search logic used by both JSON and HTML endpoints
|
|
func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
|
|
return h.searchService.ExecuteSearch(ctx, params)
|
|
}
|
|
|
|
type AddToShelfRequest struct {
|
|
MediaItemIDs []string `json:"media_item_ids" validate:"required"`
|
|
ShelfName string `json:"shelf_name"`
|
|
ShelfPosition int `json:"shelf_position"`
|
|
}
|
|
|
|
func (h *MediaHandler) AddToShelf(c *echo.Context) error {
|
|
deviceUUID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"})
|
|
}
|
|
|
|
var req AddToShelfRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
|
|
}
|
|
|
|
if req.ShelfName == "" {
|
|
req.ShelfName = "Default"
|
|
}
|
|
|
|
pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true}
|
|
addedCount := 0
|
|
|
|
for _, mediaID := range req.MediaItemIDs {
|
|
mediaUUID, err := uuid.Parse(mediaID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
_, err = h.db.AddBookToKoboShelf(c.Request().Context(), database.AddBookToKoboShelfParams{
|
|
DeviceID: pgDeviceUUID,
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
ShelfName: pgtype.Text{String: req.ShelfName, Valid: true},
|
|
ShelfPosition: pgtype.Int4{Int32: int32(req.ShelfPosition), Valid: true},
|
|
})
|
|
|
|
if err == nil {
|
|
addedCount++
|
|
req.ShelfPosition++
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"status": "success",
|
|
"added_count": addedCount,
|
|
"shelf_name": req.ShelfName,
|
|
"total_count": len(req.MediaItemIDs),
|
|
})
|
|
}
|
|
|
|
func (h *MediaHandler) GetShelf(c *echo.Context) error {
|
|
deviceUUID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"})
|
|
}
|
|
|
|
shelfName := c.QueryParam("shelf")
|
|
pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true}
|
|
|
|
var shelfBooks []database.GetKoboShelfBooksByShelfNameRow
|
|
if shelfName != "" {
|
|
shelfBooks, err = h.db.GetKoboShelfBooksByShelfName(c.Request().Context(), database.GetKoboShelfBooksByShelfNameParams{
|
|
DeviceID: pgDeviceUUID,
|
|
ShelfName: pgtype.Text{String: shelfName, Valid: true},
|
|
})
|
|
} else {
|
|
var defaultBooks []database.GetKoboShelfBooksRow
|
|
defaultBooks, err = h.db.GetKoboShelfBooks(c.Request().Context(), pgDeviceUUID)
|
|
shelfBooks = make([]database.GetKoboShelfBooksByShelfNameRow, len(defaultBooks))
|
|
for i, b := range defaultBooks {
|
|
shelfBooks[i] = database.GetKoboShelfBooksByShelfNameRow{
|
|
ID: b.ID,
|
|
DeviceID: b.DeviceID,
|
|
MediaItemID: b.MediaItemID,
|
|
ShelfName: b.ShelfName,
|
|
ShelfPosition: b.ShelfPosition,
|
|
AddedAt: b.AddedAt,
|
|
LastSyncedAt: b.LastSyncedAt,
|
|
Title: b.Title,
|
|
Author: b.Author,
|
|
MimeType: b.MimeType,
|
|
EntitlementID: b.EntitlementID,
|
|
KoboContentID: b.KoboContentID,
|
|
RevisionNumber: b.RevisionNumber,
|
|
}
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to fetch shelf"})
|
|
}
|
|
|
|
type ShelfBookResponse struct {
|
|
ID string `json:"id"`
|
|
MediaItemID string `json:"media_item_id"`
|
|
Title string `json:"title"`
|
|
Author string `json:"author"`
|
|
FilePath string `json:"file_path"`
|
|
MimeType string `json:"mime_type"`
|
|
ShelfName string `json:"shelf_name"`
|
|
ShelfPosition int `json:"shelf_position"`
|
|
EntitlementID string `json:"entitlement_id,omitempty"`
|
|
KoboContentID string `json:"kobo_content_id,omitempty"`
|
|
RevisionNumber int `json:"revision_number"`
|
|
AddedAt string `json:"added_at"`
|
|
}
|
|
|
|
response := []ShelfBookResponse{}
|
|
for _, book := range shelfBooks {
|
|
response = append(response, ShelfBookResponse{
|
|
ID: uuid.UUID(book.ID.Bytes).String(),
|
|
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
|
|
Title: book.Title,
|
|
Author: book.Author.String,
|
|
MimeType: book.MimeType.String,
|
|
ShelfName: book.ShelfName.String,
|
|
ShelfPosition: int(book.ShelfPosition.Int32),
|
|
EntitlementID: book.EntitlementID.String,
|
|
KoboContentID: book.KoboContentID.String,
|
|
RevisionNumber: int(book.RevisionNumber.Int32),
|
|
AddedAt: book.AddedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"status": "success",
|
|
"device_id": deviceUUID.String(),
|
|
"shelf_name": shelfName,
|
|
"books": response,
|
|
"total": len(response),
|
|
})
|
|
}
|
|
|
|
func (h *MediaHandler) RemoveFromShelf(c *echo.Context) error {
|
|
deviceUUID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"})
|
|
}
|
|
|
|
mediaUUID, err := uuid.Parse(c.QueryParam("media_item_id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item ID"})
|
|
}
|
|
|
|
pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true}
|
|
pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
|
|
|
|
err = h.db.RemoveBookFromKoboShelf(c.Request().Context(), database.RemoveBookFromKoboShelfParams{
|
|
DeviceID: pgDeviceUUID,
|
|
MediaItemID: pgMediaUUID,
|
|
})
|
|
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to remove from shelf"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"status": "success",
|
|
"message": "book removed from shelf",
|
|
})
|
|
}
|
|
|
|
func (h *MediaHandler) ClearShelf(c *echo.Context) error {
|
|
deviceUUID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"})
|
|
}
|
|
|
|
shelfName := c.QueryParam("shelf")
|
|
pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true}
|
|
|
|
var result string
|
|
if shelfName != "" {
|
|
err = h.db.ClearKoboShelfByName(c.Request().Context(), database.ClearKoboShelfByNameParams{
|
|
DeviceID: pgDeviceUUID,
|
|
ShelfName: pgtype.Text{String: shelfName, Valid: true},
|
|
})
|
|
result = "shelf cleared"
|
|
} else {
|
|
err = h.db.ClearKoboShelf(c.Request().Context(), pgDeviceUUID)
|
|
result = "all shelves cleared"
|
|
}
|
|
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to clear shelf"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"status": "success",
|
|
"message": result,
|
|
})
|
|
}
|
|
|
|
// Bulk Operations
|
|
|
|
// POST /api/media-items/bulk-delete
|
|
// Bulk delete media items
|
|
func (h *MediaHandler) HandleBulkDelete(c *echo.Context) error {
|
|
var req struct {
|
|
MediaItemIDs []string `json:"media_item_ids" validate:"required"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
|
}
|
|
|
|
if len(req.MediaItemIDs) == 0 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "media_item_ids required"})
|
|
}
|
|
|
|
results := make([]map[string]interface{}, 0)
|
|
successCount := 0
|
|
failedCount := 0
|
|
|
|
for _, mediaIDStr := range req.MediaItemIDs {
|
|
mediaID, err := uuid.Parse(mediaIDStr)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"media_item_id": mediaIDStr,
|
|
"status": "error",
|
|
"error": "Invalid UUID",
|
|
})
|
|
failedCount++
|
|
continue
|
|
}
|
|
|
|
mediaUUID := pgtype.UUID{Bytes: mediaID, Valid: true}
|
|
|
|
media, err := h.db.GetMediaItem(c.Request().Context(), mediaUUID)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"media_item_id": mediaIDStr,
|
|
"status": "error",
|
|
"error": "Media item not found",
|
|
})
|
|
failedCount++
|
|
continue
|
|
}
|
|
|
|
err = h.db.DeleteMediaItem(c.Request().Context(), mediaUUID)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"media_item_id": mediaIDStr,
|
|
"status": "error",
|
|
"error": err.Error(),
|
|
})
|
|
failedCount++
|
|
continue
|
|
}
|
|
|
|
if media.FilePath != "" {
|
|
if err := os.Remove(media.FilePath); err != nil {
|
|
// Log file removal failure but don't fail the request
|
|
// DB record is already deleted, which is the primary concern
|
|
log.Printf("Warning: failed to remove file %s: %v", media.FilePath, err)
|
|
}
|
|
}
|
|
|
|
results = append(results, map[string]interface{}{
|
|
"media_item_id": mediaIDStr,
|
|
"status": "success",
|
|
})
|
|
successCount++
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"results": results,
|
|
"total": len(req.MediaItemIDs),
|
|
"deleted": successCount,
|
|
"failed": failedCount,
|
|
})
|
|
}
|
|
|
|
// POST /api/media-items/bulk-update
|
|
// Bulk update media item metadata
|
|
func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
|
|
var req struct {
|
|
MediaItemUpdates []struct {
|
|
MediaItemID string `json:"media_item_id" validate:"required"`
|
|
Updates struct {
|
|
Title *string `json:"title,omitempty"`
|
|
Author *string `json:"author,omitempty"`
|
|
Genre *string `json:"genre,omitempty"`
|
|
Language *string `json:"language,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
} `json:"updates"`
|
|
} `json:"media_item_updates" validate:"required"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
|
}
|
|
|
|
if len(req.MediaItemUpdates) == 0 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "media_item_updates required"})
|
|
}
|
|
|
|
for _, update := range req.MediaItemUpdates {
|
|
if update.MediaItemID == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "media_item_id cannot be empty"})
|
|
}
|
|
}
|
|
|
|
results := make([]map[string]interface{}, 0)
|
|
successCount := 0
|
|
failedCount := 0
|
|
|
|
for _, update := range req.MediaItemUpdates {
|
|
mediaID, err := uuid.Parse(update.MediaItemID)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"media_item_id": update.MediaItemID,
|
|
"status": "error",
|
|
"error": "Invalid UUID",
|
|
})
|
|
failedCount++
|
|
continue
|
|
}
|
|
|
|
mediaUUID := pgtype.UUID{Bytes: mediaID, Valid: true}
|
|
|
|
existingMedia, err := h.db.GetMediaItem(c.Request().Context(), mediaUUID)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"media_item_id": update.MediaItemID,
|
|
"status": "error",
|
|
"error": "Media item not found",
|
|
})
|
|
failedCount++
|
|
continue
|
|
}
|
|
|
|
updateParams := database.UpdateMediaItemParams{
|
|
ID: mediaUUID,
|
|
Title: existingMedia.Title,
|
|
Author: existingMedia.Author,
|
|
Genre: existingMedia.Genre,
|
|
Language: existingMedia.Language,
|
|
Tags: existingMedia.Tags,
|
|
TagsSearch: existingMedia.TagsSearch,
|
|
Description: existingMedia.Description,
|
|
Publisher: existingMedia.Publisher,
|
|
CopyrightYear: existingMedia.CopyrightYear,
|
|
Isbn: existingMedia.Isbn,
|
|
Series: existingMedia.Series,
|
|
SeriesNumber: existingMedia.SeriesNumber,
|
|
Asin: existingMedia.Asin,
|
|
DatePublished: existingMedia.DatePublished,
|
|
Contributors: existingMedia.Contributors,
|
|
ContributorsSearch: existingMedia.ContributorsSearch,
|
|
Edition: existingMedia.Edition,
|
|
PageCount: existingMedia.PageCount,
|
|
GoodreadsID: existingMedia.GoodreadsID,
|
|
OpenlibraryID: existingMedia.OpenlibraryID,
|
|
GoogleBooksID: existingMedia.GoogleBooksID,
|
|
CoverImagePath: existingMedia.CoverImagePath,
|
|
MangaType: existingMedia.MangaType,
|
|
ReadingDirection: existingMedia.ReadingDirection,
|
|
SeriesCount: existingMedia.SeriesCount,
|
|
Volume: existingMedia.Volume,
|
|
Imprint: existingMedia.Imprint,
|
|
AgeRating: existingMedia.AgeRating,
|
|
WebUrl: existingMedia.WebUrl,
|
|
MetadataNotes: existingMedia.MetadataNotes,
|
|
CommunityRating: existingMedia.CommunityRating,
|
|
StoryArc: existingMedia.StoryArc,
|
|
IsBlackAndWhite: existingMedia.IsBlackAndWhite,
|
|
AlternateInfo: existingMedia.AlternateInfo,
|
|
ScanInformation: existingMedia.ScanInformation,
|
|
Summary: existingMedia.Summary,
|
|
}
|
|
|
|
if update.Updates.Title != nil {
|
|
updateParams.Title = *update.Updates.Title
|
|
}
|
|
if update.Updates.Author != nil {
|
|
updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true}
|
|
}
|
|
if update.Updates.Genre != nil {
|
|
updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true}
|
|
}
|
|
if update.Updates.Language != nil {
|
|
updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true}
|
|
}
|
|
if len(update.Updates.Tags) > 0 {
|
|
normalizedTags := utils.NormalizeTags(update.Updates.Tags)
|
|
updateParams.Tags = normalizedTags
|
|
tagsSearch := utils.NormalizeTagsSearch(update.Updates.Tags)
|
|
updateParams.TagsSearch = tagsSearch
|
|
}
|
|
|
|
_, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"media_item_id": update.MediaItemID,
|
|
"status": "error",
|
|
"error": err.Error(),
|
|
})
|
|
failedCount++
|
|
continue
|
|
}
|
|
|
|
results = append(results, map[string]interface{}{
|
|
"media_item_id": update.MediaItemID,
|
|
"status": "success",
|
|
})
|
|
successCount++
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"results": results,
|
|
"total": len(req.MediaItemUpdates),
|
|
"updated": successCount,
|
|
"failed": failedCount,
|
|
})
|
|
}
|
|
|
|
// ListMediaItems handles GET /api/media-items
|
|
func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
|
|
libraryID := c.QueryParam("library_id")
|
|
sort := c.QueryParam("sort")
|
|
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
|
offset, _ := strconv.Atoi(c.QueryParam("offset"))
|
|
|
|
if limit == 0 {
|
|
limit = 50
|
|
}
|
|
|
|
if sort == "" {
|
|
sort = "created_at DESC"
|
|
}
|
|
|
|
allowedSorts := map[string]bool{
|
|
"created_at ASC": true,
|
|
"created_at DESC": true,
|
|
"title ASC": true,
|
|
"title DESC": true,
|
|
"author ASC": true,
|
|
"author DESC": true,
|
|
"series ASC": true,
|
|
"series DESC": true,
|
|
"date_published ASC": true,
|
|
"date_published DESC": true,
|
|
"copyright_year ASC": true,
|
|
"copyright_year DESC": true,
|
|
"page_count ASC": true,
|
|
"page_count DESC": true,
|
|
"genre ASC": true,
|
|
"genre DESC": true,
|
|
}
|
|
|
|
if !allowedSorts[sort] {
|
|
sort = "created_at DESC"
|
|
}
|
|
|
|
if limit > maxPaginationLimit {
|
|
limit = maxPaginationLimit
|
|
}
|
|
|
|
if offset < 0 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "offset cannot be negative"})
|
|
}
|
|
|
|
if libraryID != "" {
|
|
libUUID, err := uuid.Parse(libraryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
|
|
}
|
|
|
|
items, err := mh.db.ListMediaItemsSorted(c.Request().Context(), database.ListMediaItemsSortedParams{
|
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
|
Sort: pgtype.Text{String: sort, Valid: true},
|
|
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
|
Offset: pgtype.Int4{Int32: int32(offset), Valid: true},
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"data": items})
|
|
}
|
|
|
|
items, err := mh.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
|
|
Limit: int32(limit),
|
|
Offset: int32(offset),
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
resolvedItems := make([]map[string]interface{}, len(items))
|
|
for i, item := range items {
|
|
resolvedItems[i] = map[string]interface{}{
|
|
"id": uuid.UUID(item.ID.Bytes).String(),
|
|
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
|
|
"title": item.Title,
|
|
"author": textToString(item.Author),
|
|
"isbn": textToString(item.Isbn),
|
|
"description": textToString(item.Description),
|
|
"file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}),
|
|
"file_size": item.FileSize,
|
|
"mime_type": textToString(item.MimeType),
|
|
"cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
|
"series": textToString(item.Series),
|
|
"series_number": item.SeriesNumber,
|
|
"tags": item.Tags,
|
|
"asin": textToString(item.Asin),
|
|
"date_published": item.DatePublished.Time.Format("2006-01-02"),
|
|
"publisher": textToString(item.Publisher),
|
|
"contributors": item.Contributors,
|
|
"language": textToString(item.Language),
|
|
"edition": textToString(item.Edition),
|
|
"page_count": item.PageCount,
|
|
"genre": textToString(item.Genre),
|
|
"created_at": item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
|
"updated_at": item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
|
"format_group": item.FormatGroup, // already string
|
|
// NEW: Reading direction fields
|
|
"manga_type": textToString(item.MangaType),
|
|
"reading_direction": textToString(item.ReadingDirection),
|
|
// NEW: Universal metadata fields
|
|
"series_count": item.SeriesCount,
|
|
"volume": item.Volume,
|
|
"imprint": textToString(item.Imprint),
|
|
"age_rating": textToString(item.AgeRating),
|
|
"web_url": textToString(item.WebUrl),
|
|
"metadata_notes": textToString(item.MetadataNotes),
|
|
"community_rating": numericToFloat(item.CommunityRating),
|
|
// NEW: Comic-specific fields
|
|
"story_arc": textToString(item.StoryArc),
|
|
"is_black_and_white": item.IsBlackAndWhite,
|
|
"alternate_info": jsonBytesToMap(item.AlternateInfo),
|
|
"scan_information": textToString(item.ScanInformation),
|
|
"summary": textToString(item.Summary),
|
|
}
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"data": resolvedItems})
|
|
}
|
|
|
|
// GetMediaItem handles GET /api/media-items/:id
|
|
func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
|
|
mediaID := c.Param("id")
|
|
mediaUUID, err := uuid.Parse(mediaID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
}
|
|
|
|
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"id": uuid.UUID(item.ID.Bytes).String(),
|
|
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
|
|
"title": item.Title,
|
|
"author": textToString(item.Author),
|
|
"isbn": textToString(item.Isbn),
|
|
"description": textToString(item.Description),
|
|
"file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}),
|
|
"file_size": item.FileSize,
|
|
"mime_type": textToString(item.MimeType),
|
|
"cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
|
"series": textToString(item.Series),
|
|
"series_number": item.SeriesNumber,
|
|
"tags": item.Tags,
|
|
"asin": textToString(item.Asin),
|
|
"date_published": item.DatePublished.Time.Format("2006-01-02"),
|
|
"publisher": textToString(item.Publisher),
|
|
"contributors": item.Contributors,
|
|
"language": textToString(item.Language),
|
|
"edition": textToString(item.Edition),
|
|
"page_count": item.PageCount,
|
|
"genre": textToString(item.Genre),
|
|
"created_at": item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
|
"updated_at": item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
|
"format_group": item.FormatGroup,
|
|
"format_mimetype": textToString(item.FormatMimetype),
|
|
// NEW: Reading direction fields
|
|
"manga_type": textToString(item.MangaType),
|
|
"reading_direction": textToString(item.ReadingDirection),
|
|
// NEW: Universal metadata fields
|
|
"series_count": item.SeriesCount,
|
|
"volume": item.Volume,
|
|
"imprint": textToString(item.Imprint),
|
|
"age_rating": textToString(item.AgeRating),
|
|
"web_url": textToString(item.WebUrl),
|
|
"metadata_notes": textToString(item.MetadataNotes),
|
|
"community_rating": numericToFloat(item.CommunityRating),
|
|
// NEW: Comic-specific fields
|
|
"story_arc": textToString(item.StoryArc),
|
|
"is_black_and_white": item.IsBlackAndWhite,
|
|
"alternate_info": jsonBytesToMap(item.AlternateInfo),
|
|
"scan_information": textToString(item.ScanInformation),
|
|
"summary": textToString(item.Summary),
|
|
"library_type_name": textPtrToString(item.LibraryTypeName),
|
|
})
|
|
}
|
|
|
|
// CreateMediaRating handles POST /api/media-items/:id/rating
|
|
func (mh *MediaHandler) CreateMediaRating(c *echo.Context) error {
|
|
userID := c.Get("user_id").(string)
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
|
}
|
|
|
|
mediaID := c.Param("id")
|
|
mediaUUID, err := uuid.Parse(mediaID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
}
|
|
|
|
var req struct {
|
|
Rating int32 `json:"rating" validate:"required,min=1,max=10"`
|
|
}
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
rating, err := mh.db.CreateMediaRating(c.Request().Context(), database.CreateMediaRatingParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
Rating: req.Rating,
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusCreated, rating)
|
|
}
|
|
|
|
// GetMediaRating handles GET /api/media-items/:id/rating
|
|
func (mh *MediaHandler) GetMediaRating(c *echo.Context) error {
|
|
userID := c.Get("user_id").(string)
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
|
}
|
|
|
|
mediaID := c.Param("id")
|
|
mediaUUID, err := uuid.Parse(mediaID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
}
|
|
|
|
rating, err := mh.db.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"rating": nil})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, rating)
|
|
}
|
|
|
|
// UpdateMediaRating handles PUT /api/media-items/:id/rating
|
|
func (mh *MediaHandler) UpdateMediaRating(c *echo.Context) error {
|
|
return mh.CreateMediaRating(c)
|
|
}
|
|
|
|
// DeleteMediaRating handles DELETE /api/media-items/:id/rating
|
|
func (mh *MediaHandler) DeleteMediaRating(c *echo.Context) error {
|
|
userID := c.Get("user_id").(string)
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
|
}
|
|
|
|
mediaID := c.Param("id")
|
|
mediaUUID, err := uuid.Parse(mediaID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
}
|
|
|
|
err = mh.db.DeleteMediaRating(c.Request().Context(), database.DeleteMediaRatingParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "rating deleted"})
|
|
}
|
|
|
|
// GetMediaReadingProgress handles GET /api/media-items/:id/progress
|
|
func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
|
|
userID := c.Get("user_id").(string)
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
|
}
|
|
|
|
mediaID := c.Param("id")
|
|
mediaUUID, err := uuid.Parse(mediaID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
}
|
|
|
|
progress, err := mh.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"current_page": 0,
|
|
"total_pages": nil,
|
|
})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
resp := map[string]interface{}{
|
|
"id": progress.ID,
|
|
"media_item_id": progress.MediaItemID,
|
|
"user_id": progress.UserID,
|
|
"current_page": progress.CurrentPage,
|
|
"total_pages": progress.TotalPages,
|
|
"last_read_at": progress.LastReadAt,
|
|
"percentage": progress.Percentage,
|
|
"character_offset": progress.CharacterOffset,
|
|
"epubcfi": progress.Epubcfi,
|
|
"chapter": progress.Chapter,
|
|
"chapter_progress": progress.ChapterProgress,
|
|
"format_group": progress.FormatGroup,
|
|
"total_characters": progress.TotalCharacters,
|
|
"chapter_count": progress.ChapterCount,
|
|
"last_sync_device": progress.LastSyncDevice,
|
|
"last_sync_source": progress.LastSyncSource,
|
|
"last_sync_timestamp": progress.LastSyncTimestamp,
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// UpdateMediaReadingProgress handles PUT /api/media-items/:id/progress
|
|
func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
|
userID := c.Get("user_id").(string)
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
|
}
|
|
|
|
mediaID := c.Param("id")
|
|
mediaUUID, err := uuid.Parse(mediaID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
}
|
|
|
|
var req struct {
|
|
CurrentPage *int32 `json:"current_page"`
|
|
TotalPages *int32 `json:"total_pages"`
|
|
Epubcfi *string `json:"epubcfi"`
|
|
ContextText *string `json:"context_text"`
|
|
Percentage *float64 `json:"percentage"`
|
|
Chapter *int `json:"chapter"`
|
|
ChapterProgress *float64 `json:"chapter_progress"`
|
|
CharacterOffset *int64 `json:"character_offset"`
|
|
ReadingMode *string `json:"reading_mode"`
|
|
ZoomLevel *float64 `json:"zoom_level"`
|
|
ScrollX *float64 `json:"scroll_position_x"`
|
|
ScrollY *float64 `json:"scroll_position_y"`
|
|
}
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
|
|
if mh.progressSvc != nil {
|
|
saveReq := wsync.SaveProgressRequest{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
Source: "web",
|
|
DeviceID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
Percentage: req.Percentage,
|
|
Epubcfi: req.Epubcfi,
|
|
ContextText: req.ContextText,
|
|
CharacterOffset: req.CharacterOffset,
|
|
Chapter: req.Chapter,
|
|
ChapterProgress: req.ChapterProgress,
|
|
CurrentPage: nil,
|
|
TotalPages: nil,
|
|
ZoomLevel: req.ZoomLevel,
|
|
ScrollX: req.ScrollX,
|
|
ScrollY: req.ScrollY,
|
|
ReadingMode: req.ReadingMode,
|
|
DeviceType: "web",
|
|
DeviceName: "Web",
|
|
Broadcast: true,
|
|
}
|
|
if req.CurrentPage != nil {
|
|
cp := int(*req.CurrentPage)
|
|
saveReq.CurrentPage = &cp
|
|
}
|
|
if req.TotalPages != nil {
|
|
tp := int(*req.TotalPages)
|
|
saveReq.TotalPages = &tp
|
|
}
|
|
|
|
if req.Percentage != nil && *req.Percentage < 0.005 {
|
|
existing, err := mh.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
})
|
|
if err == nil && existing.Percentage.Valid && existing.Percentage.Float64 > 0.01 {
|
|
return c.JSON(http.StatusOK, map[string]string{"status": "ignored"})
|
|
}
|
|
}
|
|
|
|
result, err := mh.progressSvc.SaveProgress(c.Request().Context(), saveReq)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
percentage := 0.0
|
|
if req.Percentage != nil {
|
|
percentage = *req.Percentage
|
|
}
|
|
epubcfi := ""
|
|
if req.Epubcfi != nil {
|
|
epubcfi = *req.Epubcfi
|
|
}
|
|
currentPage := int32(0)
|
|
if req.CurrentPage != nil {
|
|
currentPage = *req.CurrentPage
|
|
}
|
|
totalPages := int32(0)
|
|
if req.TotalPages != nil {
|
|
totalPages = *req.TotalPages
|
|
}
|
|
|
|
progress, err := mh.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
|
CharacterOffset: pgtype.Int8{Valid: false},
|
|
Epubcfi: pgtype.Text{String: epubcfi, Valid: epubcfi != ""},
|
|
Chapter: pgtype.Int4{Valid: false},
|
|
ChapterProgress: pgtype.Float8{Valid: false},
|
|
ViewportX: pgtype.Float8{Valid: false},
|
|
ViewportY: pgtype.Float8{Valid: false},
|
|
ZoomLevel: pgtype.Float8{Valid: false},
|
|
ScrollPositionX: pgtype.Float8{Valid: false},
|
|
ScrollPositionY: pgtype.Float8{Valid: false},
|
|
PanelNumber: pgtype.Int4{Valid: false},
|
|
ReadingMode: pgtype.Text{Valid: false},
|
|
LastSyncDevice: pgtype.Text{String: "web", Valid: true},
|
|
LastSyncSource: pgtype.Text{String: "web", Valid: true},
|
|
CurrentPage: pgtype.Int4{Int32: currentPage, Valid: true},
|
|
TotalPages: pgtype.Int4{Int32: totalPages, Valid: totalPages > 0},
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, progress)
|
|
}
|
|
|
|
// DeleteMediaReadingProgress handles DELETE /api/media-items/:id/progress
|
|
func (mh *MediaHandler) DeleteMediaReadingProgress(c *echo.Context) error {
|
|
userID := c.Get("user_id").(string)
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
|
}
|
|
|
|
mediaID := c.Param("id")
|
|
mediaUUID, err := uuid.Parse(mediaID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
}
|
|
|
|
err = mh.db.DeleteReadingProgress(c.Request().Context(), database.DeleteReadingProgressParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "reading progress deleted"})
|
|
}
|
|
|
|
// CreateMediaItem handles POST /api/media-items (admin only)
|
|
func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
if user.Role != "admin" {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
|
}
|
|
|
|
var req CreateMediaItemRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
if len(req.Tags) > 0 {
|
|
req.Tags = utils.NormalizeTags(req.Tags)
|
|
}
|
|
|
|
if len(req.Contributors) > 0 {
|
|
req.Contributors = utils.NormalizeContributors(req.Contributors)
|
|
}
|
|
|
|
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
|
|
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
|
|
|
|
// Validate and normalize ISBN
|
|
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
|
|
if err != nil && req.ISBN != "" {
|
|
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"})
|
|
}
|
|
|
|
// Use normalized ISBN if valid, otherwise empty string
|
|
isbnValue := normalizedISBN
|
|
if err != nil {
|
|
isbnValue = ""
|
|
}
|
|
|
|
_, err = mh.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
// Validate library has folders before allowing media items
|
|
hasFolders, err := mh.libraryService.HasFolders(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to validate library folders"})
|
|
}
|
|
if !hasFolders {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Cannot add media items to a library with no folders. Please add at least one folder to the library first.",
|
|
})
|
|
}
|
|
|
|
mediaItemID, err := mh.db.CreateMediaItem(c.Request().Context(), database.CreateMediaItemParams{
|
|
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
|
|
Title: req.Title,
|
|
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
|
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
|
|
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
|
FilePath: req.FilePath,
|
|
FileSize: pgtype.Int8{Int64: req.FileSize, Valid: req.FileSize > 0},
|
|
MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""},
|
|
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
|
|
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
|
|
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
|
Tags: req.Tags,
|
|
TagsSearch: tagsSearch,
|
|
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
|
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
|
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
|
Contributors: req.Contributors,
|
|
ContributorsSearch: contributorsSearch,
|
|
AddedByAdminID: user.ID,
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaItemID.ID.Bytes, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusCreated, item)
|
|
}
|
|
|
|
// UpdateMediaItem handles PUT /api/media-items/:id (admin only)
|
|
func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
if user.Role != "admin" {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
|
}
|
|
|
|
mediaID := c.Param("id")
|
|
mediaUUID, err := uuid.Parse(mediaID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
}
|
|
|
|
var req UpdateMediaItemRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
if len(req.Tags) > 0 {
|
|
req.Tags = utils.NormalizeTags(req.Tags)
|
|
}
|
|
|
|
if len(req.Contributors) > 0 {
|
|
req.Contributors = utils.NormalizeContributors(req.Contributors)
|
|
}
|
|
|
|
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
|
|
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
|
|
|
|
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
|
|
if err != nil && req.ISBN != "" {
|
|
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"})
|
|
}
|
|
|
|
isbnValue := normalizedISBN
|
|
if err != nil {
|
|
isbnValue = ""
|
|
}
|
|
|
|
if req.CoverAction == "" {
|
|
req.CoverAction = "keep"
|
|
}
|
|
|
|
existing, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
|
}
|
|
|
|
coverPath := existing.CoverImagePath.String
|
|
|
|
if req.CoverAction == "remove" {
|
|
coverPath = ""
|
|
} else if req.CoverAction == "upload" {
|
|
file, err := c.FormFile("cover_file")
|
|
if err == nil {
|
|
savedPath, err := mh.saveCoverImage(*c, mediaUUID, file)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save cover image"})
|
|
}
|
|
coverPath = savedPath
|
|
}
|
|
}
|
|
|
|
var alternateInfoBytes []byte
|
|
if req.AlternateInfo != "" {
|
|
alternateInfoBytes = []byte(req.AlternateInfo)
|
|
}
|
|
|
|
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: coverPath, Valid: coverPath != ""},
|
|
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,
|
|
Language: pgtype.Text{String: req.Language, Valid: req.Language != ""},
|
|
Edition: pgtype.Text{String: req.Edition, Valid: req.Edition != ""},
|
|
PageCount: pgtype.Int4{Int32: req.PageCount, Valid: req.PageCount > 0},
|
|
Genre: pgtype.Text{String: req.Genre, Valid: req.Genre != ""},
|
|
CopyrightYear: pgtype.Int4{Int32: req.CopyrightYear, Valid: req.CopyrightYear > 0},
|
|
GoodreadsID: pgtype.Text{String: req.GoodreadsID, Valid: req.GoodreadsID != ""},
|
|
OpenlibraryID: pgtype.Text{String: req.OpenlibraryID, Valid: req.OpenlibraryID != ""},
|
|
GoogleBooksID: pgtype.Text{String: req.GoogleBooksID, Valid: req.GoogleBooksID != ""},
|
|
MangaType: pgtype.Text{String: req.MangaType, Valid: req.MangaType != ""},
|
|
ReadingDirection: pgtype.Text{String: req.ReadingDirection, Valid: req.ReadingDirection != ""},
|
|
SeriesCount: pgtype.Int4{Int32: req.SeriesCount, Valid: req.SeriesCount > 0},
|
|
Volume: pgtype.Int4{Int32: req.Volume, Valid: req.Volume > 0},
|
|
Imprint: pgtype.Text{String: req.Imprint, Valid: req.Imprint != ""},
|
|
AgeRating: pgtype.Text{String: req.AgeRating, Valid: req.AgeRating != ""},
|
|
WebUrl: pgtype.Text{String: req.WebURL, Valid: req.WebURL != ""},
|
|
MetadataNotes: pgtype.Text{String: req.MetadataNotes, Valid: req.MetadataNotes != ""},
|
|
CommunityRating: pgtype.Float8{Float64: req.CommunityRating, Valid: req.CommunityRating > 0},
|
|
StoryArc: pgtype.Text{String: req.StoryArc, Valid: req.StoryArc != ""},
|
|
IsBlackAndWhite: pgtype.Bool{Bool: req.IsBlackAndWhite, Valid: req.IsBlackAndWhite},
|
|
AlternateInfo: alternateInfoBytes,
|
|
ScanInformation: pgtype.Text{String: req.ScanInformation, Valid: req.ScanInformation != ""},
|
|
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
if c.Request().Header.Get("HX-Request") == "true" {
|
|
c.Response().Header().Set("HX-Redirect", "/media/"+mediaID)
|
|
}
|
|
|
|
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()})
|
|
}
|
|
|
|
var note database.MediaNotes
|
|
if mh.annotationSvc != nil {
|
|
result, err := mh.annotationSvc.SaveNote(c.Request().Context(), wsync.SaveNoteRequest{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
Content: req.Content,
|
|
Position: req.Position,
|
|
Source: "web",
|
|
ModifiedAt: time.Now(),
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
note = result.Note
|
|
} else {
|
|
var err error
|
|
note, err = mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
Content: req.Content,
|
|
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusCreated, note)
|
|
}
|
|
|
|
// GetMediaNote handles GET /api/media-items/:id/notes/:noteId
|
|
func (mh *MediaHandler) GetMediaNote(c *echo.Context) error {
|
|
noteID := c.Param("noteId")
|
|
noteUUID, err := uuid.Parse(noteID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
|
}
|
|
|
|
note, err := mh.db.GetMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "note not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, note)
|
|
}
|
|
|
|
// UpdateMediaNote handles PUT /api/media-items/:id/notes/:noteId
|
|
func (mh *MediaHandler) UpdateMediaNote(c *echo.Context) error {
|
|
noteID := c.Param("noteId")
|
|
noteUUID, err := uuid.Parse(noteID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
|
}
|
|
|
|
var req UpdateMediaNoteRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
note, err := mh.db.UpdateMediaNote(c.Request().Context(), database.UpdateMediaNoteParams{
|
|
ID: pgtype.UUID{Bytes: noteUUID, Valid: true},
|
|
Content: req.Content,
|
|
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, note)
|
|
}
|
|
|
|
// DeleteMediaNote handles DELETE /api/media-items/:id/notes/:noteId
|
|
func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error {
|
|
noteID := c.Param("noteId")
|
|
noteUUID, err := uuid.Parse(noteID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
|
}
|
|
|
|
if mh.annotationSvc != nil {
|
|
err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
|
} else {
|
|
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
|
|
}
|
|
|
|
pgMediaID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
|
|
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
|
|
|
if mh.annotationSvc != nil {
|
|
result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
|
MediaItemID: pgMediaID,
|
|
UserID: pgUserID,
|
|
SelectionText: req.SelectionText,
|
|
StartPosition: req.StartPosition,
|
|
EndPosition: req.EndPosition,
|
|
Color: color,
|
|
Source: "web",
|
|
ModifiedAt: time.Now(),
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusCreated, result.Highlight)
|
|
}
|
|
|
|
highlight, err := mh.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
|
MediaItemID: pgMediaID,
|
|
UserID: pgUserID,
|
|
SelectionText: req.SelectionText,
|
|
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
|
|
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
|
|
Color: pgtype.Text{String: color, Valid: true},
|
|
NoteID: noteUUID,
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusCreated, highlight)
|
|
}
|
|
|
|
// GetMediaHighlight handles GET /api/media-items/:id/highlights/:highlightId
|
|
func (mh *MediaHandler) GetMediaHighlight(c *echo.Context) error {
|
|
highlightID := c.Param("highlightId")
|
|
highlightUUID, err := uuid.Parse(highlightID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
|
|
}
|
|
|
|
highlight, err := mh.db.GetMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, highlight)
|
|
}
|
|
|
|
// UpdateMediaHighlight handles PUT /api/media-items/:id/highlights/:highlightId
|
|
func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
|
|
highlightID := c.Param("highlightId")
|
|
highlightUUID, err := uuid.Parse(highlightID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
|
|
}
|
|
|
|
var req UpdateMediaHighlightRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
var noteUUID pgtype.UUID
|
|
if req.NoteID != "" {
|
|
if noteID, err := uuid.Parse(req.NoteID); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
|
} else {
|
|
noteUUID = pgtype.UUID{Bytes: noteID, Valid: true}
|
|
}
|
|
}
|
|
|
|
color := "#ffff00"
|
|
if req.Color != "" {
|
|
color = req.Color
|
|
}
|
|
|
|
highlight, err := mh.db.UpdateMediaHighlight(c.Request().Context(), database.UpdateMediaHighlightParams{
|
|
ID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
|
|
SelectionText: req.SelectionText,
|
|
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
|
|
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
|
|
Color: pgtype.Text{String: color, Valid: true},
|
|
NoteID: noteUUID,
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, highlight)
|
|
}
|
|
|
|
// DeleteMediaHighlight handles DELETE /api/media-items/:id/highlights/:highlightId
|
|
func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
|
|
highlightID := c.Param("highlightId")
|
|
highlightUUID, err := uuid.Parse(highlightID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
|
|
}
|
|
|
|
pgHighlightID := pgtype.UUID{Bytes: highlightUUID, Valid: true}
|
|
|
|
if mh.annotationSvc != nil {
|
|
if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgHighlightID, "web"); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgHighlightID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
// SearchMediaItems handles GET /api/media-items/search
|
|
// Supports two modes:
|
|
// 1. Autocomplete: author=value, genre=value, etc. → returns field values for dropdowns
|
|
// 2. Search: q=value with optional filters → returns media items
|
|
func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
|
// Safely get user from context
|
|
userID, ok := c.Get("user").(database.Users)
|
|
if !ok {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
|
|
}
|
|
|
|
// Detect autocomplete queries first (author=value, genre=value, etc.)
|
|
if author := c.QueryParam("author"); author != "" {
|
|
return mh.handleFieldValuesSearch(c, userID.ID, "author", author)
|
|
}
|
|
if genre := c.QueryParam("genre"); genre != "" {
|
|
return mh.handleFieldValuesSearch(c, userID.ID, "genre", genre)
|
|
}
|
|
if tags := c.QueryParam("tags"); tags != "" {
|
|
return mh.handleFieldValuesSearch(c, userID.ID, "tags", tags)
|
|
}
|
|
if series := c.QueryParam("series"); series != "" {
|
|
return mh.handleFieldValuesSearch(c, userID.ID, "series", series)
|
|
}
|
|
if language := c.QueryParam("language"); language != "" {
|
|
return mh.handleFieldValuesSearch(c, userID.ID, "language", language)
|
|
}
|
|
|
|
// Handle media item search with filters
|
|
query := c.QueryParam("q")
|
|
libraryID := c.QueryParam("library_id")
|
|
|
|
// Validate library_id if provided
|
|
var libUUID pgtype.UUID
|
|
if libraryID != "" {
|
|
lib, err := uuid.Parse(libraryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
}
|
|
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
|
|
} else {
|
|
libUUID = pgtype.UUID{Valid: false}
|
|
}
|
|
|
|
c.Logger().Info("Search parameters",
|
|
"query", query,
|
|
"library_id", libraryID,
|
|
"libUUID_valid", libUUID.Valid,
|
|
"libUUID_bytes", libUUID.Bytes)
|
|
|
|
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
|
if limit == 0 {
|
|
limit = 50
|
|
}
|
|
offset, _ := strconv.Atoi(c.QueryParam("offset"))
|
|
|
|
// Extract filter parameters
|
|
authorFilter := c.QueryParam("author_filter")
|
|
seriesFilter := c.QueryParam("series_filter")
|
|
genreFilter := c.QueryParam("genre_filter")
|
|
tagsFilter := c.QueryParam("tags_filter")
|
|
languageFilter := c.QueryParam("language_filter")
|
|
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
|
|
yearMax, _ := strconv.Atoi(c.QueryParam("year_max"))
|
|
hasCover := false
|
|
hasCoverValid := false
|
|
if _, exists := c.QueryParams()["has_cover"]; exists {
|
|
hasCover = c.QueryParam("has_cover") == "true"
|
|
hasCoverValid = true
|
|
}
|
|
|
|
// Extract sort parameter
|
|
sortParam := c.QueryParam("sort")
|
|
if sortParam == "" {
|
|
sortParam = "title ASC" // Default sort
|
|
}
|
|
|
|
// Build search params
|
|
params := services.SearchParams{
|
|
UserID: userID.ID,
|
|
LibraryID: libUUID,
|
|
SearchQuery: query,
|
|
AuthorFilter: authorFilter,
|
|
SeriesFilter: seriesFilter,
|
|
GenreFilter: genreFilter,
|
|
TagsFilter: tagsFilter,
|
|
LanguageFilter: languageFilter,
|
|
YearMin: yearMin,
|
|
YearMax: yearMax,
|
|
HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
|
|
Sort: sortParam,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
}
|
|
|
|
c.Logger().Info("SearchMediaItems called",
|
|
"query", query,
|
|
"library_id_provided", libraryID != "",
|
|
"library_id_uuid", libUUID.Valid,
|
|
"library_id_bytes", libUUID.Bytes)
|
|
|
|
// Call shared search service
|
|
results, _, err := mh.searchService.ExecuteSearch(c.Request().Context(), params)
|
|
if err != nil {
|
|
c.Logger().Error("search error", "error", err.Error())
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
if len(results) == 0 {
|
|
return c.JSON(http.StatusNotFound, map[string]interface{}{
|
|
"error": "no results found",
|
|
"query": query,
|
|
"results": []interface{}{},
|
|
})
|
|
}
|
|
return c.JSON(http.StatusOK, results)
|
|
}
|
|
|
|
// handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.)
|
|
// Returns distinct field values with counts and similarity scores for dropdown population
|
|
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {
|
|
libraryID := c.QueryParam("library_id")
|
|
if libraryID == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id is required"})
|
|
}
|
|
|
|
libUUID, err := uuid.Parse(libraryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
}
|
|
|
|
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
|
if limit == 0 {
|
|
limit = 50
|
|
}
|
|
offset, _ := strconv.Atoi(c.QueryParam("offset"))
|
|
|
|
params := services.FieldSearchParams{
|
|
UserID: userID,
|
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
|
FieldType: fieldType,
|
|
SearchQuery: searchQuery,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
}
|
|
|
|
results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params)
|
|
if err != nil {
|
|
c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"results": results,
|
|
"total": len(results),
|
|
})
|
|
}
|
|
|
|
// getFullFilePath returns the absolute filesystem path for a media item
|
|
// Uses LibraryService for resolution (one source of truth)
|
|
func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
|
|
if relativePath == "" {
|
|
return "", fmt.Errorf("no file path")
|
|
}
|
|
|
|
// Check if already absolute (backward compatibility)
|
|
if filepath.IsAbs(relativePath) {
|
|
return relativePath, nil
|
|
}
|
|
|
|
// Use service for resolution (one source of truth)
|
|
return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath)
|
|
}
|
|
|
|
// ServeFile serves files (covers or books) via /uploads/library-{id}/path
|
|
// Requires JWT authentication
|
|
func (mh *MediaHandler) ServeFile(c *echo.Context) error {
|
|
// URL format: /uploads/library-{libraryID}/{relativePath}
|
|
// Get library ID directly from route parameter
|
|
libraryIDStr := c.Param("id")
|
|
libraryUUID, err := uuid.Parse(libraryIDStr)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library ID"})
|
|
}
|
|
|
|
// Get remaining path from URL
|
|
rawPath := c.Param("*")
|
|
relativePath, err := url.QueryUnescape(rawPath)
|
|
if err != nil {
|
|
relativePath = rawPath
|
|
}
|
|
|
|
if relativePath == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"})
|
|
}
|
|
|
|
// Resolve using service
|
|
fullPath, err := mh.getFullFilePath(c.Request().Context(), pgtype.UUID{Bytes: libraryUUID, Valid: true}, relativePath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
|
}
|
|
|
|
// Check if file exists
|
|
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(fullPath))
|
|
contentType := services.MimeTypes[ext]
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
|
|
c.Response().Header().Set("Content-Type", contentType)
|
|
c.Response().Header().Set("Cache-Control", "public, max-age=86400")
|
|
http.ServeFile(c.Response(), c.Request(), fullPath)
|
|
return nil
|
|
}
|
|
|
|
// numericToFloat converts pgtype.Numeric to float64, returning 0 if invalid
|
|
func numericToFloat(n pgtype.Float8) float64 {
|
|
if n.Valid {
|
|
return n.Float64
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// jsonBytesToMap converts []byte JSONB to map[string]interface{}, returning nil if invalid
|
|
func jsonBytesToMap(b []byte) map[string]interface{} {
|
|
if len(b) == 0 {
|
|
return nil
|
|
}
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(b, &result); err != nil {
|
|
return nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (mh *MediaHandler) saveCoverImage(c echo.Context, mediaUUID uuid.UUID, file *multipart.FileHeader) (string, error) {
|
|
src, err := file.Open()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to open uploaded file: %w", err)
|
|
}
|
|
defer src.Close()
|
|
|
|
imageData, err := io.ReadAll(src)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read uploaded file: %w", err)
|
|
}
|
|
|
|
if len(imageData) < 512 {
|
|
return "", fmt.Errorf("file too small to be a valid image")
|
|
}
|
|
|
|
contentType := http.DetectContentType(imageData)
|
|
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {
|
|
return "", fmt.Errorf("invalid image type: %s", contentType)
|
|
}
|
|
|
|
mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
|
if err != nil {
|
|
return "", fmt.Errorf("media item not found: %w", err)
|
|
}
|
|
|
|
relativeFilePath := mediaItem.FilePath
|
|
if relativeFilePath == "" {
|
|
return "", fmt.Errorf("media item has no file path")
|
|
}
|
|
|
|
coverRelPath := relativeFilePath + ".cover.jpg"
|
|
|
|
coverFullPath, err := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverRelPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to resolve cover path: %w", err)
|
|
}
|
|
|
|
coverDir := filepath.Dir(coverFullPath)
|
|
if err := os.MkdirAll(coverDir, 0755); err != nil {
|
|
return "", fmt.Errorf("failed to create cover directory: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(coverFullPath, imageData, 0644); err != nil {
|
|
return "", fmt.Errorf("failed to write cover file: %w", err)
|
|
}
|
|
|
|
return coverRelPath, nil
|
|
}
|