The 9 Batch A facets could not be SET from the web UI, the facets
endpoint had no in-app consumer, and the registry's libraryTypes hints
rendered nowhere. This phase closes all three.
- ONE UNION-ALL sqlc query (FacetCountsUnderFilters) computes all 14
listable facets in a single round trip: each arm omits its OWN
facet's filter block and carries every other registered facet's
block (copied mechanically from SearchMediaItemsUnified) — classic
faceted semantics: with filters active each facet counts under the
others; with none, blocks are neutral and the counts are the v1
global-per-library ones. One query serves both cases. The 14
per-column Batch C queries are deleted.
- Endpoint: no facet param → the new all-facets shape
({facets: {param: [{value, display?, count}]}}), filters bound via
the shared ParseMediaSearchParams, library_id optional (the
All-Libraries view, family lens still enforced); facet=X keeps the
pinned single-facet shape (now routed through the same query with
neutral filters).
- SSR partial GET /media-items/facet-panel: templ renders the drawer's
value lists — registry order, Batch D library-type scoping
(FacetsForLibraryType: manga_type on manga, reading_direction on
manga+comics, narrator on audiobooks; All view = union), prettified
genre labels + language display names, active values marked. Family
lens: out-of-set explicit library_id is 403.
- Drawer wiring: the panel fetches on drawer open and after every
apply with the CURRENT filter query string; a value click toggles
that facet's comma-joined filter (hidden input where the drawer has
no field — same as deep links) and re-applies the form; chips and
URL stay in sync. Values are server-rendered (rendering doctrine);
bookshelf.ts owns only the click/fetch orchestration.
- prettifyGenre moves to internal/utils as the canonical
implementation (services' display enrichment shares it; templ wraps
it; the TS port in bookshelf.ts is unchanged).
- Drift guard extended: FacetCountsUnderFiltersParams must carry every
registry facet's param, the bools as pgtype.Bool.
Tests: under-filter exactness (own-block omission, OR-within,
AND-across), family lens (hidden values absent; 403 on both shapes),
all-shape + pinned-shape coexistence, partial rendering with Batch D
scoping + active marking + 403. Full container suite green except the
documented pre-existing TestAdminCreateUser red.
3081 lines
115 KiB
Go
3081 lines
115 KiB
Go
package handlers
|
|
|
|
import (
|
|
"archive/zip"
|
|
"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 float64 `json:"series_number"`
|
|
Tags []string `json:"tags"`
|
|
ASIN string `json:"asin"`
|
|
DatePublished string `json:"date_published"`
|
|
Subtitle string `json:"subtitle"`
|
|
Explicit bool `json:"explicit"`
|
|
Authors []string `json:"authors"` // write truth; legacy `author` still accepted
|
|
Publishers []string `json:"publishers"` // write truth; legacy `publisher` still accepted
|
|
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"`
|
|
Authors []string `form:"authors" json:"authors"` // write truth; legacy `author` still accepted
|
|
Subtitle string `form:"subtitle" json:"subtitle"`
|
|
Explicit bool `form:"explicit" json:"explicit"`
|
|
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 float64 `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"`
|
|
Publishers []string `form:"publishers" json:"publishers"` // write truth; legacy `publisher` still accepted
|
|
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"`
|
|
}
|
|
|
|
// originSource normalizes a client-supplied origin label into the writer
|
|
// Source ("android"/"ios"); anything absent or unknown stays "web". The
|
|
// web API serves several first-party clients, and the label is what
|
|
// last_modified_source / last_sync_source record — a client that
|
|
// identifies itself gets honest attribution. Keep the set tight: the
|
|
// label also keys device_sync_data and progress-conflict claims.
|
|
func originSource(origin string) string {
|
|
switch origin {
|
|
case "android", "ios":
|
|
return origin
|
|
}
|
|
return "web"
|
|
}
|
|
|
|
// 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"`
|
|
// Optional client label recorded as the writer (see originSource).
|
|
Origin string `json:"origin"`
|
|
}
|
|
|
|
// 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"`
|
|
// Optional client label ("android", "ios") recorded as the writer in
|
|
// last_modified_source; absent/unknown stays "web".
|
|
Origin string `json:"origin"`
|
|
}
|
|
|
|
// 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"`
|
|
// Optional client label recorded as the writer (see originSource).
|
|
Origin string `json:"origin"`
|
|
}
|
|
|
|
// 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"`
|
|
// Optional client label recorded as the writer (see originSource).
|
|
Origin string `json:"origin"`
|
|
}
|
|
|
|
// 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"`
|
|
// Optional client label recorded as the writer in
|
|
// last_modified_source (see originSource).
|
|
Origin string `json:"origin"`
|
|
}
|
|
|
|
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,
|
|
Subtitle: existingMedia.Subtitle,
|
|
Authors: existingMedia.Authors,
|
|
AuthorsSearch: existingMedia.AuthorsSearch,
|
|
Genre: existingMedia.Genre,
|
|
Language: existingMedia.Language,
|
|
Tags: existingMedia.Tags,
|
|
TagsSearch: existingMedia.TagsSearch,
|
|
Description: existingMedia.Description,
|
|
Publishers: existingMedia.Publishers,
|
|
PublishersSearch: existingMedia.PublishersSearch,
|
|
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,
|
|
Explicit: existingMedia.Explicit,
|
|
}
|
|
|
|
// 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.Authors = []string{*update.Updates.Author}
|
|
updateParams.AuthorsSearch = utils.NormalizeTagsSearch(updateParams.Authors)
|
|
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.
|
|
// visibleLibrarySet adapts the media endpoints' identity source (the
|
|
// JWT user object) to the canonical resolver (H4.5).
|
|
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")
|
|
}
|
|
return mh.libraryService.VisibleLibrarySet(c.Request().Context(), user.ID)
|
|
}
|
|
|
|
// 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,
|
|
"subtitle": utils.PgTextString(item.Subtitle),
|
|
"author": utils.PgTextString(item.Author),
|
|
"authors": item.Authors,
|
|
"isbn": utils.PgTextString(item.Isbn),
|
|
"description": utils.PgTextString(item.Description),
|
|
"file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}),
|
|
"file_size": item.FileSize,
|
|
"mime_type": utils.PgTextString(item.MimeType),
|
|
"cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
|
"series": utils.PgTextString(item.Series),
|
|
"series_number": item.SeriesNumber,
|
|
"tags": item.Tags,
|
|
"asin": utils.PgTextString(item.Asin),
|
|
"date_published": item.DatePublished.Time.Format("2006-01-02"),
|
|
"publisher": utils.PgTextString(item.Publisher),
|
|
"publishers": item.Publishers,
|
|
"explicit": item.Explicit,
|
|
"contributors": item.Contributors,
|
|
"language": utils.PgTextString(item.Language),
|
|
"edition": utils.PgTextString(item.Edition),
|
|
"page_count": item.PageCount,
|
|
"genre": utils.PgTextString(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": utils.PgTextString(item.MangaType),
|
|
"reading_direction": utils.PgTextString(item.ReadingDirection),
|
|
// NEW: Universal metadata fields
|
|
"series_count": item.SeriesCount,
|
|
"volume": item.Volume,
|
|
"imprint": utils.PgTextString(item.Imprint),
|
|
"age_rating": utils.PgTextString(item.AgeRating),
|
|
"web_url": utils.PgTextString(item.WebUrl),
|
|
"metadata_notes": utils.PgTextString(item.MetadataNotes),
|
|
"community_rating": numericToFloat(item.CommunityRating),
|
|
// NEW: Comic-specific fields
|
|
"story_arc": utils.PgTextString(item.StoryArc),
|
|
"is_black_and_white": item.IsBlackAndWhite,
|
|
"alternate_info": jsonBytesToMap(item.AlternateInfo),
|
|
"scan_information": utils.PgTextString(item.ScanInformation),
|
|
"summary": utils.PgTextString(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,
|
|
"subtitle": utils.PgTextString(item.Subtitle),
|
|
"author": utils.PgTextString(item.Author),
|
|
"authors": item.Authors,
|
|
"isbn": utils.PgTextString(item.Isbn),
|
|
"description": utils.PgTextString(item.Description),
|
|
"file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}),
|
|
"file_size": item.FileSize,
|
|
"mime_type": utils.PgTextString(item.MimeType),
|
|
"cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
|
"series": utils.PgTextString(item.Series),
|
|
"series_number": item.SeriesNumber,
|
|
"tags": item.Tags,
|
|
"asin": utils.PgTextString(item.Asin),
|
|
"date_published": item.DatePublished.Time.Format("2006-01-02"),
|
|
"publisher": utils.PgTextString(item.Publisher),
|
|
"publishers": item.Publishers,
|
|
"explicit": item.Explicit,
|
|
"contributors": item.Contributors,
|
|
"language": utils.PgTextString(item.Language),
|
|
"edition": utils.PgTextString(item.Edition),
|
|
"page_count": item.PageCount,
|
|
"genre": utils.PgTextString(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": utils.PgTextString(item.FormatMimetype),
|
|
"narrators": item.Narrators,
|
|
"abridged": item.Abridged,
|
|
// NEW: Reading direction fields
|
|
"manga_type": utils.PgTextString(item.MangaType),
|
|
"reading_direction": utils.PgTextString(item.ReadingDirection),
|
|
// NEW: Universal metadata fields
|
|
"series_count": item.SeriesCount,
|
|
"volume": item.Volume,
|
|
"imprint": utils.PgTextString(item.Imprint),
|
|
"age_rating": utils.PgTextString(item.AgeRating),
|
|
"web_url": utils.PgTextString(item.WebUrl),
|
|
"metadata_notes": utils.PgTextString(item.MetadataNotes),
|
|
"community_rating": numericToFloat(item.CommunityRating),
|
|
// NEW: Comic-specific fields
|
|
"story_arc": utils.PgTextString(item.StoryArc),
|
|
"is_black_and_white": item.IsBlackAndWhite,
|
|
"alternate_info": jsonBytesToMap(item.AlternateInfo),
|
|
"scan_information": utils.PgTextString(item.ScanInformation),
|
|
"summary": utils.PgTextString(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
|
|
if deviceSource == "web" {
|
|
// No paired device claimed this push: an explicit client origin
|
|
// ("android"/"ios") is the next-best attribution.
|
|
req.Source = originSource(req.Origin)
|
|
}
|
|
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,
|
|
Subtitle: pgtype.Text{String: req.Subtitle, Valid: req.Subtitle != ""},
|
|
Authors: utils.SingleOrSlice(req.Author, req.Authors),
|
|
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(req.Author, req.Authors)),
|
|
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.Float8{Float64: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
|
Tags: req.Tags,
|
|
TagsSearch: tagsSearch,
|
|
Narrators: narrators,
|
|
NarratorsSearch: narratorsSearch,
|
|
Abridged: req.Abridged,
|
|
Explicit: req.Explicit,
|
|
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
|
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
|
Publishers: utils.SingleOrSlice(req.Publisher, req.Publishers),
|
|
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(req.Publisher, req.Publishers)),
|
|
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,
|
|
Subtitle: pgtype.Text{String: req.Subtitle, Valid: req.Subtitle != ""},
|
|
Authors: utils.SingleOrSlice(req.Author, req.Authors),
|
|
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(req.Author, req.Authors)),
|
|
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.Float8{Float64: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
|
Tags: req.Tags,
|
|
TagsSearch: tagsSearch,
|
|
Narrators: narrators,
|
|
NarratorsSearch: narratorsSearch,
|
|
Abridged: req.Abridged,
|
|
Explicit: req.Explicit,
|
|
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
|
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
|
Publishers: utils.SingleOrSlice(req.Publisher, req.Publishers),
|
|
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(req.Publisher, req.Publishers)),
|
|
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: originSource(req.Origin),
|
|
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: originSource(req.Origin),
|
|
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: originSource(req.Origin),
|
|
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: originSource(req.Origin),
|
|
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 := originSource(req.Origin)
|
|
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: originSource(req.Origin),
|
|
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: originSource(req.Origin),
|
|
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
|
|
params := ParseMediaSearchParams(c)
|
|
params.UserID = userID.ID
|
|
|
|
c.Logger().Info("Search parameters",
|
|
"query", params.SearchQuery,
|
|
"library_id", libraryIDString(c),
|
|
"libUUID_valid", params.LibraryID.Valid,
|
|
"libUUID_bytes", params.LibraryID.Bytes)
|
|
|
|
// Family-lens gate (AGENTS.md → Library visibility): an explicit
|
|
// library_id outside the requester's visible set is a 403, never a
|
|
// silent empty set.
|
|
if params.LibraryID.Valid {
|
|
visible, err := mh.libraryVisible(c, params.LibraryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
if !visible {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
|
|
}
|
|
}
|
|
|
|
c.Logger().Info("SearchMediaItems called",
|
|
"query", params.SearchQuery,
|
|
"library_id_provided", params.LibraryID.Valid,
|
|
"library_id_uuid", params.LibraryID.Valid,
|
|
"library_id_bytes", params.LibraryID.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": params.SearchQuery,
|
|
"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)
|
|
}
|
|
|
|
// libraryIDString returns the raw library_id query param (logging only).
|
|
func libraryIDString(c *echo.Context) string { return c.QueryParam("library_id") }
|
|
|
|
// searchParamSource abstracts the query-param surface (echo.Context)
|
|
// so the shared parser serves both handlers and router.
|
|
type searchParamSource interface {
|
|
QueryParam(name string) string
|
|
QueryParams() url.Values
|
|
}
|
|
|
|
// parseMediaSearchParams parses the unified search's query params into
|
|
// services.SearchParams — the SINGLE parse for both the JSON endpoint
|
|
// (MediaHandler.SearchMediaItems) and the HTMX grid partial
|
|
// (router handleSearchHTML), so the two branches can never disagree.
|
|
// Text facet params: comma = OR-within (the SQL splits via
|
|
// string_to_array). Bool facet params: presence-bound (absent = no
|
|
// filter, "true"/"false" = bound). Facet names come from the facet
|
|
// registry (services/facet_registry.go) — adding a facet there means
|
|
// one line here, mirroring its narg block in queries.sql.
|
|
func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
|
|
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
|
if limit == 0 {
|
|
limit = 50
|
|
}
|
|
offset, _ := strconv.Atoi(c.QueryParam("offset"))
|
|
|
|
sortParam := c.QueryParam("sort")
|
|
if sortParam == "" {
|
|
sortParam = "title ASC" // Default sort
|
|
}
|
|
|
|
var libUUID pgtype.UUID
|
|
if libraryID := c.QueryParam("library_id"); libraryID != "" {
|
|
if lib, err := uuid.Parse(libraryID); err == nil {
|
|
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
|
|
}
|
|
}
|
|
|
|
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
|
|
yearMax, _ := strconv.Atoi(c.QueryParam("year_max"))
|
|
|
|
boolFacet := func(name string) pgtype.Bool {
|
|
if _, exists := c.QueryParams()[name]; !exists {
|
|
return pgtype.Bool{Valid: false}
|
|
}
|
|
return pgtype.Bool{Bool: c.QueryParam(name) == "true", Valid: true}
|
|
}
|
|
|
|
return services.SearchParams{
|
|
SearchQuery: c.QueryParam("q"),
|
|
LibraryID: libUUID,
|
|
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: yearMin,
|
|
YearMax: yearMax,
|
|
HasCover: boolFacet("has_cover"),
|
|
StoryArcFilter: c.QueryParam("story_arc_filter"),
|
|
ImprintFilter: c.QueryParam("imprint_filter"),
|
|
EditionFilter: c.QueryParam("edition_filter"),
|
|
AgeRatingFilter: c.QueryParam("age_rating_filter"),
|
|
FormatGroupFilter: c.QueryParam("format_group_filter"),
|
|
MangaTypeFilter: c.QueryParam("manga_type_filter"),
|
|
ReadingDirectionFilter: c.QueryParam("reading_direction_filter"),
|
|
ExplicitFilter: boolFacet("explicit_filter"),
|
|
AbridgedFilter: boolFacet("abridged_filter"),
|
|
Sort: sortParam,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
}
|
|
}
|
|
|
|
// libraryVisible reports whether the requested library is inside the
|
|
// requester's visible-library set. Slice compare on pgtype.UUID bytes —
|
|
// immune to string-format differences between the query param and the
|
|
// membership map keys.
|
|
func (mh *MediaHandler) libraryVisible(c *echo.Context, libraryID pgtype.UUID) (bool, error) {
|
|
_, ids, err := mh.visibleLibrarySet(c)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, id := range ids {
|
|
if id == libraryID {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// GetFacets handles GET /api/media-items/facets — the filter panel's
|
|
// value+count listing (faceted-navigation Batch C, extended in Phase 7).
|
|
//
|
|
// Two shapes:
|
|
// - ?facet=X&library_id=… → the PINNED single-facet shape
|
|
// ({facet, values[]}); facet must name a FacetArray or FacetScalar
|
|
// entry (bool/range facets render as toggles, not lists).
|
|
// - no facet param → the all-facets shape ({"facets": {param:
|
|
// [value,…]}}), computed under the ACTIVE FILTERS (the same query
|
|
// params the search endpoint takes) — each facet counts under the
|
|
// others, the SQL omitting each arm's own block. library_id is
|
|
// optional: absent = the All-Libraries view.
|
|
//
|
|
// Family-lens: an explicit library_id outside the visible set is 403
|
|
// in both shapes.
|
|
func (mh *MediaHandler) GetFacets(c *echo.Context) error {
|
|
user, ok := c.Get("user").(database.Users)
|
|
if !ok {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
|
|
}
|
|
|
|
facetParam := c.QueryParam("facet")
|
|
libraryID := c.QueryParam("library_id")
|
|
|
|
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
|
if limit == 0 {
|
|
limit = 50
|
|
}
|
|
|
|
if facetParam == "" {
|
|
// All-facets shape: filters bind exactly like the search
|
|
// endpoint's (ParseMediaSearchParams), so the panel's counts
|
|
// and the grid's rows can never disagree about what's active.
|
|
var libPg pgtype.UUID
|
|
if libraryID != "" {
|
|
libUUID, err := uuid.Parse(libraryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
}
|
|
libPg = pgtype.UUID{Bytes: libUUID, Valid: true}
|
|
visible, err := mh.libraryVisible(c, libPg)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
if !visible {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
|
|
}
|
|
}
|
|
counts, err := mh.searchService.FacetCounts(c.Request().Context(), user.ID, libPg, ParseMediaSearchParams(c), limit)
|
|
if err != nil {
|
|
c.Logger().Error("facet counts error", "error", err.Error())
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"facets": counts,
|
|
})
|
|
}
|
|
|
|
if libraryID == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id is required"})
|
|
}
|
|
facet := services.FacetByParam(facetParam)
|
|
if facet == nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "unknown facet: " + facetParam})
|
|
}
|
|
if facet.Kind != services.FacetArray && facet.Kind != services.FacetScalar {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "facet does not list values: " + facetParam})
|
|
}
|
|
|
|
libUUID, err := uuid.Parse(libraryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
}
|
|
libPg := pgtype.UUID{Bytes: libUUID, Valid: true}
|
|
visible, err := mh.libraryVisible(c, libPg)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
if !visible {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
|
|
}
|
|
|
|
values, err := mh.searchService.FacetValues(c.Request().Context(), user.ID, *facet, libPg, limit)
|
|
if err != nil {
|
|
c.Logger().Error("facet values error", "error", err.Error(), "facet", facetParam)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"facet": facetParam,
|
|
"values": values,
|
|
})
|
|
}
|
|
|
|
// FacetCountsForView is the SSR facet panel's data path (faceted plan
|
|
// Phase 7): the all-facets counts under the active filters for one
|
|
// library — or across every visible library when libraryID is NULL.
|
|
func (mh *MediaHandler) FacetCountsForView(ctx context.Context, userID, libraryID pgtype.UUID, params services.SearchParams, limit int) (map[string][]services.FacetValue, error) {
|
|
return mh.searchService.FacetCounts(ctx, userID, libraryID, params, limit)
|
|
}
|
|
|
|
// 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)
|
|
visibleSet, _, err := mh.libraryService.VisibleLibrarySet(c.Request().Context(), user.ID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
|
|
}
|
|
libraryVisible := visibleSet[uuid.UUID(libraryUUID.Bytes).String()]
|
|
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
|
|
fullInfo, err := os.Stat(fullPath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
|
}
|
|
// Folder-of-tracks audiobooks store their book root DIRECTORY in
|
|
// file_path; there is no single file to serve and ServeFile must not
|
|
// fall through to directory listing behavior.
|
|
if fullInfo.IsDir() {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "multi-file books have no single download"})
|
|
}
|
|
|
|
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, folder-of-tracks books and zip
|
|
// books write the same shape. Multi-file books additionally carry
|
|
// `layout` and a `tracks` manifest whose URLs point at the per-track
|
|
// streaming endpoint; single-file books keep the empty layout their
|
|
// existing consumers expect. 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"})
|
|
}
|
|
if !item.LibraryID.Valid {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book has no library"})
|
|
}
|
|
|
|
// Library visibility gate (family-lens rule): chapter lists are media
|
|
// metadata — a hidden library must not leak them any more than its
|
|
// file bytes.
|
|
visibleSet, _, err := mh.visibleLibrarySet(c)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
|
|
}
|
|
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
|
}
|
|
|
|
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"})
|
|
}
|
|
}
|
|
|
|
tracks := make([]map[string]interface{}, 0, len(payload.Tracks))
|
|
for _, tr := range payload.Tracks {
|
|
tracks = append(tracks, map[string]interface{}{
|
|
"index": tr.Index,
|
|
"url": fmt.Sprintf("/api/media-items/%s/audio/%d", mediaID, tr.Index),
|
|
"title": tr.Title,
|
|
"path": tr.Path,
|
|
"start": tr.Start,
|
|
"end": tr.End,
|
|
"duration_seconds": tr.DurationSeconds,
|
|
"size_bytes": tr.SizeBytes,
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"media_item_id": item.ID,
|
|
"chapters": payload.Chapters,
|
|
"duration_seconds": payload.DurationSeconds,
|
|
"codec_profile": payload.CodecProfile,
|
|
"layout": payload.Layout,
|
|
"tracks": tracks,
|
|
})
|
|
}
|
|
|
|
// GetAudioTrack handles GET /api/media-items/:id/audio/:trackIndex — the
|
|
// per-track byte stream for multi-file audiobooks (folder books serve the
|
|
// real file; zip books extract the entry in place). Library visibility is
|
|
// the permission gate, same as ServeFile.
|
|
func (mh *MediaHandler) GetAudioTrack(c *echo.Context) error {
|
|
mediaUUID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
}
|
|
trackIndex, err := strconv.Atoi(c.Param("trackIndex"))
|
|
if err != nil || trackIndex < 0 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid track index"})
|
|
}
|
|
|
|
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"})
|
|
}
|
|
if !item.LibraryID.Valid {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book has no library"})
|
|
}
|
|
|
|
// Library visibility gate (family-lens rule): a hidden library means
|
|
// its audiobooks' bytes are not reachable either.
|
|
visibleSet, _, err := mh.visibleLibrarySet(c)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
|
|
}
|
|
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
|
}
|
|
|
|
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"})
|
|
}
|
|
}
|
|
if trackIndex >= len(payload.Tracks) {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "track not found"})
|
|
}
|
|
track := payload.Tracks[trackIndex]
|
|
|
|
switch payload.Layout {
|
|
case "folder":
|
|
fullPath, err := mh.getFullFilePath(c.Request().Context(), item.LibraryID, track.Path)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "track file not found"})
|
|
}
|
|
f, err := os.Open(fullPath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "track file not found"})
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
info, err := f.Stat()
|
|
if err != nil || info.IsDir() {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "track file not found"})
|
|
}
|
|
c.Response().Header().Set("Content-Type", audioTrackContentType(track.Path))
|
|
c.Response().Header().Set("Cache-Control", "private, max-age=86400")
|
|
// http.ServeContent wants a ReadSeeker for Range support; the os
|
|
// file provides both.
|
|
http.ServeContent(c.Response(), c.Request(), filepath.Base(fullPath), info.ModTime(), f)
|
|
return nil
|
|
|
|
case "zip":
|
|
zipPath, err := mh.getFullFilePath(c.Request().Context(), item.LibraryID, item.FilePath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "archive not found"})
|
|
}
|
|
seeker, err := newZipEntrySeeker(zipPath, track.Path)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "track not found in archive"})
|
|
}
|
|
defer func() { _ = seeker.Close() }()
|
|
c.Response().Header().Set("Content-Type", audioTrackContentType(track.Path))
|
|
c.Response().Header().Set("Cache-Control", "private, max-age=86400")
|
|
// Zip entries re-open+skip for backward seeks (deflate has no
|
|
// random access), which audio bitrates make cheap enough.
|
|
http.ServeContent(c.Response(), c.Request(), filepath.Base(track.Path), time.Time{}, seeker)
|
|
return nil
|
|
|
|
default:
|
|
// Single-file books stream through the /uploads path; the track
|
|
// endpoint has nothing to slice.
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book has no track manifest"})
|
|
}
|
|
}
|
|
|
|
// audioTrackContentType maps a track path/entry name to its mime type.
|
|
func audioTrackContentType(name string) string {
|
|
if m := services.MimeTypes[strings.ToLower(filepath.Ext(name))]; m != "" {
|
|
return m
|
|
}
|
|
return "audio/mpeg"
|
|
}
|
|
|
|
// zipEntrySeeker is an io.ReadSeeker over one zip entry. Forward seeks
|
|
// discard bytes in the open stream; backward seeks re-open the entry and
|
|
// skip — the only option over a deflate stream, and cheap at audio
|
|
// bitrates. Owns the archive reader for the life of the request.
|
|
type zipEntrySeeker struct {
|
|
zrc *zip.ReadCloser
|
|
rc io.ReadCloser
|
|
pos int64
|
|
size int64
|
|
entryFn func() (io.ReadCloser, error)
|
|
}
|
|
|
|
func newZipEntrySeeker(zipPath, entryName string) (*zipEntrySeeker, error) {
|
|
zrc, err := zip.OpenReader(zipPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var entry *zip.File
|
|
for _, f := range zrc.File {
|
|
if filepath.ToSlash(f.Name) == filepath.ToSlash(entryName) {
|
|
entry = f
|
|
break
|
|
}
|
|
}
|
|
if entry == nil {
|
|
_ = zrc.Close()
|
|
return nil, fmt.Errorf("entry %q not found", entryName)
|
|
}
|
|
open := func() (io.ReadCloser, error) { return entry.Open() }
|
|
rc, err := open()
|
|
if err != nil {
|
|
_ = zrc.Close()
|
|
return nil, err
|
|
}
|
|
return &zipEntrySeeker{
|
|
zrc: zrc,
|
|
rc: rc,
|
|
size: int64(entry.UncompressedSize64),
|
|
entryFn: open,
|
|
}, nil
|
|
}
|
|
|
|
func (z *zipEntrySeeker) Read(p []byte) (int, error) {
|
|
n, err := z.rc.Read(p)
|
|
z.pos += int64(n)
|
|
return n, err
|
|
}
|
|
|
|
func (z *zipEntrySeeker) Seek(offset int64, whence int) (int64, error) {
|
|
var target int64
|
|
switch whence {
|
|
case io.SeekStart:
|
|
target = offset
|
|
case io.SeekCurrent:
|
|
target = z.pos + offset
|
|
case io.SeekEnd:
|
|
target = z.size + offset
|
|
default:
|
|
return z.pos, fmt.Errorf("invalid whence %d", whence)
|
|
}
|
|
if target < 0 {
|
|
return z.pos, fmt.Errorf("negative seek target %d", target)
|
|
}
|
|
if target < z.pos {
|
|
// Deflate streams can't rewind: restart the entry and skip.
|
|
if err := z.rc.Close(); err != nil {
|
|
return z.pos, err
|
|
}
|
|
rc, err := z.entryFn()
|
|
if err != nil {
|
|
return z.pos, err
|
|
}
|
|
z.rc = rc
|
|
z.pos = 0
|
|
}
|
|
if remaining := target - z.pos; remaining > 0 {
|
|
if _, err := io.CopyN(io.Discard, z.rc, remaining); err != nil {
|
|
return z.pos, err
|
|
}
|
|
z.pos = target
|
|
}
|
|
return z.pos, nil
|
|
}
|
|
|
|
func (z *zipEntrySeeker) Close() error {
|
|
_ = z.rc.Close()
|
|
return z.zrc.Close()
|
|
}
|