H3 census bugs #2 and #3: both web PUTs used plain queries that bypassed the sync-aware layer entirely — no LWW, no source bookkeeping, and locator fields the client couldn't even send. The highlight PUT was the only one already routed (via HighlightID); the other two now mirror it: - SaveBookmarkRequest gains BookmarkID, SaveNoteRequest gains NoteID: id-targeted resolution reads the exact row, verifies ownership, LWWs under its stored dedup key, and resurrects tombstones when the edit is newer (same doctrine and comment structure as the highlight path). - Fields the PUT omits are coalesced from the stored row before the full-column sync update — the legacy web PUT sends only title/notes/position, and zeroing cfi/percentage/page on every edit would have been data loss. Verified both directions by TestH3_BookmarkPUTPreservesLocators (omitted survives, explicit lands). - The bookmark PUT's wire DTO gains the create endpoint's locator keys as optional fields (documented additive request keys in contract_pins.md); the note PUT keeps its content+position wire. UpdateMediaBookmark/UpdateMediaNote plain queries are now uncalled (the generated queries stay until the H4 query sweep).
2669 lines
98 KiB
Go
2669 lines
98 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"`
|
|
// Audio editorial facts (A2a)
|
|
Narrators []string `json:"narrators"`
|
|
Abridged bool `json:"abridged"`
|
|
}
|
|
|
|
// 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"`
|
|
// Audiobook editorial facts (A2a): narrators verbatim display order;
|
|
// abridged as the Libation/Audible flag.
|
|
Narrators []string `form:"narrators" json:"narrators"`
|
|
Abridged bool `form:"abridged" json:"abridged"`
|
|
}
|
|
|
|
// CreateMediaNoteRequest represents the request for creating a media note
|
|
type CreateMediaNoteRequest struct {
|
|
Content string `json:"content" validate:"required,min=1,max=100000"`
|
|
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=100000"`
|
|
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=100000"`
|
|
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=100000"`
|
|
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=100000"`
|
|
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=100000"`
|
|
Position string `json:"position" validate:"max=100"`
|
|
// Optional locator fields (H3): the create endpoint has always
|
|
// accepted these; the PUT ignored them until it was routed through
|
|
// the sync service. Zero-valued fields preserve the stored values.
|
|
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"`
|
|
}
|
|
|
|
type MediaHandler struct {
|
|
db *database.Queries
|
|
worker *services.Worker
|
|
libraryService *services.LibraryService
|
|
searchService *services.SearchService
|
|
progressSvc *wsync.ProgressService
|
|
annotationSvc *wsync.AnnotationService
|
|
}
|
|
|
|
// NewMediaHandler requires the shared ProgressService — progress writes
|
|
// must funnel through SaveProgress (the LWW-seeded canonical writer);
|
|
// a nil service has nowhere to fall back to and must not be wired.
|
|
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, progressSvc *wsync.ProgressService, annotationSvc *wsync.AnnotationService, worker ...*services.Worker) *MediaHandler {
|
|
mh := &MediaHandler{
|
|
db: db,
|
|
libraryService: libraryService,
|
|
searchService: services.NewSearchService(db),
|
|
progressSvc: progressSvc,
|
|
annotationSvc: annotationSvc,
|
|
}
|
|
if len(worker) > 0 && worker[0] != nil {
|
|
mh.worker = worker[0]
|
|
}
|
|
return mh
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Storage-down guard: deleting cascades the item's annotations, so
|
|
// refuse while the library storage is unreachable — the file would
|
|
// survive as an orphan and re-import bare on the next scan.
|
|
storage := h.resolveMediaStorage(c.Request().Context(), uuid.UUID(media.LibraryID.Bytes).String(), media.FilePath)
|
|
if !storage.Reachable {
|
|
results = append(results, map[string]interface{}{
|
|
"media_item_id": mediaIDStr,
|
|
"status": "error",
|
|
"error": "storage unreachable — archive the item instead, or retry when the storage is mounted",
|
|
})
|
|
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 storage.AbsolutePath != "" {
|
|
if err := os.Remove(storage.AbsolutePath); 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", storage.AbsolutePath, 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,
|
|
})
|
|
}
|
|
|
|
// visibleLibrarySet loads the requester's visible libraries — membership
|
|
// map for param checks, id slice for SQL ANY() filters.
|
|
func (mh *MediaHandler) visibleLibrarySet(c *echo.Context) (map[string]bool, []pgtype.UUID, error) {
|
|
user, ok := c.Get("user").(database.Users)
|
|
if !ok {
|
|
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
|
}
|
|
libraries, err := mh.libraryService.GetUserVisibleLibraries(c.Request().Context(), user.ID)
|
|
if err != nil {
|
|
return nil, nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to check library access")
|
|
}
|
|
set := make(map[string]bool, len(libraries))
|
|
ids := make([]pgtype.UUID, 0, len(libraries))
|
|
for _, lib := range libraries {
|
|
if !lib.ID.Valid {
|
|
continue
|
|
}
|
|
set[uuid.UUID(lib.ID.Bytes).String()] = true
|
|
ids = append(ids, lib.ID)
|
|
}
|
|
return set, ids, nil
|
|
}
|
|
|
|
// mediaStorageState reports whether the library storage holding a media
|
|
// item's file is currently reachable, and resolves the file's absolute
|
|
// path when it can be located.
|
|
//
|
|
// Why this exists: deleting a media item cascades its annotations, so the
|
|
// delete must never proceed while the storage is unreachable — otherwise
|
|
// a NAS outage turns an admin cleanup into permanent annotation loss
|
|
// (the DB row dies, the orphaned file re-imports bare on the next scan).
|
|
type mediaStorageState struct {
|
|
// Absolute path of the file when located on reachable storage;
|
|
// empty when the file is missing or reachability is undeterminable.
|
|
AbsolutePath string
|
|
// Reachable is false ONLY when the library has folders and every one
|
|
// of them fails to stat — positive evidence the mount is down. When
|
|
// no folders are configured reachability is undeterminable and this
|
|
// is true (delete proceeds; behavior unchanged from before this check).
|
|
Reachable bool
|
|
}
|
|
|
|
func (mh *MediaHandler) resolveMediaStorage(ctx context.Context, libraryID string, relativePath string) mediaStorageState {
|
|
libUUID, err := uuid.Parse(libraryID)
|
|
if err != nil {
|
|
return mediaStorageState{Reachable: true}
|
|
}
|
|
folders, err := mh.libraryService.GetLibraryFolders(ctx, pgtype.UUID{Bytes: libUUID, Valid: true})
|
|
if err != nil || len(folders) == 0 {
|
|
return mediaStorageState{Reachable: true}
|
|
}
|
|
|
|
anyFolderUp := false
|
|
for _, f := range folders {
|
|
if _, err := os.Stat(f.FolderPath); err != nil {
|
|
continue // folder root unreachable
|
|
}
|
|
anyFolderUp = true
|
|
candidate := filepath.Join(f.FolderPath, filepath.FromSlash(relativePath))
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
return mediaStorageState{AbsolutePath: candidate, Reachable: true}
|
|
}
|
|
}
|
|
if !anyFolderUp {
|
|
return mediaStorageState{Reachable: false}
|
|
}
|
|
// Storage is up but the file is genuinely gone: a stale row is safe
|
|
// to delete.
|
|
return mediaStorageState{Reachable: true}
|
|
}
|
|
|
|
// 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"})
|
|
}
|
|
|
|
visibleSet, _, err := mh.visibleLibrarySet(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !visibleSet[libraryID] {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
|
}
|
|
|
|
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})
|
|
}
|
|
|
|
_, visibleIDs, err := mh.visibleLibrarySet(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items, err := mh.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
|
|
Limit: int32(limit),
|
|
Offset: int32(offset),
|
|
VisibleLibraryIds: visibleIDs,
|
|
})
|
|
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()})
|
|
}
|
|
|
|
// Visibility gate: hidden libraries don't leak row detail by id.
|
|
visibleSet, _, err := mh.visibleLibrarySet(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
|
}
|
|
|
|
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),
|
|
"narrators": item.Narrators,
|
|
"abridged": item.Abridged,
|
|
// 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"})
|
|
}
|
|
|
|
// UpsertMediaReview handles POST and PUT /api/media-items/:id/review.
|
|
// The review body is RAW MARKDOWN (same doctrine as notes): storage and
|
|
// every sync payload carry the source string; rendering happens only at
|
|
// display sites. An empty body deletes the review — clearing via the
|
|
// editor is a delete, not an empty row.
|
|
func (mh *MediaHandler) UpsertMediaReview(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 {
|
|
Review string `json:"review" validate:"max=50000"`
|
|
}
|
|
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()})
|
|
}
|
|
|
|
trimmed := strings.TrimSpace(req.Review)
|
|
if trimmed == "" {
|
|
err := mh.db.DeleteMediaReview(c.Request().Context(), database.DeleteMediaReviewParams{
|
|
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]interface{}{"review": nil})
|
|
}
|
|
|
|
review, err := mh.db.UpsertMediaReview(c.Request().Context(), database.UpsertMediaReviewParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
Review: req.Review,
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, review)
|
|
}
|
|
|
|
// GetMediaReview handles GET /api/media-items/:id/review
|
|
func (mh *MediaHandler) GetMediaReview(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"})
|
|
}
|
|
|
|
review, err := mh.db.GetMediaReview(c.Request().Context(), database.GetMediaReviewParams{
|
|
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{}{"review": nil})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, review)
|
|
}
|
|
|
|
// DeleteMediaReview handles DELETE /api/media-items/:id/review
|
|
func (mh *MediaHandler) DeleteMediaReview(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.DeleteMediaReview(c.Request().Context(), database.DeleteMediaReviewParams{
|
|
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": "review 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 := progressResponse(progress)
|
|
|
|
// Position authority: re-verify the stored anchor and derive the
|
|
// client-facing handles (cssSelector + anchor document href) server-
|
|
// side, so clients scroll to an address instead of parsing CFIs.
|
|
if progress.Epubcfi.Valid && wsync.IsConvertibleFormat(progress.FormatGroup) && mh.libraryService != nil {
|
|
contextText := ""
|
|
if progress.ContextText.Valid {
|
|
contextText = progress.ContextText.String
|
|
}
|
|
pct := 0.0
|
|
if progress.Percentage.Valid {
|
|
pct = progress.Percentage.Float64
|
|
}
|
|
if mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err == nil {
|
|
if path, perr := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath); perr == nil && path != "" {
|
|
if finalCFI, sel, href, charOff, _, _, _, verr := wsync.VerifyProgressAnchor(path, progress.Epubcfi.String, contextText, pct); verr == nil && sel != "" {
|
|
resp["css_selector"] = sel
|
|
resp["anchor_href"] = href
|
|
if charOff != nil {
|
|
resp["char_offset"] = *charOff
|
|
}
|
|
if finalCFI != "" {
|
|
resp["epubcfi"] = finalCFI
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if progress.ContextText.Valid {
|
|
resp["context_text"] = progress.ContextText.String
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// progressResponse serializes a progress row to the client wire shape —
|
|
// one place, keyed off the row struct, so new row columns surface here by
|
|
// construction. Every existing key is preserved verbatim; the row-carried
|
|
// locator fields (audio/reading_mode/viewport/zoom/scroll/panel) are
|
|
// additive keys per docs/contract_pins.md (H1).
|
|
func progressResponse(progress database.GetUniversalProgressRow) map[string]interface{} {
|
|
return 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,
|
|
// Row-carried fields surfaced additively (H1) — the web audiobook
|
|
// reader already reads audio_offset_seconds (audio.ts) and the
|
|
// app parses the same names.
|
|
"audio_offset_seconds": progress.AudioOffsetSeconds,
|
|
"reading_mode": progress.ReadingMode,
|
|
"viewport_x": progress.ViewportX,
|
|
"viewport_y": progress.ViewportY,
|
|
"zoom_level": progress.ZoomLevel,
|
|
"scroll_position_x": progress.ScrollPositionX,
|
|
"scroll_position_y": progress.ScrollPositionY,
|
|
"panel_number": progress.PanelNumber,
|
|
}
|
|
}
|
|
|
|
// 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"})
|
|
}
|
|
|
|
// Reading-stats attribution: registered clients may send their device
|
|
// token in X-Device-Token (the user JWT stays the primary auth). A
|
|
// token that maps to a device owned by THIS user attaches the push —
|
|
// and everything the reading history records about it — to that
|
|
// device's registered name/type ("Pixel 9" · mobile, "Kindle… " ·
|
|
// koreader). Absent/invalid/foreign tokens soft-fail to the plain
|
|
// web attribution; a stats decoration must never fail a sync.
|
|
deviceID := pgtype.UUID{}
|
|
deviceSource := "web"
|
|
deviceLabel := ""
|
|
deviceType := ""
|
|
if token := c.Request().Header.Get("X-Device-Token"); token != "" {
|
|
device, devErr := mh.db.GetDeviceByAuthToken(c.Request().Context(), token)
|
|
if devErr == nil && device.UserID.Valid && uuid.UUID(device.UserID.Bytes) == userUUID {
|
|
deviceID = device.ID
|
|
deviceLabel = device.DeviceName
|
|
deviceType = device.DeviceType
|
|
deviceSource = device.DeviceName
|
|
if strings.TrimSpace(deviceSource) == "" {
|
|
deviceSource = device.DeviceType
|
|
}
|
|
}
|
|
}
|
|
|
|
// The canonical write struct binds the wire directly; identity and
|
|
// attribution fields below are server-owned (json:"-" on the struct)
|
|
// and copied here once.
|
|
var req wsync.SaveProgressRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
req.MediaItemID = pgtype.UUID{Bytes: mediaUUID, Valid: true}
|
|
req.UserID = pgtype.UUID{Bytes: userUUID, Valid: true}
|
|
req.Source = deviceSource
|
|
req.DeviceID = deviceID
|
|
req.DeviceType = deviceType
|
|
req.DeviceName = deviceLabel
|
|
req.Broadcast = true
|
|
|
|
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(), req)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// 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)
|
|
narrators := normalizeNarrators(req.Narrators)
|
|
narratorsSearch := utils.NormalizeNarratorsSearch(narrators)
|
|
|
|
// 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.",
|
|
})
|
|
}
|
|
|
|
// Race-safe create-or-detect: on a duplicate (library_id, file_path)
|
|
// the insert writes nothing and returns no row — answer with the
|
|
// EXISTING item, a duplicate flag, and 200 OK (not a 201 lie).
|
|
created, err := mh.db.CreateMediaItemIfNew(c.Request().Context(), database.CreateMediaItemIfNewParams{
|
|
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,
|
|
Narrators: narrators,
|
|
NarratorsSearch: narratorsSearch,
|
|
Abridged: req.Abridged,
|
|
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 errors.Is(err, pgx.ErrNoRows) {
|
|
existing, gerr := mh.db.GetMediaItemByLibraryAndPath(c.Request().Context(), database.GetMediaItemByLibraryAndPathParams{
|
|
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
|
|
FilePath: req.FilePath,
|
|
})
|
|
if gerr != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": gerr.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"duplicate": true,
|
|
"message": "A media item already exists for this file_path in this library",
|
|
"item": existing,
|
|
})
|
|
}
|
|
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: created.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)
|
|
narrators := normalizeNarrators(req.Narrators)
|
|
narratorsSearch := utils.NormalizeNarratorsSearch(narrators)
|
|
|
|
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,
|
|
Narrators: narrators,
|
|
NarratorsSearch: narratorsSearch,
|
|
Abridged: req.Abridged,
|
|
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"})
|
|
}
|
|
|
|
media, 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()})
|
|
}
|
|
|
|
// Storage-down guard (see HandleBulkDelete): refuse while the library
|
|
// storage is unreachable so the cascade can't destroy annotations for
|
|
// a file that will re-import on the next scan.
|
|
storage := mh.resolveMediaStorage(c.Request().Context(), uuid.UUID(media.LibraryID.Bytes).String(), media.FilePath)
|
|
if !storage.Reachable {
|
|
return c.JSON(http.StatusConflict, map[string]string{
|
|
"error": "storage unreachable — archive the item instead, or retry when the storage is mounted",
|
|
})
|
|
}
|
|
|
|
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()})
|
|
}
|
|
|
|
if storage.AbsolutePath != "" {
|
|
if err := os.Remove(storage.AbsolutePath); err != nil {
|
|
log.Printf("Warning: failed to remove file %s: %v", storage.AbsolutePath, err)
|
|
}
|
|
}
|
|
|
|
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()})
|
|
}
|
|
|
|
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()})
|
|
}
|
|
|
|
return c.JSON(http.StatusCreated, result.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"})
|
|
}
|
|
|
|
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 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()})
|
|
}
|
|
|
|
// Through the sync service (H3): the PUT lands as a proper LWW edit
|
|
// targeted by id (locator columns coalesced with the stored row), so
|
|
// source/modified_at bookkeeping is maintained — the plain query
|
|
// bypassed it entirely.
|
|
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(),
|
|
NoteID: pgtype.UUID{Bytes: noteUUID, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, result.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"})
|
|
}
|
|
|
|
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"})
|
|
}
|
|
|
|
if err := mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}, pgtype.UUID{Bytes: userUUID, Valid: true}); 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()})
|
|
}
|
|
|
|
color := "#ffff00"
|
|
if req.Color != "" {
|
|
color = req.Color
|
|
}
|
|
|
|
pgMediaID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
|
|
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
|
|
|
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)
|
|
}
|
|
|
|
// 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()})
|
|
}
|
|
|
|
color := "#ffff00"
|
|
if req.Color != "" {
|
|
color = req.Color
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// 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}
|
|
|
|
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"})
|
|
}
|
|
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
|
|
|
if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgUserID, pgHighlightID, "web"); 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.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 the only path: the
|
|
// annotation service is a required constructor dependency.
|
|
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)
|
|
}
|
|
|
|
// 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"})
|
|
}
|
|
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 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()})
|
|
}
|
|
|
|
// Through the sync service (H3): the PUT now lands as a proper LWW
|
|
// edit — source/modified_at bookkeeping maintained, locator fields
|
|
// carried (coalesced with the stored row for omitted ones), and the
|
|
// row targeted by id so an edited title can't miss its dedup key.
|
|
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",
|
|
ModifiedAt: time.Now(),
|
|
BookmarkID: pgtype.UUID{Bytes: bookmarkUUID, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, result.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}
|
|
|
|
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"})
|
|
}
|
|
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
|
|
|
if err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgUserID, pgBookmarkID, "web"); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "bookmark not found"})
|
|
}
|
|
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 narrator := c.QueryParam("narrator"); narrator != "" {
|
|
return mh.handleFieldValuesSearch(c, userID.ID, "narrator", narrator)
|
|
}
|
|
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")
|
|
publisherFilter := c.QueryParam("publisher_filter")
|
|
tagsFilter := c.QueryParam("tags_filter")
|
|
narratorFilter := c.QueryParam("narrator_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,
|
|
PublisherFilter: publisherFilter,
|
|
TagsFilter: tagsFilter,
|
|
NarratorFilter: narratorFilter,
|
|
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
|
|
}
|
|
|
|
// GetAudioChapters handles GET /api/media-items/:id/chapters — the
|
|
// format-abstraction chapter list for audiobooks (media_items
|
|
// .audio_metadata). Embedded m4b chapters and folder-as-book files write
|
|
// the same shape. Empty list when the item isn't audio or hasn't been
|
|
// probed.
|
|
func (mh *MediaHandler) GetAudioChapters(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 {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
|
}
|
|
|
|
var payload services.AudioMetadataPayload
|
|
if len(item.AudioMetadata) > 0 {
|
|
if err := json.Unmarshal(item.AudioMetadata, &payload); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to parse audio metadata"})
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"media_item_id": item.ID,
|
|
"chapters": payload.Chapters,
|
|
"duration_seconds": payload.DurationSeconds,
|
|
"codec_profile": payload.CodecProfile,
|
|
})
|
|
}
|