Multi-file book ingest: a directory of ≥2 audio files (recursively —
disc-part folders like CD1/Disc 2 merge upward, siblings never merge)
ingests as ONE book whose file_path is the book-root directory; a zip
of audio tracks is its own book (file_path = the archive). Both write
the documented chapters abstraction {index,title,start,end} into
audio_metadata and add additive keys: layout ("folder"|"zip") and a
tracks[] manifest (per-track path, global-seconds start/end, duration,
size). Pre-grouping one-track strays under a book root are absorbed on
rescan (annotations cascade). Rescans take a stat-only fast path
(track count + total size + known durations unchanged → no re-probe —
a 1189-track book must not re-ffprobe every auto-scan); a zero-duration
book counts as changed so a ffprobe-equipped rescan fills durations in.
Folder-book roots count as present in the archive lifecycle pass, and
per-item rescan (RescanMediaItem) routes multi-file books through a
book-level refresh — the generic updater would read the root/zip as if
it were the book file and clobber format_group with "unknown".
Serving: GET /api/media-items/:id/chapters gains additive layout +
tracks keys (urls → the new endpoint). New GET
/api/media-items/:id/audio/:trackIndex streams one track — folder
tracks served from the real file (full Range via ServeContent), zip
tracks via a reopen-and-skip ReadSeeker over the entry (deflate has no
random access; acceptable at audio bitrates; stored entries stay
section readers). Both endpoints gate on library visibility (403 on
hidden libraries — family-lens rule). ServeFile and OPDS DownloadBook
refuse directory paths (folder books have no single-file download).
Web player (next commit) consumes the manifest; progress/bookmarks/
deep-links keep their global-seconds coordinates, so the server-side
progress model is untouched. sync learns FormatGroupAudiobook
(percentage passthrough in ConvertProgress); detectFormatType stops
reporting "unknown" for audio.
Metadata ingestion (Batch B) — read everything available, degrade
gracefully, documented precedence sidecars → folder structure →
embedded tags → filename: ffprobe tags are now a case-insensitive
lookup map (freeform keys pass through verbatim); composer fills
narrators (Audible's own convention, kept as ONE string — no reliable
multi-narrator delimiter); grouping + Audible freeform atoms
(SERIES/SERIES-PART/SUBTITLE/ASIN/ABRIDGED) fill series/index/
subtitle-join/ASIN/abridged (explicit sidecar abridged-false is
guarded); date/genre fall back to tags; album/album_artist/artist
fill title/author. Genre persists at create for all formats.
Embedded cover art (mp3 APIC, m4b covr, flac picture) extracts via one
optional ffmpeg call as a "<book>.cover.jpg" sibling — sidecar covers
win. CUE sheets (FILE-matched, INDEX 01 at 75fps) are the lowest-
priority chapter source under sidecars and embedded m4b chapters. A
lone m3u/m3u8 in a book root imposes playlist ordering + #EXTINF
names; (disc, track) tags reorder tracks when coverage is complete.
extractMetadata's default branch no longer pre-fills the filename as
title (tags must outrank it) — final filename fallbacks now exist on
BOTH the create and rescan paths (TestRescanKeepsFilenameTitle pins
that rescans cannot blank a title); tagless zip books take the zip's
own name, not their parent folder's.
Accepted containers widen to .m4b .m4a .mp3 .flac .zip .opus .ogg .oga
.aac .wav with real media types registered (audio/ogg per RFC 7845 for
Opus-in-Ogg) — all browser-playable; .wma deliberately excluded.
Tests: unit tests for CUE parsing, playlist hints, tag fallbacks
(synthetic ffprobe reports — ffmpeg cannot write Audible freeform
atoms), ordinal parsing, tag ordering, natural sort, grouping rules;
integration suite (11 tests) over the live dev DB: folder/zip ingest +
idempotency, stray absorption, chapters manifest, per-track streaming
with Range, hidden-library 403s, embedded covers + sidecar-wins, CUE
chapters, embedded m4b chapters, playlist ordering, tag fallbacks, all
five new containers with content-type assertions, rescan title pin.
Fixture media is real committed-format binary content living untracked
under internal/services/testdata/ (gitignored by policy; recipes in
docs/contributing/development.md → Media fixtures). Test image now
ships ffmpeg to match the runtime's audio toolchain.
2907 lines
107 KiB
Go
2907 lines
107 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 int32 `json:"series_number"`
|
|
Tags []string `json:"tags"`
|
|
ASIN string `json:"asin"`
|
|
DatePublished string `json:"date_published"`
|
|
Publisher string `json:"publisher"`
|
|
Contributors []string `json:"contributors"`
|
|
// NEW: Reading direction and comic metadata fields
|
|
MangaType string `json:"manga_type"` // 'unknown' | 'no' | 'yes' | 'yes_and_right_to_left'
|
|
ReadingDirection string `json:"reading_direction"` // 'auto' | 'ltr' | 'rtl' | 'vertical'
|
|
SeriesCount int32 `json:"series_count"`
|
|
Volume int32 `json:"volume"`
|
|
Imprint string `json:"imprint"`
|
|
AgeRating string `json:"age_rating"` // 'Everyone' | 'Teen' | 'Mature' | 'Adult'
|
|
WebURL string `json:"web_url"`
|
|
MetadataNotes string `json:"metadata_notes"`
|
|
CommunityRating float64 `json:"community_rating"`
|
|
StoryArc string `json:"story_arc"`
|
|
IsBlackAndWhite bool `json:"is_black_and_white"`
|
|
AlternateInfo string `json:"alternate_info"` // JSON string
|
|
ScanInformation string `json:"scan_information"`
|
|
Summary string `json:"summary"`
|
|
// Audio editorial facts (A2a)
|
|
Narrators []string `json:"narrators"`
|
|
Abridged bool `json:"abridged"`
|
|
}
|
|
|
|
// UpdateMediaItemRequest represents the request for updating a media item
|
|
type UpdateMediaItemRequest struct {
|
|
Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
|
|
Author string `form:"author" json:"author"`
|
|
ISBN string `form:"isbn" json:"isbn"`
|
|
Description string `form:"description" json:"description"`
|
|
CoverImagePath string `form:"cover_image_path" json:"cover_image_path"`
|
|
CoverAction string `form:"cover_action" json:"cover_action"`
|
|
Series string `form:"series" json:"series"`
|
|
SeriesNumber int32 `form:"series_number" json:"series_number"`
|
|
Tags []string `form:"tags" json:"tags"`
|
|
ASIN string `form:"asin" json:"asin"`
|
|
DatePublished string `form:"date_published" json:"date_published"`
|
|
Publisher string `form:"publisher" json:"publisher"`
|
|
Contributors []string `form:"contributors" json:"contributors"`
|
|
Language string `form:"language" json:"language"`
|
|
Edition string `form:"edition" json:"edition"`
|
|
PageCount int32 `form:"page_count" json:"page_count"`
|
|
Genre string `form:"genre" json:"genre"`
|
|
CopyrightYear int32 `form:"copyright_year" json:"copyright_year"`
|
|
GoodreadsID string `form:"goodreads_id" json:"goodreads_id"`
|
|
OpenlibraryID string `form:"openlibrary_id" json:"openlibrary_id"`
|
|
GoogleBooksID string `form:"google_books_id" json:"google_books_id"`
|
|
MangaType string `form:"manga_type" json:"manga_type"`
|
|
ReadingDirection string `form:"reading_direction" json:"reading_direction"`
|
|
SeriesCount int32 `form:"series_count" json:"series_count"`
|
|
Volume int32 `form:"volume" json:"volume"`
|
|
Imprint string `form:"imprint" json:"imprint"`
|
|
AgeRating string `form:"age_rating" json:"age_rating"`
|
|
WebURL string `form:"web_url" json:"web_url"`
|
|
MetadataNotes string `form:"metadata_notes" json:"metadata_notes"`
|
|
CommunityRating float64 `form:"community_rating" json:"community_rating"`
|
|
StoryArc string `form:"story_arc" json:"story_arc"`
|
|
IsBlackAndWhite bool `form:"is_black_and_white" json:"is_black_and_white"`
|
|
AlternateInfo string `form:"alternate_info" json:"alternate_info"`
|
|
ScanInformation string `form:"scan_information" json:"scan_information"`
|
|
Summary string `form:"summary" json:"summary"`
|
|
// Audiobook editorial facts (A2a): narrators verbatim display order;
|
|
// abridged as the Libation/Audible flag.
|
|
Narrators []string `form:"narrators" json:"narrators"`
|
|
Abridged bool `form:"abridged" json:"abridged"`
|
|
}
|
|
|
|
// 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,
|
|
Author: existingMedia.Author,
|
|
Genre: existingMedia.Genre,
|
|
Language: existingMedia.Language,
|
|
Tags: existingMedia.Tags,
|
|
TagsSearch: existingMedia.TagsSearch,
|
|
Description: existingMedia.Description,
|
|
Publisher: existingMedia.Publisher,
|
|
CopyrightYear: existingMedia.CopyrightYear,
|
|
Isbn: existingMedia.Isbn,
|
|
Series: existingMedia.Series,
|
|
SeriesNumber: existingMedia.SeriesNumber,
|
|
Asin: existingMedia.Asin,
|
|
DatePublished: existingMedia.DatePublished,
|
|
Contributors: existingMedia.Contributors,
|
|
ContributorsSearch: existingMedia.ContributorsSearch,
|
|
Edition: existingMedia.Edition,
|
|
PageCount: existingMedia.PageCount,
|
|
GoodreadsID: existingMedia.GoodreadsID,
|
|
OpenlibraryID: existingMedia.OpenlibraryID,
|
|
GoogleBooksID: existingMedia.GoogleBooksID,
|
|
CoverImagePath: existingMedia.CoverImagePath,
|
|
MangaType: existingMedia.MangaType,
|
|
ReadingDirection: existingMedia.ReadingDirection,
|
|
SeriesCount: existingMedia.SeriesCount,
|
|
Volume: existingMedia.Volume,
|
|
Imprint: existingMedia.Imprint,
|
|
AgeRating: existingMedia.AgeRating,
|
|
WebUrl: existingMedia.WebUrl,
|
|
MetadataNotes: existingMedia.MetadataNotes,
|
|
CommunityRating: existingMedia.CommunityRating,
|
|
StoryArc: existingMedia.StoryArc,
|
|
IsBlackAndWhite: existingMedia.IsBlackAndWhite,
|
|
AlternateInfo: existingMedia.AlternateInfo,
|
|
ScanInformation: existingMedia.ScanInformation,
|
|
Summary: existingMedia.Summary,
|
|
}
|
|
|
|
// Bulk edits are user customizations: keep existing overrides and mark
|
|
// each applied update as overridden so scans preserve it.
|
|
overrides := existingMedia.MetadataOverrides
|
|
if update.Updates.Title != nil {
|
|
updateParams.Title = *update.Updates.Title
|
|
overrides = utils.MergeOverrides(overrides, utils.OverrideTitle)
|
|
}
|
|
if update.Updates.Author != nil {
|
|
updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true}
|
|
overrides = utils.MergeOverrides(overrides, utils.OverrideAuthor)
|
|
}
|
|
if update.Updates.Genre != nil {
|
|
updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true}
|
|
overrides = utils.MergeOverrides(overrides, utils.OverrideGenre)
|
|
}
|
|
if update.Updates.Language != nil {
|
|
updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true}
|
|
overrides = utils.MergeOverrides(overrides, utils.OverrideLanguage)
|
|
}
|
|
if len(update.Updates.Tags) > 0 {
|
|
normalizedTags := utils.NormalizeTags(update.Updates.Tags)
|
|
updateParams.Tags = normalizedTags
|
|
tagsSearch := utils.NormalizeTagsSearch(update.Updates.Tags)
|
|
updateParams.TagsSearch = tagsSearch
|
|
overrides = utils.MergeOverrides(overrides, utils.OverrideTags)
|
|
}
|
|
updateParams.MetadataOverrides = overrides
|
|
|
|
_, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"media_item_id": update.MediaItemID,
|
|
"status": "error",
|
|
"error": err.Error(),
|
|
})
|
|
failedCount++
|
|
continue
|
|
}
|
|
|
|
results = append(results, map[string]interface{}{
|
|
"media_item_id": update.MediaItemID,
|
|
"status": "success",
|
|
})
|
|
successCount++
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"results": results,
|
|
"total": len(req.MediaItemUpdates),
|
|
"updated": successCount,
|
|
"failed": failedCount,
|
|
})
|
|
}
|
|
|
|
// visibleLibrarySet loads the requester's visible libraries — membership
|
|
// map for param checks, id slice for SQL ANY() filters.
|
|
// 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,
|
|
"author": utils.PgTextString(item.Author),
|
|
"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),
|
|
"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,
|
|
"author": utils.PgTextString(item.Author),
|
|
"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),
|
|
"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,
|
|
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
|
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
|
|
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
|
FilePath: req.FilePath,
|
|
FileSize: pgtype.Int8{Int64: req.FileSize, Valid: req.FileSize > 0},
|
|
MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""},
|
|
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
|
|
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
|
|
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
|
Tags: req.Tags,
|
|
TagsSearch: tagsSearch,
|
|
Narrators: narrators,
|
|
NarratorsSearch: narratorsSearch,
|
|
Abridged: req.Abridged,
|
|
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
|
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
|
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
|
Contributors: req.Contributors,
|
|
ContributorsSearch: contributorsSearch,
|
|
AddedByAdminID: user.ID,
|
|
})
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
existing, gerr := mh.db.GetMediaItemByLibraryAndPath(c.Request().Context(), database.GetMediaItemByLibraryAndPathParams{
|
|
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
|
|
FilePath: req.FilePath,
|
|
})
|
|
if gerr != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": gerr.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"duplicate": true,
|
|
"message": "A media item already exists for this file_path in this library",
|
|
"item": existing,
|
|
})
|
|
}
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: created.ID.Bytes, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusCreated, item)
|
|
}
|
|
|
|
// UpdateMediaItem handles PUT /api/media-items/:id (admin only)
|
|
func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
if user.Role != "admin" {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
|
}
|
|
|
|
mediaID := c.Param("id")
|
|
mediaUUID, err := uuid.Parse(mediaID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
}
|
|
|
|
var req UpdateMediaItemRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
if len(req.Tags) > 0 {
|
|
req.Tags = utils.NormalizeTags(req.Tags)
|
|
}
|
|
|
|
if len(req.Contributors) > 0 {
|
|
req.Contributors = utils.NormalizeContributors(req.Contributors)
|
|
}
|
|
|
|
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
|
|
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
|
|
narrators := normalizeNarrators(req.Narrators)
|
|
narratorsSearch := utils.NormalizeNarratorsSearch(narrators)
|
|
|
|
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
|
|
if err != nil && req.ISBN != "" {
|
|
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"})
|
|
}
|
|
|
|
isbnValue := normalizedISBN
|
|
if err != nil {
|
|
isbnValue = ""
|
|
}
|
|
|
|
if req.CoverAction == "" {
|
|
req.CoverAction = "keep"
|
|
}
|
|
|
|
existing, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
|
}
|
|
|
|
coverPath := existing.CoverImagePath.String
|
|
coverUploaded := false
|
|
|
|
if req.CoverAction == "remove" {
|
|
coverPath = ""
|
|
} else if req.CoverAction == "upload" {
|
|
file, err := c.FormFile("cover_file")
|
|
if err == nil {
|
|
savedPath, err := mh.saveCoverImage(*c, mediaUUID, file)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save cover image"})
|
|
}
|
|
coverPath = savedPath
|
|
coverUploaded = true
|
|
}
|
|
}
|
|
|
|
var alternateInfoBytes []byte
|
|
if req.AlternateInfo != "" {
|
|
alternateInfoBytes = []byte(req.AlternateInfo)
|
|
}
|
|
|
|
params := database.UpdateMediaItemParams{
|
|
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
Title: req.Title,
|
|
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
|
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
|
|
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
|
CoverImagePath: pgtype.Text{String: coverPath, Valid: coverPath != ""},
|
|
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
|
|
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
|
Tags: req.Tags,
|
|
TagsSearch: tagsSearch,
|
|
Narrators: narrators,
|
|
NarratorsSearch: narratorsSearch,
|
|
Abridged: req.Abridged,
|
|
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
|
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
|
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
|
Contributors: req.Contributors,
|
|
ContributorsSearch: contributorsSearch,
|
|
Language: pgtype.Text{String: req.Language, Valid: req.Language != ""},
|
|
Edition: pgtype.Text{String: req.Edition, Valid: req.Edition != ""},
|
|
PageCount: pgtype.Int4{Int32: req.PageCount, Valid: req.PageCount > 0},
|
|
Genre: pgtype.Text{String: req.Genre, Valid: req.Genre != ""},
|
|
CopyrightYear: pgtype.Int4{Int32: req.CopyrightYear, Valid: req.CopyrightYear > 0},
|
|
GoodreadsID: pgtype.Text{String: req.GoodreadsID, Valid: req.GoodreadsID != ""},
|
|
OpenlibraryID: pgtype.Text{String: req.OpenlibraryID, Valid: req.OpenlibraryID != ""},
|
|
GoogleBooksID: pgtype.Text{String: req.GoogleBooksID, Valid: req.GoogleBooksID != ""},
|
|
MangaType: pgtype.Text{String: req.MangaType, Valid: req.MangaType != ""},
|
|
ReadingDirection: pgtype.Text{String: req.ReadingDirection, Valid: req.ReadingDirection != ""},
|
|
SeriesCount: pgtype.Int4{Int32: req.SeriesCount, Valid: req.SeriesCount > 0},
|
|
Volume: pgtype.Int4{Int32: req.Volume, Valid: req.Volume > 0},
|
|
Imprint: pgtype.Text{String: req.Imprint, Valid: req.Imprint != ""},
|
|
AgeRating: pgtype.Text{String: req.AgeRating, Valid: req.AgeRating != ""},
|
|
WebUrl: pgtype.Text{String: req.WebURL, Valid: req.WebURL != ""},
|
|
MetadataNotes: pgtype.Text{String: req.MetadataNotes, Valid: req.MetadataNotes != ""},
|
|
CommunityRating: pgtype.Float8{Float64: req.CommunityRating, Valid: req.CommunityRating > 0},
|
|
StoryArc: pgtype.Text{String: req.StoryArc, Valid: req.StoryArc != ""},
|
|
IsBlackAndWhite: pgtype.Bool{Bool: req.IsBlackAndWhite, Valid: req.IsBlackAndWhite},
|
|
AlternateInfo: alternateInfoBytes,
|
|
ScanInformation: pgtype.Text{String: req.ScanInformation, Valid: req.ScanInformation != ""},
|
|
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
|
|
}
|
|
|
|
// Fields the user actually changed become overrides so future scans keep
|
|
// the custom values. An explicit cover upload/removal always overrides.
|
|
overrides := utils.DetectMetadataOverrides(params, existing)
|
|
if req.CoverAction == "remove" || coverUploaded {
|
|
overrides = utils.MergeOverrides(overrides, utils.OverrideCoverImagePath)
|
|
}
|
|
params.MetadataOverrides = overrides
|
|
|
|
item, err := mh.db.UpdateMediaItem(c.Request().Context(), params)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
if c.Request().Header.Get("HX-Request") == "true" {
|
|
c.Response().Header().Set("HX-Redirect", "/media/"+mediaID)
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, item)
|
|
}
|
|
|
|
// UnarchiveMediaItem handles POST /api/media-items/:id/unarchive (admin
|
|
// only). Manually restores a hidden/archived item to library visibility
|
|
// without waiting for its file to return. If the file is still gone, the
|
|
// next scan hides it again.
|
|
func (mh *MediaHandler) UnarchiveMediaItem(c *echo.Context) error {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
if user.Role != "admin" {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
|
}
|
|
|
|
mediaID := c.Param("id")
|
|
mediaUUID, err := uuid.Parse(mediaID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
}
|
|
|
|
if err := mh.db.ClearMediaItemArchive(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
// MergeArchivedMediaItem handles POST /api/media-items/:id/merge (admin
|
|
// only). Merges an archived/missing item's reading data (progress,
|
|
// annotations, highlights, notes, bookmarks, ratings, collections, kobo
|
|
// shelves) into an active item with identical content (same SHA-256), then
|
|
// deletes the archived row. Covers the copy-then-delete-later workflow: the
|
|
// active copy exists, the archived twin holds the real reading history.
|
|
// Per-user collisions keep the active copy's data, mirroring hash-conflict
|
|
// resolution.
|
|
func (mh *MediaHandler) MergeArchivedMediaItem(c *echo.Context) error {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
if user.Role != "admin" {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
|
}
|
|
|
|
sourceUUID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
}
|
|
|
|
var req struct {
|
|
TargetID string `json:"target_id"`
|
|
}
|
|
if err := c.Bind(&req); err != nil || req.TargetID == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "target_id is required"})
|
|
}
|
|
targetUUID, err := uuid.Parse(req.TargetID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid target_id"})
|
|
}
|
|
if sourceUUID == targetUUID {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot merge an item into itself"})
|
|
}
|
|
|
|
ctx := c.Request().Context()
|
|
source, err := mh.db.GetMediaItem(ctx, pgtype.UUID{Bytes: sourceUUID, Valid: true})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "archived item not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
if !source.ArchivedAt.Valid && source.MissingScanCount == 0 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "source item is not archived or missing"})
|
|
}
|
|
|
|
target, err := mh.db.GetMediaItem(ctx, pgtype.UUID{Bytes: targetUUID, Valid: true})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "target item not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
if target.ArchivedAt.Valid || target.MissingScanCount > 0 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "target item is not active"})
|
|
}
|
|
|
|
if !source.FileSha256.Valid || source.FileSha256.String == "" ||
|
|
source.FileSha256.String != target.FileSha256.String {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "items do not share the same content hash"})
|
|
}
|
|
|
|
if err := mh.db.ReparentMediaItemChildren(ctx, database.ReparentMediaItemChildrenParams{
|
|
Column1: pgtype.UUID{Bytes: targetUUID, Valid: true},
|
|
Column2: pgtype.UUID{Bytes: sourceUUID, Valid: true},
|
|
}); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to merge reading data: " + err.Error()})
|
|
}
|
|
if err := mh.db.DeleteMediaItem(ctx, pgtype.UUID{Bytes: sourceUUID, Valid: true}); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to remove archived item: " + err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{
|
|
"message": "Merged reading progress, annotations, and collections into the active item",
|
|
})
|
|
}
|
|
|
|
// PurgeArchivedMediaItems handles POST /api/media-items/purge-archived
|
|
// (admin only). Hard-deletes every archived item (files missing from disk for
|
|
// 2+ scans) together with its reading history. The archive retention window
|
|
// eventually does the same automatically; this is the manual bulk escape hatch.
|
|
func (mh *MediaHandler) PurgeArchivedMediaItems(c *echo.Context) error {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
if user.Role != "admin" {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
|
}
|
|
|
|
purged, err := mh.db.PurgeAllArchivedMediaItems(c.Request().Context())
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"purged": len(purged),
|
|
})
|
|
}
|
|
|
|
// RescanMediaItem handles POST /api/media-items/:id/rescan (admin only)
|
|
func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
if user.Role != "admin" {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
|
}
|
|
|
|
mediaID := c.Param("id")
|
|
mediaUUID, err := uuid.Parse(mediaID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
}
|
|
|
|
if _, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
scanner := services.NewMediaScanner(mh.db)
|
|
defer scanner.Close()
|
|
|
|
// reset_overrides=true discards user customizations first, returning the
|
|
// item to pure scanned defaults (the "Reset to Scanned" action).
|
|
resetOverrides := c.QueryParam("reset_overrides") == "true"
|
|
|
|
if err := scanner.RescanMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}, resetOverrides); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, item)
|
|
}
|
|
|
|
// DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
|
|
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
if user.Role != "admin" {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
|
}
|
|
|
|
mediaID := c.Param("id")
|
|
mediaUUID, err := uuid.Parse(mediaID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
}
|
|
|
|
media, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
// Storage-down guard (see HandleBulkDelete): refuse while the library
|
|
// storage is unreachable so the cascade can't destroy annotations for
|
|
// a file that will re-import on the next scan.
|
|
storage := mh.resolveMediaStorage(c.Request().Context(), uuid.UUID(media.LibraryID.Bytes).String(), media.FilePath)
|
|
if !storage.Reachable {
|
|
return c.JSON(http.StatusConflict, map[string]string{
|
|
"error": "storage unreachable — archive the item instead, or retry when the storage is mounted",
|
|
})
|
|
}
|
|
|
|
err = mh.db.DeleteMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
if storage.AbsolutePath != "" {
|
|
if err := os.Remove(storage.AbsolutePath); err != nil {
|
|
log.Printf("Warning: failed to remove file %s: %v", storage.AbsolutePath, err)
|
|
}
|
|
}
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
// GetMediaNotes handles GET /api/media-items/:id/notes
|
|
func (mh *MediaHandler) GetMediaNotes(c *echo.Context) error {
|
|
userID := c.Get("user_id").(string)
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
|
}
|
|
|
|
mediaID := c.Param("id")
|
|
mediaUUID, err := uuid.Parse(mediaID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
}
|
|
|
|
notes, err := mh.db.GetMediaNotes(c.Request().Context(), database.GetMediaNotesParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, notes)
|
|
}
|
|
|
|
// CreateMediaNote handles POST /api/media-items/:id/notes
|
|
func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error {
|
|
userID := c.Get("user_id").(string)
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
|
}
|
|
|
|
mediaID := c.Param("id")
|
|
mediaUUID, err := uuid.Parse(mediaID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
}
|
|
|
|
var req CreateMediaNoteRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
result, err := mh.annotationSvc.SaveNote(c.Request().Context(), wsync.SaveNoteRequest{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
Content: req.Content,
|
|
Position: req.Position,
|
|
Source: 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
|
|
query := c.QueryParam("q")
|
|
libraryID := c.QueryParam("library_id")
|
|
|
|
// Validate library_id if provided
|
|
var libUUID pgtype.UUID
|
|
if libraryID != "" {
|
|
lib, err := uuid.Parse(libraryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
}
|
|
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
|
|
} else {
|
|
libUUID = pgtype.UUID{Valid: false}
|
|
}
|
|
|
|
c.Logger().Info("Search parameters",
|
|
"query", query,
|
|
"library_id", libraryID,
|
|
"libUUID_valid", libUUID.Valid,
|
|
"libUUID_bytes", libUUID.Bytes)
|
|
|
|
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
|
if limit == 0 {
|
|
limit = 50
|
|
}
|
|
offset, _ := strconv.Atoi(c.QueryParam("offset"))
|
|
|
|
// Extract filter parameters
|
|
authorFilter := c.QueryParam("author_filter")
|
|
seriesFilter := c.QueryParam("series_filter")
|
|
genreFilter := c.QueryParam("genre_filter")
|
|
publisherFilter := c.QueryParam("publisher_filter")
|
|
tagsFilter := c.QueryParam("tags_filter")
|
|
narratorFilter := c.QueryParam("narrator_filter")
|
|
languageFilter := c.QueryParam("language_filter")
|
|
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
|
|
yearMax, _ := strconv.Atoi(c.QueryParam("year_max"))
|
|
hasCover := false
|
|
hasCoverValid := false
|
|
if _, exists := c.QueryParams()["has_cover"]; exists {
|
|
hasCover = c.QueryParam("has_cover") == "true"
|
|
hasCoverValid = true
|
|
}
|
|
|
|
// Extract sort parameter
|
|
sortParam := c.QueryParam("sort")
|
|
if sortParam == "" {
|
|
sortParam = "title ASC" // Default sort
|
|
}
|
|
|
|
// Build search params
|
|
params := services.SearchParams{
|
|
UserID: userID.ID,
|
|
LibraryID: libUUID,
|
|
SearchQuery: query,
|
|
AuthorFilter: authorFilter,
|
|
SeriesFilter: seriesFilter,
|
|
GenreFilter: genreFilter,
|
|
PublisherFilter: publisherFilter,
|
|
TagsFilter: tagsFilter,
|
|
NarratorFilter: narratorFilter,
|
|
LanguageFilter: languageFilter,
|
|
YearMin: yearMin,
|
|
YearMax: yearMax,
|
|
HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
|
|
Sort: sortParam,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
}
|
|
|
|
c.Logger().Info("SearchMediaItems called",
|
|
"query", query,
|
|
"library_id_provided", libraryID != "",
|
|
"library_id_uuid", libUUID.Valid,
|
|
"library_id_bytes", libUUID.Bytes)
|
|
|
|
// Call shared search service
|
|
results, _, err := mh.searchService.ExecuteSearch(c.Request().Context(), params)
|
|
if err != nil {
|
|
c.Logger().Error("search error", "error", err.Error())
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
if len(results) == 0 {
|
|
return c.JSON(http.StatusNotFound, map[string]interface{}{
|
|
"error": "no results found",
|
|
"query": query,
|
|
"results": []interface{}{},
|
|
})
|
|
}
|
|
for i := range results {
|
|
resolved := utils.ResolveMediaURL(results[i].LibraryID, results[i].CoverImagePath)
|
|
results[i].CoverImagePath = pgtype.Text{String: resolved, Valid: resolved != ""}
|
|
}
|
|
return c.JSON(http.StatusOK, results)
|
|
}
|
|
|
|
// handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.)
|
|
// Returns distinct field values with counts and similarity scores for dropdown population
|
|
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {
|
|
libraryID := c.QueryParam("library_id")
|
|
if libraryID == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id is required"})
|
|
}
|
|
|
|
libUUID, err := uuid.Parse(libraryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
}
|
|
|
|
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
|
if limit == 0 {
|
|
limit = 50
|
|
}
|
|
offset, _ := strconv.Atoi(c.QueryParam("offset"))
|
|
|
|
params := services.FieldSearchParams{
|
|
UserID: userID,
|
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
|
FieldType: fieldType,
|
|
SearchQuery: searchQuery,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
}
|
|
|
|
results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params)
|
|
if err != nil {
|
|
c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType)
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"results": results,
|
|
"total": len(results),
|
|
})
|
|
}
|
|
|
|
// getFullFilePath returns the absolute filesystem path for a media item
|
|
// Uses LibraryService for resolution (one source of truth)
|
|
func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
|
|
if relativePath == "" {
|
|
return "", fmt.Errorf("no file path")
|
|
}
|
|
|
|
// Check if already absolute (backward compatibility)
|
|
if filepath.IsAbs(relativePath) {
|
|
return relativePath, nil
|
|
}
|
|
|
|
// Use service for resolution (one source of truth)
|
|
return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath)
|
|
}
|
|
|
|
// ServeFile serves stored library files (covers and books).
|
|
//
|
|
// Two URL forms funnel into this handler:
|
|
//
|
|
// /uploads/library-{libraryID}/{relativePath} (covers, reader files)
|
|
// /api/media-items/{mediaItemID}/download (explicit book download)
|
|
//
|
|
// Both require JWT authentication and that the authenticated user can see
|
|
// the library owning the file - library visibility is the permission gate.
|
|
func (mh *MediaHandler) ServeFile(c *echo.Context) error {
|
|
var libraryUUID pgtype.UUID
|
|
var relativePath string
|
|
|
|
rawPath := c.Param("*")
|
|
if rawPath != "" {
|
|
// Path form: /uploads/library-{libraryID}/{relativePath}
|
|
libraryIDStr := c.Param("id")
|
|
parsed, err := uuid.Parse(libraryIDStr)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library ID"})
|
|
}
|
|
libraryUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
|
|
relativePath, err = url.QueryUnescape(rawPath)
|
|
if err != nil {
|
|
relativePath = rawPath
|
|
}
|
|
|
|
if relativePath == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"})
|
|
}
|
|
} else {
|
|
// Item form: /api/media-items/{mediaItemID}/download
|
|
itemUUID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item ID"})
|
|
}
|
|
|
|
mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: itemUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
|
}
|
|
|
|
if !mediaItem.LibraryID.Valid || mediaItem.FilePath == "" {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found"})
|
|
}
|
|
|
|
libraryUUID = mediaItem.LibraryID
|
|
relativePath = mediaItem.FilePath
|
|
|
|
// Explicit download endpoint: suggest saving instead of inline display.
|
|
filename := strings.Map(func(r rune) rune {
|
|
if r == '"' || r == '\\' || r == '/' {
|
|
return -1
|
|
}
|
|
return r
|
|
}, filepath.Base(relativePath))
|
|
c.Response().Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
|
}
|
|
|
|
// Library visibility gate: the library is what grants permission to
|
|
// see and download media.
|
|
user := c.Get("user").(database.Users)
|
|
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()
|
|
}
|