Covers the copy-then-delete-later workflow: a user copies books to a new
library, deletes the originals, and ends up with an active copy in the new
library plus an archived twin holding the real reading history. Until now
those twins could only be purged (destroying the history) or restored
(showing a permanently broken entry).
- POST /api/media-items/:id/merge (admin only, body {target_id}):
validates the source is archived/missing, the target is active, and
both share the same file_sha256; then re-parents every child row onto
the target via the existing reparent_media_item_children function (the
same machinery as hash-conflict resolution) and deletes the source row.
Per-user collisions keep the active copy's data, mirroring that flow.
Affected data: reading progress, speed, ratings, highlights, notes,
bookmarks (including tombstoned deleted-annotation history), formats,
collections, kobo shelves/entitlements, sync rows, panel data,
processing issues, and device aliases.
- GET /admin/archived: ListHiddenMediaItems' match_* columns surface each
row's best active twin; rows with a twin get a confirm-guarded "Merge"
button (data-merge-source/-target) next to Restore/Delete, wired in
web/src/admin.ts like the existing unarchive/delete handlers.
- templates.ArchivedItem gains MatchID/MatchTitle/MatchLibraryName;
frontend.go populates them from the listing row.
After a merge the archived row is gone, so the retention purge can never
destroy the merged data. Deleted-annotation tombstones carry over and
remain restorable from the target book's "recently deleted" history.
2402 lines
87 KiB
Go
2402 lines
87 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/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:"max=1000"`
|
|
EndPosition string `json:"end_position" validate:"max=1000"`
|
|
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
|
|
EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"`
|
|
Color string `json:"color" validate:"omitempty,len=7"`
|
|
NoteText string `json:"note_text" validate:"max=10000"`
|
|
NoteID string `json:"note_id"`
|
|
PercentageStart float64 `json:"percentage_start"`
|
|
PercentageEnd float64 `json:"percentage_end"`
|
|
ChapterReference int32 `json:"chapter_reference"`
|
|
}
|
|
|
|
// 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:"max=1000"`
|
|
EndPosition string `json:"end_position" validate:"max=1000"`
|
|
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
|
|
EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"`
|
|
Color string `json:"color" validate:"omitempty,len=7"`
|
|
NoteText string `json:"note_text" validate:"max=10000"`
|
|
NoteID string `json:"note_id"`
|
|
PercentageStart float64 `json:"percentage_start"`
|
|
PercentageEnd float64 `json:"percentage_end"`
|
|
ChapterReference int32 `json:"chapter_reference"`
|
|
}
|
|
|
|
// CreateMediaBookmarkRequest represents the request for creating a media bookmark
|
|
type CreateMediaBookmarkRequest struct {
|
|
Title string `json:"title" validate:"required,min=1,max=255"`
|
|
Position string `json:"position" validate:"max=100"`
|
|
Notes string `json:"notes" validate:"max=10000"`
|
|
CfiPosition string `json:"cfi_position" validate:"max=255"`
|
|
PageNumber int32 `json:"page_number"`
|
|
ChapterNumber int32 `json:"chapter_number"`
|
|
Percentage float64 `json:"percentage"`
|
|
ChapterReference int32 `json:"chapter_reference"`
|
|
// Origin labels the creating client for display ("android", "web").
|
|
// Optional: defaults to "web" for browser callers.
|
|
Origin string `json:"origin" validate:"omitempty,max=30"`
|
|
}
|
|
|
|
// UpdateMediaBookmarkRequest represents the request for updating a media bookmark
|
|
type UpdateMediaBookmarkRequest struct {
|
|
Title string `json:"title" validate:"required,min=1,max=255"`
|
|
Notes string `json:"notes" validate:"max=10000"`
|
|
Position string `json:"position" validate:"max=100"`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
// Bulk edits are user customizations: keep existing overrides and mark
|
|
// each applied update as overridden so scans preserve it.
|
|
overrides := existingMedia.MetadataOverrides
|
|
if update.Updates.Title != nil {
|
|
updateParams.Title = *update.Updates.Title
|
|
overrides = utils.MergeOverrides(overrides, utils.OverrideTitle)
|
|
}
|
|
if update.Updates.Author != nil {
|
|
updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true}
|
|
overrides = utils.MergeOverrides(overrides, utils.OverrideAuthor)
|
|
}
|
|
if update.Updates.Genre != nil {
|
|
updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true}
|
|
overrides = utils.MergeOverrides(overrides, utils.OverrideGenre)
|
|
}
|
|
if update.Updates.Language != nil {
|
|
updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true}
|
|
overrides = utils.MergeOverrides(overrides, utils.OverrideLanguage)
|
|
}
|
|
if len(update.Updates.Tags) > 0 {
|
|
normalizedTags := utils.NormalizeTags(update.Updates.Tags)
|
|
updateParams.Tags = normalizedTags
|
|
tagsSearch := utils.NormalizeTagsSearch(update.Updates.Tags)
|
|
updateParams.TagsSearch = tagsSearch
|
|
overrides = utils.MergeOverrides(overrides, utils.OverrideTags)
|
|
}
|
|
updateParams.MetadataOverrides = overrides
|
|
|
|
_, 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
|
|
coverUploaded := false
|
|
|
|
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
|
|
coverUploaded = true
|
|
}
|
|
}
|
|
|
|
var alternateInfoBytes []byte
|
|
if req.AlternateInfo != "" {
|
|
alternateInfoBytes = []byte(req.AlternateInfo)
|
|
}
|
|
|
|
params := 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 != ""},
|
|
}
|
|
|
|
// Fields the user actually changed become overrides so future scans keep
|
|
// the custom values. An explicit cover upload/removal always overrides.
|
|
overrides := utils.DetectMetadataOverrides(params, existing)
|
|
if req.CoverAction == "remove" || coverUploaded {
|
|
overrides = utils.MergeOverrides(overrides, utils.OverrideCoverImagePath)
|
|
}
|
|
params.MetadataOverrides = overrides
|
|
|
|
item, err := mh.db.UpdateMediaItem(c.Request().Context(), params)
|
|
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)
|
|
}
|
|
|
|
// UnarchiveMediaItem handles POST /api/media-items/:id/unarchive (admin
|
|
// only). Manually restores a hidden/archived item to library visibility
|
|
// without waiting for its file to return. If the file is still gone, the
|
|
// next scan hides it again.
|
|
func (mh *MediaHandler) UnarchiveMediaItem(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"})
|
|
}
|
|
|
|
if err := mh.db.ClearMediaItemArchive(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
// MergeArchivedMediaItem handles POST /api/media-items/:id/merge (admin
|
|
// only). Merges an archived/missing item's reading data (progress,
|
|
// annotations, highlights, notes, bookmarks, ratings, collections, kobo
|
|
// shelves) into an active item with identical content (same SHA-256), then
|
|
// deletes the archived row. Covers the copy-then-delete-later workflow: the
|
|
// active copy exists, the archived twin holds the real reading history.
|
|
// Per-user collisions keep the active copy's data, mirroring hash-conflict
|
|
// resolution.
|
|
func (mh *MediaHandler) MergeArchivedMediaItem(c *echo.Context) error {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
if user.Role != "admin" {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
|
}
|
|
|
|
sourceUUID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
}
|
|
|
|
var req struct {
|
|
TargetID string `json:"target_id"`
|
|
}
|
|
if err := c.Bind(&req); err != nil || req.TargetID == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "target_id is required"})
|
|
}
|
|
targetUUID, err := uuid.Parse(req.TargetID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid target_id"})
|
|
}
|
|
if sourceUUID == targetUUID {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot merge an item into itself"})
|
|
}
|
|
|
|
ctx := c.Request().Context()
|
|
source, err := mh.db.GetMediaItem(ctx, pgtype.UUID{Bytes: sourceUUID, Valid: true})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "archived item not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
if !source.ArchivedAt.Valid && source.MissingScanCount == 0 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "source item is not archived or missing"})
|
|
}
|
|
|
|
target, err := mh.db.GetMediaItem(ctx, pgtype.UUID{Bytes: targetUUID, Valid: true})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "target item not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
if target.ArchivedAt.Valid || target.MissingScanCount > 0 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "target item is not active"})
|
|
}
|
|
|
|
if !source.FileSha256.Valid || source.FileSha256.String == "" ||
|
|
source.FileSha256.String != target.FileSha256.String {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "items do not share the same content hash"})
|
|
}
|
|
|
|
if err := mh.db.ReparentMediaItemChildren(ctx, database.ReparentMediaItemChildrenParams{
|
|
Column1: pgtype.UUID{Bytes: targetUUID, Valid: true},
|
|
Column2: pgtype.UUID{Bytes: sourceUUID, Valid: true},
|
|
}); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to merge reading data: " + err.Error()})
|
|
}
|
|
if err := mh.db.DeleteMediaItem(ctx, pgtype.UUID{Bytes: sourceUUID, Valid: true}); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to remove archived item: " + err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{
|
|
"message": "Merged reading progress, annotations, and collections into the active item",
|
|
})
|
|
}
|
|
|
|
// PurgeArchivedMediaItems handles POST /api/media-items/purge-archived
|
|
// (admin only). Hard-deletes every archived item (files missing from disk for
|
|
// 2+ scans) together with its reading history. The archive retention window
|
|
// eventually does the same automatically; this is the manual bulk escape hatch.
|
|
func (mh *MediaHandler) PurgeArchivedMediaItems(c *echo.Context) error {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
if user.Role != "admin" {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
|
}
|
|
|
|
purged, err := mh.db.PurgeAllArchivedMediaItems(c.Request().Context())
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"purged": len(purged),
|
|
})
|
|
}
|
|
|
|
// RescanMediaItem handles POST /api/media-items/:id/rescan (admin only)
|
|
func (mh *MediaHandler) RescanMediaItem(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"})
|
|
}
|
|
|
|
if _, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); 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()})
|
|
}
|
|
|
|
scanner := services.NewMediaScanner(mh.db)
|
|
defer scanner.Close()
|
|
|
|
// reset_overrides=true discards user customizations first, returning the
|
|
// item to pure scanned defaults (the "Reset to Scanned" action).
|
|
resetOverrides := c.QueryParam("reset_overrides") == "true"
|
|
|
|
if err := scanner.RescanMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}, resetOverrides); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
item, err := mh.db.GetMediaItem(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.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,
|
|
EpubcfiStart: req.EpubcfiStart,
|
|
EpubcfiEnd: req.EpubcfiEnd,
|
|
Color: color,
|
|
NoteText: req.NoteText,
|
|
PercentageStart: req.PercentageStart,
|
|
PercentageEnd: req.PercentageEnd,
|
|
ChapterReference: req.ChapterReference,
|
|
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
|
|
}
|
|
|
|
// Prefer the sync-aware path: the same selection text + CFI resolves to
|
|
// the same dedup key, so this performs an LWW update of the existing row
|
|
// (including note_text and CFI columns the plain query cannot touch).
|
|
if mh.annotationSvc != nil {
|
|
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"})
|
|
}
|
|
result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
SelectionText: req.SelectionText,
|
|
StartPosition: req.StartPosition,
|
|
EndPosition: req.EndPosition,
|
|
EpubcfiStart: req.EpubcfiStart,
|
|
EpubcfiEnd: req.EpubcfiEnd,
|
|
Color: color,
|
|
NoteText: req.NoteText,
|
|
PercentageStart: req.PercentageStart,
|
|
PercentageEnd: req.PercentageEnd,
|
|
ChapterReference: req.ChapterReference,
|
|
Source: "web",
|
|
ModifiedAt: time.Now(),
|
|
// The PUT targets this exact row (from the URL): identity must
|
|
// not be re-derived from content — a device echo has usually
|
|
// rewritten the stored CFI to point shape, so the computed key
|
|
// would miss and mint a duplicate beside the edited row.
|
|
HighlightID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, result.Highlight)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// GetMediaBookmarks handles GET /api/media-items/:id/bookmarks
|
|
func (mh *MediaHandler) GetMediaBookmarks(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"})
|
|
}
|
|
|
|
bookmarks, err := mh.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
|
|
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, bookmarks)
|
|
}
|
|
|
|
// CreateMediaBookmark handles POST /api/media-items/:id/bookmarks
|
|
func (mh *MediaHandler) CreateMediaBookmark(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 CreateMediaBookmarkRequest
|
|
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()})
|
|
}
|
|
|
|
// The sync-aware path (dedup + LWW + tombstones) is preferred; fall back
|
|
// to the plain query when the service isn't wired (e.g. some tests).
|
|
if mh.annotationSvc != nil {
|
|
origin := req.Origin
|
|
if origin == "" {
|
|
origin = "web"
|
|
}
|
|
result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
Title: req.Title,
|
|
Position: req.Position,
|
|
Notes: req.Notes,
|
|
PageNumber: req.PageNumber,
|
|
ChapterNumber: req.ChapterNumber,
|
|
CFIPosition: req.CfiPosition,
|
|
PercentageLoc: req.Percentage,
|
|
ChapterReference: req.ChapterReference,
|
|
Source: "web",
|
|
OriginSource: origin,
|
|
ModifiedAt: time.Now(),
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusCreated, result.Bookmark)
|
|
}
|
|
|
|
bookmark, err := mh.db.CreateMediaBookmark(c.Request().Context(), database.CreateMediaBookmarkParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
PageNumber: pgtype.Int4{Int32: req.PageNumber, Valid: req.PageNumber > 0},
|
|
ChapterNumber: pgtype.Int4{Int32: req.ChapterNumber, Valid: req.ChapterNumber > 0},
|
|
CfiPosition: pgtype.Text{String: req.CfiPosition, Valid: req.CfiPosition != ""},
|
|
Title: req.Title,
|
|
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
|
Notes: pgtype.Text{String: req.Notes, Valid: req.Notes != ""},
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusCreated, bookmark)
|
|
}
|
|
|
|
// UpdateMediaBookmark handles PUT /api/media-items/:id/bookmarks/:bookmarkId
|
|
func (mh *MediaHandler) UpdateMediaBookmark(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"})
|
|
}
|
|
|
|
bookmarkID := c.Param("bookmarkId")
|
|
bookmarkUUID, err := uuid.Parse(bookmarkID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid bookmark id"})
|
|
}
|
|
|
|
var req UpdateMediaBookmarkRequest
|
|
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()})
|
|
}
|
|
|
|
bookmark, err := mh.db.UpdateMediaBookmark(c.Request().Context(), database.UpdateMediaBookmarkParams{
|
|
ID: pgtype.UUID{Bytes: bookmarkUUID, Valid: true},
|
|
Title: req.Title,
|
|
Notes: pgtype.Text{String: req.Notes, Valid: req.Notes != ""},
|
|
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
|
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, bookmark)
|
|
}
|
|
|
|
// DeleteMediaBookmark handles DELETE /api/media-items/:id/bookmarks/:bookmarkId
|
|
func (mh *MediaHandler) DeleteMediaBookmark(c *echo.Context) error {
|
|
bookmarkID := c.Param("bookmarkId")
|
|
bookmarkUUID, err := uuid.Parse(bookmarkID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid bookmark id"})
|
|
}
|
|
|
|
pgBookmarkID := pgtype.UUID{Bytes: bookmarkUUID, Valid: true}
|
|
|
|
if mh.annotationSvc != nil {
|
|
if err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgBookmarkID, "web"); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
if err := mh.db.DeleteMediaBookmark(c.Request().Context(), pgBookmarkID); 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{}{},
|
|
})
|
|
}
|
|
for i := range results {
|
|
resolved := utils.ResolveMediaURL(results[i].LibraryID, results[i].CoverImagePath)
|
|
results[i].CoverImagePath = pgtype.Text{String: resolved, Valid: resolved != ""}
|
|
}
|
|
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 stored library files (covers and books).
|
|
//
|
|
// Two URL forms funnel into this handler:
|
|
//
|
|
// /uploads/library-{libraryID}/{relativePath} (covers, reader files)
|
|
// /api/media-items/{mediaItemID}/download (explicit book download)
|
|
//
|
|
// Both require JWT authentication and that the authenticated user can see
|
|
// the library owning the file - library visibility is the permission gate.
|
|
func (mh *MediaHandler) ServeFile(c *echo.Context) error {
|
|
var libraryUUID pgtype.UUID
|
|
var relativePath string
|
|
|
|
rawPath := c.Param("*")
|
|
if rawPath != "" {
|
|
// Path form: /uploads/library-{libraryID}/{relativePath}
|
|
libraryIDStr := c.Param("id")
|
|
parsed, err := uuid.Parse(libraryIDStr)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library ID"})
|
|
}
|
|
libraryUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
|
|
relativePath, err = url.QueryUnescape(rawPath)
|
|
if err != nil {
|
|
relativePath = rawPath
|
|
}
|
|
|
|
if relativePath == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"})
|
|
}
|
|
} else {
|
|
// Item form: /api/media-items/{mediaItemID}/download
|
|
itemUUID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item ID"})
|
|
}
|
|
|
|
mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: itemUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
|
}
|
|
|
|
if !mediaItem.LibraryID.Valid || mediaItem.FilePath == "" {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found"})
|
|
}
|
|
|
|
libraryUUID = mediaItem.LibraryID
|
|
relativePath = mediaItem.FilePath
|
|
|
|
// Explicit download endpoint: suggest saving instead of inline display.
|
|
filename := strings.Map(func(r rune) rune {
|
|
if r == '"' || r == '\\' || r == '/' {
|
|
return -1
|
|
}
|
|
return r
|
|
}, filepath.Base(relativePath))
|
|
c.Response().Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
|
}
|
|
|
|
// Library visibility gate: the library is what grants permission to
|
|
// see and download media.
|
|
user := c.Get("user").(database.Users)
|
|
visibleLibraries, err := mh.libraryService.GetUserVisibleLibraries(c.Request().Context(), user.ID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
|
|
}
|
|
libraryVisible := false
|
|
for _, lib := range visibleLibraries {
|
|
if lib.ID.Valid && lib.ID.Bytes == libraryUUID.Bytes {
|
|
libraryVisible = true
|
|
break
|
|
}
|
|
}
|
|
if !libraryVisible {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
|
}
|
|
|
|
// Resolve using service
|
|
fullPath, err := mh.getFullFilePath(c.Request().Context(), libraryUUID, 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")
|
|
}
|
|
|
|
// Custom covers live at a dedicated sidecar path (distinct from the
|
|
// scanner-generated {file}.cover.jpg) so scans can never overwrite a
|
|
// user-uploaded cover and the metadata_overrides set can protect it.
|
|
ext := ".jpg"
|
|
switch contentType {
|
|
case "image/png":
|
|
ext = ".png"
|
|
case "image/webp":
|
|
ext = ".webp"
|
|
}
|
|
coverRelPath := relativeFilePath + ".custom_cover" + ext
|
|
|
|
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
|
|
}
|