package handlers import ( "bookhoard/internal/database" "bookhoard/internal/services" wsync "bookhoard/internal/sync" "bookhoard/internal/utils" "context" "encoding/json" "errors" "fmt" "io" "log" "mime/multipart" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) // CreateMediaItemRequest represents the request for creating a media item type CreateMediaItemRequest struct { LibraryID uuid.UUID `json:"library_id" validate:"required"` Title string `json:"title" validate:"required,min=1,max=500"` Author string `json:"author"` ISBN string `json:"isbn"` Description string `json:"description"` FilePath string `json:"file_path" validate:"required"` FileSize int64 `json:"file_size" validate:"required,min=1"` MimeType string `json:"mime_type" validate:"required"` CoverImagePath string `json:"cover_image_path"` Series string `json:"series"` SeriesNumber int32 `json:"series_number"` Tags []string `json:"tags"` ASIN string `json:"asin"` DatePublished string `json:"date_published"` Publisher string `json:"publisher"` Contributors []string `json:"contributors"` // NEW: Reading direction and comic metadata fields MangaType string `json:"manga_type"` // 'unknown' | 'no' | 'yes' | 'yes_and_right_to_left' ReadingDirection string `json:"reading_direction"` // 'auto' | 'ltr' | 'rtl' | 'vertical' SeriesCount int32 `json:"series_count"` Volume int32 `json:"volume"` Imprint string `json:"imprint"` AgeRating string `json:"age_rating"` // 'Everyone' | 'Teen' | 'Mature' | 'Adult' WebURL string `json:"web_url"` MetadataNotes string `json:"metadata_notes"` CommunityRating float64 `json:"community_rating"` StoryArc string `json:"story_arc"` IsBlackAndWhite bool `json:"is_black_and_white"` AlternateInfo string `json:"alternate_info"` // JSON string ScanInformation string `json:"scan_information"` Summary string `json:"summary"` // Audio editorial facts (A2a) Narrators []string `json:"narrators"` Abridged bool `json:"abridged"` } // UpdateMediaItemRequest represents the request for updating a media item type UpdateMediaItemRequest struct { Title string `form:"title" json:"title" validate:"required,min=1,max=500"` Author string `form:"author" json:"author"` ISBN string `form:"isbn" json:"isbn"` Description string `form:"description" json:"description"` CoverImagePath string `form:"cover_image_path" json:"cover_image_path"` CoverAction string `form:"cover_action" json:"cover_action"` Series string `form:"series" json:"series"` SeriesNumber int32 `form:"series_number" json:"series_number"` Tags []string `form:"tags" json:"tags"` ASIN string `form:"asin" json:"asin"` DatePublished string `form:"date_published" json:"date_published"` Publisher string `form:"publisher" json:"publisher"` Contributors []string `form:"contributors" json:"contributors"` Language string `form:"language" json:"language"` Edition string `form:"edition" json:"edition"` PageCount int32 `form:"page_count" json:"page_count"` Genre string `form:"genre" json:"genre"` CopyrightYear int32 `form:"copyright_year" json:"copyright_year"` GoodreadsID string `form:"goodreads_id" json:"goodreads_id"` OpenlibraryID string `form:"openlibrary_id" json:"openlibrary_id"` GoogleBooksID string `form:"google_books_id" json:"google_books_id"` MangaType string `form:"manga_type" json:"manga_type"` ReadingDirection string `form:"reading_direction" json:"reading_direction"` SeriesCount int32 `form:"series_count" json:"series_count"` Volume int32 `form:"volume" json:"volume"` Imprint string `form:"imprint" json:"imprint"` AgeRating string `form:"age_rating" json:"age_rating"` WebURL string `form:"web_url" json:"web_url"` MetadataNotes string `form:"metadata_notes" json:"metadata_notes"` CommunityRating float64 `form:"community_rating" json:"community_rating"` StoryArc string `form:"story_arc" json:"story_arc"` IsBlackAndWhite bool `form:"is_black_and_white" json:"is_black_and_white"` AlternateInfo string `form:"alternate_info" json:"alternate_info"` ScanInformation string `form:"scan_information" json:"scan_information"` Summary string `form:"summary" json:"summary"` // Audiobook editorial facts (A2a): narrators verbatim display order; // abridged as the Libation/Audible flag. Narrators []string `form:"narrators" json:"narrators"` Abridged bool `form:"abridged" json:"abridged"` } // CreateMediaNoteRequest represents the request for creating a media note type CreateMediaNoteRequest struct { Content string `json:"content" validate:"required,min=1,max=100000"` Position string `json:"position"` } // UpdateMediaNoteRequest represents the request for updating a media note type UpdateMediaNoteRequest struct { Content string `json:"content" validate:"required,min=1,max=100000"` Position string `json:"position"` } // CreateMediaHighlightRequest represents the request for creating a media highlight type CreateMediaHighlightRequest struct { SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"` StartPosition string `json:"start_position" validate:"max=1000"` EndPosition string `json:"end_position" validate:"max=1000"` EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"` EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"` Color string `json:"color" validate:"omitempty,len=7"` NoteText string `json:"note_text" validate:"max=100000"` NoteID string `json:"note_id"` PercentageStart float64 `json:"percentage_start"` PercentageEnd float64 `json:"percentage_end"` ChapterReference int32 `json:"chapter_reference"` } // UpdateMediaHighlightRequest represents the request for updating a media highlight type UpdateMediaHighlightRequest struct { SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"` StartPosition string `json:"start_position" validate:"max=1000"` EndPosition string `json:"end_position" validate:"max=1000"` EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"` EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"` Color string `json:"color" validate:"omitempty,len=7"` NoteText string `json:"note_text" validate:"max=100000"` NoteID string `json:"note_id"` PercentageStart float64 `json:"percentage_start"` PercentageEnd float64 `json:"percentage_end"` ChapterReference int32 `json:"chapter_reference"` } // CreateMediaBookmarkRequest represents the request for creating a media bookmark type CreateMediaBookmarkRequest struct { Title string `json:"title" validate:"required,min=1,max=255"` Position string `json:"position" validate:"max=100"` Notes string `json:"notes" validate:"max=100000"` CfiPosition string `json:"cfi_position" validate:"max=255"` PageNumber int32 `json:"page_number"` ChapterNumber int32 `json:"chapter_number"` Percentage float64 `json:"percentage"` ChapterReference int32 `json:"chapter_reference"` // Origin labels the creating client for display ("android", "web"). // Optional: defaults to "web" for browser callers. Origin string `json:"origin" validate:"omitempty,max=30"` } // UpdateMediaBookmarkRequest represents the request for updating a media bookmark type UpdateMediaBookmarkRequest struct { Title string `json:"title" validate:"required,min=1,max=255"` Notes string `json:"notes" validate:"max=100000"` Position string `json:"position" validate:"max=100"` // Optional locator fields (H3): the create endpoint has always // accepted these; the PUT ignored them until it was routed through // the sync service. Zero-valued fields preserve the stored values. CfiPosition string `json:"cfi_position" validate:"max=255"` PageNumber int32 `json:"page_number"` ChapterNumber int32 `json:"chapter_number"` Percentage float64 `json:"percentage"` ChapterReference int32 `json:"chapter_reference"` } type MediaHandler struct { db *database.Queries worker *services.Worker libraryService *services.LibraryService searchService *services.SearchService progressSvc *wsync.ProgressService annotationSvc *wsync.AnnotationService } // NewMediaHandler requires the shared ProgressService — progress writes // must funnel through SaveProgress (the LWW-seeded canonical writer); // a nil service has nowhere to fall back to and must not be wired. func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, progressSvc *wsync.ProgressService, annotationSvc *wsync.AnnotationService, worker ...*services.Worker) *MediaHandler { mh := &MediaHandler{ db: db, libraryService: libraryService, searchService: services.NewSearchService(db), progressSvc: progressSvc, annotationSvc: annotationSvc, } if len(worker) > 0 && worker[0] != nil { mh.worker = worker[0] } return mh } // ExecuteSearch performs search and returns results with count // Public wrapper for shared search logic used by both JSON and HTML endpoints func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) { return h.searchService.ExecuteSearch(ctx, params) } type AddToShelfRequest struct { MediaItemIDs []string `json:"media_item_ids" validate:"required"` ShelfName string `json:"shelf_name"` ShelfPosition int `json:"shelf_position"` } func (h *MediaHandler) AddToShelf(c *echo.Context) error { deviceUUID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) } var req AddToShelfRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"}) } if req.ShelfName == "" { req.ShelfName = "Default" } pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true} addedCount := 0 for _, mediaID := range req.MediaItemIDs { mediaUUID, err := uuid.Parse(mediaID) if err != nil { continue } _, err = h.db.AddBookToKoboShelf(c.Request().Context(), database.AddBookToKoboShelfParams{ DeviceID: pgDeviceUUID, MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, ShelfName: pgtype.Text{String: req.ShelfName, Valid: true}, ShelfPosition: pgtype.Int4{Int32: int32(req.ShelfPosition), Valid: true}, }) if err == nil { addedCount++ req.ShelfPosition++ } } return c.JSON(http.StatusOK, map[string]interface{}{ "status": "success", "added_count": addedCount, "shelf_name": req.ShelfName, "total_count": len(req.MediaItemIDs), }) } func (h *MediaHandler) GetShelf(c *echo.Context) error { deviceUUID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) } shelfName := c.QueryParam("shelf") pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true} var shelfBooks []database.GetKoboShelfBooksByShelfNameRow if shelfName != "" { shelfBooks, err = h.db.GetKoboShelfBooksByShelfName(c.Request().Context(), database.GetKoboShelfBooksByShelfNameParams{ DeviceID: pgDeviceUUID, ShelfName: pgtype.Text{String: shelfName, Valid: true}, }) } else { var defaultBooks []database.GetKoboShelfBooksRow defaultBooks, err = h.db.GetKoboShelfBooks(c.Request().Context(), pgDeviceUUID) shelfBooks = make([]database.GetKoboShelfBooksByShelfNameRow, len(defaultBooks)) for i, b := range defaultBooks { shelfBooks[i] = database.GetKoboShelfBooksByShelfNameRow{ ID: b.ID, DeviceID: b.DeviceID, MediaItemID: b.MediaItemID, ShelfName: b.ShelfName, ShelfPosition: b.ShelfPosition, AddedAt: b.AddedAt, LastSyncedAt: b.LastSyncedAt, Title: b.Title, Author: b.Author, MimeType: b.MimeType, EntitlementID: b.EntitlementID, KoboContentID: b.KoboContentID, RevisionNumber: b.RevisionNumber, } } } if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to fetch shelf"}) } type ShelfBookResponse struct { ID string `json:"id"` MediaItemID string `json:"media_item_id"` Title string `json:"title"` Author string `json:"author"` FilePath string `json:"file_path"` MimeType string `json:"mime_type"` ShelfName string `json:"shelf_name"` ShelfPosition int `json:"shelf_position"` EntitlementID string `json:"entitlement_id,omitempty"` KoboContentID string `json:"kobo_content_id,omitempty"` RevisionNumber int `json:"revision_number"` AddedAt string `json:"added_at"` } response := []ShelfBookResponse{} for _, book := range shelfBooks { response = append(response, ShelfBookResponse{ ID: uuid.UUID(book.ID.Bytes).String(), MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(), Title: book.Title, Author: book.Author.String, MimeType: book.MimeType.String, ShelfName: book.ShelfName.String, ShelfPosition: int(book.ShelfPosition.Int32), EntitlementID: book.EntitlementID.String, KoboContentID: book.KoboContentID.String, RevisionNumber: int(book.RevisionNumber.Int32), AddedAt: book.AddedAt.Time.Format("2006-01-02T15:04:05Z07:00"), }) } return c.JSON(http.StatusOK, map[string]interface{}{ "status": "success", "device_id": deviceUUID.String(), "shelf_name": shelfName, "books": response, "total": len(response), }) } func (h *MediaHandler) RemoveFromShelf(c *echo.Context) error { deviceUUID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) } mediaUUID, err := uuid.Parse(c.QueryParam("media_item_id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item ID"}) } pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true} pgMediaUUID := pgtype.UUID{Bytes: mediaUUID, Valid: true} err = h.db.RemoveBookFromKoboShelf(c.Request().Context(), database.RemoveBookFromKoboShelfParams{ DeviceID: pgDeviceUUID, MediaItemID: pgMediaUUID, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to remove from shelf"}) } return c.JSON(http.StatusOK, map[string]interface{}{ "status": "success", "message": "book removed from shelf", }) } func (h *MediaHandler) ClearShelf(c *echo.Context) error { deviceUUID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) } shelfName := c.QueryParam("shelf") pgDeviceUUID := pgtype.UUID{Bytes: deviceUUID, Valid: true} var result string if shelfName != "" { err = h.db.ClearKoboShelfByName(c.Request().Context(), database.ClearKoboShelfByNameParams{ DeviceID: pgDeviceUUID, ShelfName: pgtype.Text{String: shelfName, Valid: true}, }) result = "shelf cleared" } else { err = h.db.ClearKoboShelf(c.Request().Context(), pgDeviceUUID) result = "all shelves cleared" } if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to clear shelf"}) } return c.JSON(http.StatusOK, map[string]interface{}{ "status": "success", "message": result, }) } // Bulk Operations // POST /api/media-items/bulk-delete // Bulk delete media items func (h *MediaHandler) HandleBulkDelete(c *echo.Context) error { var req struct { MediaItemIDs []string `json:"media_item_ids" validate:"required"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) } if len(req.MediaItemIDs) == 0 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "media_item_ids required"}) } results := make([]map[string]interface{}, 0) successCount := 0 failedCount := 0 for _, mediaIDStr := range req.MediaItemIDs { mediaID, err := uuid.Parse(mediaIDStr) if err != nil { results = append(results, map[string]interface{}{ "media_item_id": mediaIDStr, "status": "error", "error": "Invalid UUID", }) failedCount++ continue } mediaUUID := pgtype.UUID{Bytes: mediaID, Valid: true} media, err := h.db.GetMediaItem(c.Request().Context(), mediaUUID) if err != nil { results = append(results, map[string]interface{}{ "media_item_id": mediaIDStr, "status": "error", "error": "Media item not found", }) failedCount++ continue } // Storage-down guard: deleting cascades the item's annotations, so // refuse while the library storage is unreachable — the file would // survive as an orphan and re-import bare on the next scan. storage := h.resolveMediaStorage(c.Request().Context(), uuid.UUID(media.LibraryID.Bytes).String(), media.FilePath) if !storage.Reachable { results = append(results, map[string]interface{}{ "media_item_id": mediaIDStr, "status": "error", "error": "storage unreachable — archive the item instead, or retry when the storage is mounted", }) failedCount++ continue } err = h.db.DeleteMediaItem(c.Request().Context(), mediaUUID) if err != nil { results = append(results, map[string]interface{}{ "media_item_id": mediaIDStr, "status": "error", "error": err.Error(), }) failedCount++ continue } if storage.AbsolutePath != "" { if err := os.Remove(storage.AbsolutePath); err != nil { // Log file removal failure but don't fail the request // DB record is already deleted, which is the primary concern log.Printf("Warning: failed to remove file %s: %v", storage.AbsolutePath, err) } } results = append(results, map[string]interface{}{ "media_item_id": mediaIDStr, "status": "success", }) successCount++ } return c.JSON(http.StatusOK, map[string]interface{}{ "results": results, "total": len(req.MediaItemIDs), "deleted": successCount, "failed": failedCount, }) } // POST /api/media-items/bulk-update // Bulk update media item metadata func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error { var req struct { MediaItemUpdates []struct { MediaItemID string `json:"media_item_id" validate:"required"` Updates struct { Title *string `json:"title,omitempty"` Author *string `json:"author,omitempty"` Genre *string `json:"genre,omitempty"` Language *string `json:"language,omitempty"` Tags []string `json:"tags,omitempty"` } `json:"updates"` } `json:"media_item_updates" validate:"required"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) } if len(req.MediaItemUpdates) == 0 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "media_item_updates required"}) } for _, update := range req.MediaItemUpdates { if update.MediaItemID == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "media_item_id cannot be empty"}) } } results := make([]map[string]interface{}, 0) successCount := 0 failedCount := 0 for _, update := range req.MediaItemUpdates { mediaID, err := uuid.Parse(update.MediaItemID) if err != nil { results = append(results, map[string]interface{}{ "media_item_id": update.MediaItemID, "status": "error", "error": "Invalid UUID", }) failedCount++ continue } mediaUUID := pgtype.UUID{Bytes: mediaID, Valid: true} existingMedia, err := h.db.GetMediaItem(c.Request().Context(), mediaUUID) if err != nil { results = append(results, map[string]interface{}{ "media_item_id": update.MediaItemID, "status": "error", "error": "Media item not found", }) failedCount++ continue } updateParams := database.UpdateMediaItemParams{ ID: mediaUUID, Title: existingMedia.Title, Author: existingMedia.Author, Genre: existingMedia.Genre, Language: existingMedia.Language, Tags: existingMedia.Tags, TagsSearch: existingMedia.TagsSearch, Description: existingMedia.Description, Publisher: existingMedia.Publisher, CopyrightYear: existingMedia.CopyrightYear, Isbn: existingMedia.Isbn, Series: existingMedia.Series, SeriesNumber: existingMedia.SeriesNumber, Asin: existingMedia.Asin, DatePublished: existingMedia.DatePublished, Contributors: existingMedia.Contributors, ContributorsSearch: existingMedia.ContributorsSearch, Edition: existingMedia.Edition, PageCount: existingMedia.PageCount, GoodreadsID: existingMedia.GoodreadsID, OpenlibraryID: existingMedia.OpenlibraryID, GoogleBooksID: existingMedia.GoogleBooksID, CoverImagePath: existingMedia.CoverImagePath, MangaType: existingMedia.MangaType, ReadingDirection: existingMedia.ReadingDirection, SeriesCount: existingMedia.SeriesCount, Volume: existingMedia.Volume, Imprint: existingMedia.Imprint, AgeRating: existingMedia.AgeRating, WebUrl: existingMedia.WebUrl, MetadataNotes: existingMedia.MetadataNotes, CommunityRating: existingMedia.CommunityRating, StoryArc: existingMedia.StoryArc, IsBlackAndWhite: existingMedia.IsBlackAndWhite, AlternateInfo: existingMedia.AlternateInfo, ScanInformation: existingMedia.ScanInformation, Summary: existingMedia.Summary, } // Bulk edits are user customizations: keep existing overrides and mark // each applied update as overridden so scans preserve it. overrides := existingMedia.MetadataOverrides if update.Updates.Title != nil { updateParams.Title = *update.Updates.Title overrides = utils.MergeOverrides(overrides, utils.OverrideTitle) } if update.Updates.Author != nil { updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true} overrides = utils.MergeOverrides(overrides, utils.OverrideAuthor) } if update.Updates.Genre != nil { updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true} overrides = utils.MergeOverrides(overrides, utils.OverrideGenre) } if update.Updates.Language != nil { updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true} overrides = utils.MergeOverrides(overrides, utils.OverrideLanguage) } if len(update.Updates.Tags) > 0 { normalizedTags := utils.NormalizeTags(update.Updates.Tags) updateParams.Tags = normalizedTags tagsSearch := utils.NormalizeTagsSearch(update.Updates.Tags) updateParams.TagsSearch = tagsSearch overrides = utils.MergeOverrides(overrides, utils.OverrideTags) } updateParams.MetadataOverrides = overrides _, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams) if err != nil { results = append(results, map[string]interface{}{ "media_item_id": update.MediaItemID, "status": "error", "error": err.Error(), }) failedCount++ continue } results = append(results, map[string]interface{}{ "media_item_id": update.MediaItemID, "status": "success", }) successCount++ } return c.JSON(http.StatusOK, map[string]interface{}{ "results": results, "total": len(req.MediaItemUpdates), "updated": successCount, "failed": failedCount, }) } // visibleLibrarySet loads the requester's visible libraries — membership // map for param checks, id slice for SQL ANY() filters. func (mh *MediaHandler) visibleLibrarySet(c *echo.Context) (map[string]bool, []pgtype.UUID, error) { user, ok := c.Get("user").(database.Users) if !ok { return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user") } libraries, err := mh.libraryService.GetUserVisibleLibraries(c.Request().Context(), user.ID) if err != nil { return nil, nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to check library access") } set := make(map[string]bool, len(libraries)) ids := make([]pgtype.UUID, 0, len(libraries)) for _, lib := range libraries { if !lib.ID.Valid { continue } set[uuid.UUID(lib.ID.Bytes).String()] = true ids = append(ids, lib.ID) } return set, ids, nil } // mediaStorageState reports whether the library storage holding a media // item's file is currently reachable, and resolves the file's absolute // path when it can be located. // // Why this exists: deleting a media item cascades its annotations, so the // delete must never proceed while the storage is unreachable — otherwise // a NAS outage turns an admin cleanup into permanent annotation loss // (the DB row dies, the orphaned file re-imports bare on the next scan). type mediaStorageState struct { // Absolute path of the file when located on reachable storage; // empty when the file is missing or reachability is undeterminable. AbsolutePath string // Reachable is false ONLY when the library has folders and every one // of them fails to stat — positive evidence the mount is down. When // no folders are configured reachability is undeterminable and this // is true (delete proceeds; behavior unchanged from before this check). Reachable bool } func (mh *MediaHandler) resolveMediaStorage(ctx context.Context, libraryID string, relativePath string) mediaStorageState { libUUID, err := uuid.Parse(libraryID) if err != nil { return mediaStorageState{Reachable: true} } folders, err := mh.libraryService.GetLibraryFolders(ctx, pgtype.UUID{Bytes: libUUID, Valid: true}) if err != nil || len(folders) == 0 { return mediaStorageState{Reachable: true} } anyFolderUp := false for _, f := range folders { if _, err := os.Stat(f.FolderPath); err != nil { continue // folder root unreachable } anyFolderUp = true candidate := filepath.Join(f.FolderPath, filepath.FromSlash(relativePath)) if _, err := os.Stat(candidate); err == nil { return mediaStorageState{AbsolutePath: candidate, Reachable: true} } } if !anyFolderUp { return mediaStorageState{Reachable: false} } // Storage is up but the file is genuinely gone: a stale row is safe // to delete. return mediaStorageState{Reachable: true} } // ListMediaItems handles GET /api/media-items func (mh *MediaHandler) ListMediaItems(c *echo.Context) error { libraryID := c.QueryParam("library_id") sort := c.QueryParam("sort") limit, _ := strconv.Atoi(c.QueryParam("limit")) offset, _ := strconv.Atoi(c.QueryParam("offset")) if limit == 0 { limit = 50 } if sort == "" { sort = "created_at DESC" } allowedSorts := map[string]bool{ "created_at ASC": true, "created_at DESC": true, "title ASC": true, "title DESC": true, "author ASC": true, "author DESC": true, "series ASC": true, "series DESC": true, "date_published ASC": true, "date_published DESC": true, "copyright_year ASC": true, "copyright_year DESC": true, "page_count ASC": true, "page_count DESC": true, "genre ASC": true, "genre DESC": true, } if !allowedSorts[sort] { sort = "created_at DESC" } if limit > maxPaginationLimit { limit = maxPaginationLimit } if offset < 0 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "offset cannot be negative"}) } if libraryID != "" { libUUID, err := uuid.Parse(libraryID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"}) } visibleSet, _, err := mh.visibleLibrarySet(c) if err != nil { return err } if !visibleSet[libraryID] { return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"}) } items, err := mh.db.ListMediaItemsSorted(c.Request().Context(), database.ListMediaItemsSortedParams{ LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, Sort: pgtype.Text{String: sort, Valid: true}, Limit: pgtype.Int4{Int32: int32(limit), Valid: true}, Offset: pgtype.Int4{Int32: int32(offset), Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]interface{}{"data": items}) } _, visibleIDs, err := mh.visibleLibrarySet(c) if err != nil { return err } items, err := mh.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{ Limit: int32(limit), Offset: int32(offset), VisibleLibraryIds: visibleIDs, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } resolvedItems := make([]map[string]interface{}, len(items)) for i, item := range items { resolvedItems[i] = map[string]interface{}{ "id": uuid.UUID(item.ID.Bytes).String(), "library_id": uuid.UUID(item.LibraryID.Bytes).String(), "title": item.Title, "author": textToString(item.Author), "isbn": textToString(item.Isbn), "description": textToString(item.Description), "file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}), "file_size": item.FileSize, "mime_type": textToString(item.MimeType), "cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), "series": textToString(item.Series), "series_number": item.SeriesNumber, "tags": item.Tags, "asin": textToString(item.Asin), "date_published": item.DatePublished.Time.Format("2006-01-02"), "publisher": textToString(item.Publisher), "contributors": item.Contributors, "language": textToString(item.Language), "edition": textToString(item.Edition), "page_count": item.PageCount, "genre": textToString(item.Genre), "created_at": item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), "updated_at": item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), "format_group": item.FormatGroup, // already string // NEW: Reading direction fields "manga_type": textToString(item.MangaType), "reading_direction": textToString(item.ReadingDirection), // NEW: Universal metadata fields "series_count": item.SeriesCount, "volume": item.Volume, "imprint": textToString(item.Imprint), "age_rating": textToString(item.AgeRating), "web_url": textToString(item.WebUrl), "metadata_notes": textToString(item.MetadataNotes), "community_rating": numericToFloat(item.CommunityRating), // NEW: Comic-specific fields "story_arc": textToString(item.StoryArc), "is_black_and_white": item.IsBlackAndWhite, "alternate_info": jsonBytesToMap(item.AlternateInfo), "scan_information": textToString(item.ScanInformation), "summary": textToString(item.Summary), } } return c.JSON(http.StatusOK, map[string]interface{}{"data": resolvedItems}) } // GetMediaItem handles GET /api/media-items/:id func (mh *MediaHandler) GetMediaItem(c *echo.Context) error { mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } // Visibility gate: hidden libraries don't leak row detail by id. visibleSet, _, err := mh.visibleLibrarySet(c) if err != nil { return err } if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] { return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"}) } return c.JSON(http.StatusOK, map[string]interface{}{ "id": uuid.UUID(item.ID.Bytes).String(), "library_id": uuid.UUID(item.LibraryID.Bytes).String(), "title": item.Title, "author": textToString(item.Author), "isbn": textToString(item.Isbn), "description": textToString(item.Description), "file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}), "file_size": item.FileSize, "mime_type": textToString(item.MimeType), "cover_image_path": utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), "series": textToString(item.Series), "series_number": item.SeriesNumber, "tags": item.Tags, "asin": textToString(item.Asin), "date_published": item.DatePublished.Time.Format("2006-01-02"), "publisher": textToString(item.Publisher), "contributors": item.Contributors, "language": textToString(item.Language), "edition": textToString(item.Edition), "page_count": item.PageCount, "genre": textToString(item.Genre), "created_at": item.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), "updated_at": item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), "format_group": item.FormatGroup, "format_mimetype": textToString(item.FormatMimetype), "narrators": item.Narrators, "abridged": item.Abridged, // NEW: Reading direction fields "manga_type": textToString(item.MangaType), "reading_direction": textToString(item.ReadingDirection), // NEW: Universal metadata fields "series_count": item.SeriesCount, "volume": item.Volume, "imprint": textToString(item.Imprint), "age_rating": textToString(item.AgeRating), "web_url": textToString(item.WebUrl), "metadata_notes": textToString(item.MetadataNotes), "community_rating": numericToFloat(item.CommunityRating), // NEW: Comic-specific fields "story_arc": textToString(item.StoryArc), "is_black_and_white": item.IsBlackAndWhite, "alternate_info": jsonBytesToMap(item.AlternateInfo), "scan_information": textToString(item.ScanInformation), "summary": textToString(item.Summary), "library_type_name": textPtrToString(item.LibraryTypeName), }) } // CreateMediaRating handles POST /api/media-items/:id/rating func (mh *MediaHandler) CreateMediaRating(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } var req struct { Rating int32 `json:"rating" validate:"required,min=1,max=10"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } rating, err := mh.db.CreateMediaRating(c.Request().Context(), database.CreateMediaRatingParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, Rating: req.Rating, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusCreated, rating) } // GetMediaRating handles GET /api/media-items/:id/rating func (mh *MediaHandler) GetMediaRating(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } rating, err := mh.db.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusOK, map[string]interface{}{"rating": nil}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, rating) } // UpdateMediaRating handles PUT /api/media-items/:id/rating func (mh *MediaHandler) UpdateMediaRating(c *echo.Context) error { return mh.CreateMediaRating(c) } // DeleteMediaRating handles DELETE /api/media-items/:id/rating func (mh *MediaHandler) DeleteMediaRating(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } err = mh.db.DeleteMediaRating(c.Request().Context(), database.DeleteMediaRatingParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]string{"message": "rating deleted"}) } // UpsertMediaReview handles POST and PUT /api/media-items/:id/review. // The review body is RAW MARKDOWN (same doctrine as notes): storage and // every sync payload carry the source string; rendering happens only at // display sites. An empty body deletes the review — clearing via the // editor is a delete, not an empty row. func (mh *MediaHandler) UpsertMediaReview(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } var req struct { Review string `json:"review" validate:"max=50000"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } trimmed := strings.TrimSpace(req.Review) if trimmed == "" { err := mh.db.DeleteMediaReview(c.Request().Context(), database.DeleteMediaReviewParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]interface{}{"review": nil}) } review, err := mh.db.UpsertMediaReview(c.Request().Context(), database.UpsertMediaReviewParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, Review: req.Review, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, review) } // GetMediaReview handles GET /api/media-items/:id/review func (mh *MediaHandler) GetMediaReview(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } review, err := mh.db.GetMediaReview(c.Request().Context(), database.GetMediaReviewParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusOK, map[string]interface{}{"review": nil}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, review) } // DeleteMediaReview handles DELETE /api/media-items/:id/review func (mh *MediaHandler) DeleteMediaReview(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } err = mh.db.DeleteMediaReview(c.Request().Context(), database.DeleteMediaReviewParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]string{"message": "review deleted"}) } // GetMediaReadingProgress handles GET /api/media-items/:id/progress func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } progress, err := mh.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusOK, map[string]interface{}{ "current_page": 0, "total_pages": nil, }) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } resp := progressResponse(progress) // Position authority: re-verify the stored anchor and derive the // client-facing handles (cssSelector + anchor document href) server- // side, so clients scroll to an address instead of parsing CFIs. if progress.Epubcfi.Valid && wsync.IsConvertibleFormat(progress.FormatGroup) && mh.libraryService != nil { contextText := "" if progress.ContextText.Valid { contextText = progress.ContextText.String } pct := 0.0 if progress.Percentage.Valid { pct = progress.Percentage.Float64 } if mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err == nil { if path, perr := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath); perr == nil && path != "" { if finalCFI, sel, href, charOff, _, _, _, verr := wsync.VerifyProgressAnchor(path, progress.Epubcfi.String, contextText, pct); verr == nil && sel != "" { resp["css_selector"] = sel resp["anchor_href"] = href if charOff != nil { resp["char_offset"] = *charOff } if finalCFI != "" { resp["epubcfi"] = finalCFI } } } } } if progress.ContextText.Valid { resp["context_text"] = progress.ContextText.String } return c.JSON(http.StatusOK, resp) } // progressResponse serializes a progress row to the client wire shape — // one place, keyed off the row struct, so new row columns surface here by // construction. Every existing key is preserved verbatim; the row-carried // locator fields (audio/reading_mode/viewport/zoom/scroll/panel) are // additive keys per docs/contract_pins.md (H1). func progressResponse(progress database.GetUniversalProgressRow) map[string]interface{} { return map[string]interface{}{ "id": progress.ID, "media_item_id": progress.MediaItemID, "user_id": progress.UserID, "current_page": progress.CurrentPage, "total_pages": progress.TotalPages, "last_read_at": progress.LastReadAt, "percentage": progress.Percentage, "character_offset": progress.CharacterOffset, "epubcfi": progress.Epubcfi, "chapter": progress.Chapter, "chapter_progress": progress.ChapterProgress, "format_group": progress.FormatGroup, "total_characters": progress.TotalCharacters, "chapter_count": progress.ChapterCount, "last_sync_device": progress.LastSyncDevice, "last_sync_source": progress.LastSyncSource, "last_sync_timestamp": progress.LastSyncTimestamp, // Row-carried fields surfaced additively (H1) — the web audiobook // reader already reads audio_offset_seconds (audio.ts) and the // app parses the same names. "audio_offset_seconds": progress.AudioOffsetSeconds, "reading_mode": progress.ReadingMode, "viewport_x": progress.ViewportX, "viewport_y": progress.ViewportY, "zoom_level": progress.ZoomLevel, "scroll_position_x": progress.ScrollPositionX, "scroll_position_y": progress.ScrollPositionY, "panel_number": progress.PanelNumber, } } // UpdateMediaReadingProgress handles PUT /api/media-items/:id/progress func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } // Reading-stats attribution: registered clients may send their device // token in X-Device-Token (the user JWT stays the primary auth). A // token that maps to a device owned by THIS user attaches the push — // and everything the reading history records about it — to that // device's registered name/type ("Pixel 9" · mobile, "Kindle… " · // koreader). Absent/invalid/foreign tokens soft-fail to the plain // web attribution; a stats decoration must never fail a sync. deviceID := pgtype.UUID{} deviceSource := "web" deviceLabel := "" deviceType := "" if token := c.Request().Header.Get("X-Device-Token"); token != "" { device, devErr := mh.db.GetDeviceByAuthToken(c.Request().Context(), token) if devErr == nil && device.UserID.Valid && uuid.UUID(device.UserID.Bytes) == userUUID { deviceID = device.ID deviceLabel = device.DeviceName deviceType = device.DeviceType deviceSource = device.DeviceName if strings.TrimSpace(deviceSource) == "" { deviceSource = device.DeviceType } } } // The canonical write struct binds the wire directly; identity and // attribution fields below are server-owned (json:"-" on the struct) // and copied here once. var req wsync.SaveProgressRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } req.MediaItemID = pgtype.UUID{Bytes: mediaUUID, Valid: true} req.UserID = pgtype.UUID{Bytes: userUUID, Valid: true} req.Source = deviceSource req.DeviceID = deviceID req.DeviceType = deviceType req.DeviceName = deviceLabel req.Broadcast = true if req.Percentage != nil && *req.Percentage < 0.005 { existing, err := mh.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err == nil && existing.Percentage.Valid && existing.Percentage.Float64 > 0.01 { return c.JSON(http.StatusOK, map[string]string{"status": "ignored"}) } } result, err := mh.progressSvc.SaveProgress(c.Request().Context(), req) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, result) } // DeleteMediaReadingProgress handles DELETE /api/media-items/:id/progress func (mh *MediaHandler) DeleteMediaReadingProgress(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } err = mh.db.DeleteReadingProgress(c.Request().Context(), database.DeleteReadingProgressParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]string{"message": "reading progress deleted"}) } // CreateMediaItem handles POST /api/media-items (admin only) func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error { user := MustGetAuthenticatedUser(c) if user.Role != "admin" { return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) } var req CreateMediaItemRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } if len(req.Tags) > 0 { req.Tags = utils.NormalizeTags(req.Tags) } if len(req.Contributors) > 0 { req.Contributors = utils.NormalizeContributors(req.Contributors) } tagsSearch := utils.NormalizeTagsSearch(req.Tags) contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors) narrators := normalizeNarrators(req.Narrators) narratorsSearch := utils.NormalizeNarratorsSearch(narrators) // Validate and normalize ISBN normalizedISBN, err := utils.NormalizeISBN(req.ISBN) if err != nil && req.ISBN != "" { return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"}) } // Use normalized ISBN if valid, otherwise empty string isbnValue := normalizedISBN if err != nil { isbnValue = "" } _, err = mh.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } // Validate library has folders before allowing media items hasFolders, err := mh.libraryService.HasFolders(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true}) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to validate library folders"}) } if !hasFolders { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "Cannot add media items to a library with no folders. Please add at least one folder to the library first.", }) } // Race-safe create-or-detect: on a duplicate (library_id, file_path) // the insert writes nothing and returns no row — answer with the // EXISTING item, a duplicate flag, and 200 OK (not a 201 lie). created, err := mh.db.CreateMediaItemIfNew(c.Request().Context(), database.CreateMediaItemIfNewParams{ LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true}, Title: req.Title, Author: pgtype.Text{String: req.Author, Valid: req.Author != ""}, Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""}, Description: pgtype.Text{String: req.Description, Valid: req.Description != ""}, FilePath: req.FilePath, FileSize: pgtype.Int8{Int64: req.FileSize, Valid: req.FileSize > 0}, MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""}, CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""}, Series: pgtype.Text{String: req.Series, Valid: req.Series != ""}, SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0}, Tags: req.Tags, TagsSearch: tagsSearch, Narrators: narrators, NarratorsSearch: narratorsSearch, Abridged: req.Abridged, Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""}, DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""}, Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""}, Contributors: req.Contributors, ContributorsSearch: contributorsSearch, AddedByAdminID: user.ID, }) if errors.Is(err, pgx.ErrNoRows) { existing, gerr := mh.db.GetMediaItemByLibraryAndPath(c.Request().Context(), database.GetMediaItemByLibraryAndPathParams{ LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true}, FilePath: req.FilePath, }) if gerr != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": gerr.Error()}) } return c.JSON(http.StatusOK, map[string]interface{}{ "duplicate": true, "message": "A media item already exists for this file_path in this library", "item": existing, }) } if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: created.ID.Bytes, Valid: true}) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusCreated, item) } // UpdateMediaItem handles PUT /api/media-items/:id (admin only) func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error { user := MustGetAuthenticatedUser(c) if user.Role != "admin" { return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } var req UpdateMediaItemRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } if len(req.Tags) > 0 { req.Tags = utils.NormalizeTags(req.Tags) } if len(req.Contributors) > 0 { req.Contributors = utils.NormalizeContributors(req.Contributors) } tagsSearch := utils.NormalizeTagsSearch(req.Tags) contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors) narrators := normalizeNarrators(req.Narrators) narratorsSearch := utils.NormalizeNarratorsSearch(narrators) normalizedISBN, err := utils.NormalizeISBN(req.ISBN) if err != nil && req.ISBN != "" { return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"}) } isbnValue := normalizedISBN if err != nil { isbnValue = "" } if req.CoverAction == "" { req.CoverAction = "keep" } existing, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"}) } coverPath := existing.CoverImagePath.String coverUploaded := false if req.CoverAction == "remove" { coverPath = "" } else if req.CoverAction == "upload" { file, err := c.FormFile("cover_file") if err == nil { savedPath, err := mh.saveCoverImage(*c, mediaUUID, file) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save cover image"}) } coverPath = savedPath coverUploaded = true } } var alternateInfoBytes []byte if req.AlternateInfo != "" { alternateInfoBytes = []byte(req.AlternateInfo) } params := database.UpdateMediaItemParams{ ID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, Title: req.Title, Author: pgtype.Text{String: req.Author, Valid: req.Author != ""}, Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""}, Description: pgtype.Text{String: req.Description, Valid: req.Description != ""}, CoverImagePath: pgtype.Text{String: coverPath, Valid: coverPath != ""}, Series: pgtype.Text{String: req.Series, Valid: req.Series != ""}, SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0}, Tags: req.Tags, TagsSearch: tagsSearch, Narrators: narrators, NarratorsSearch: narratorsSearch, Abridged: req.Abridged, Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""}, DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""}, Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""}, Contributors: req.Contributors, ContributorsSearch: contributorsSearch, Language: pgtype.Text{String: req.Language, Valid: req.Language != ""}, Edition: pgtype.Text{String: req.Edition, Valid: req.Edition != ""}, PageCount: pgtype.Int4{Int32: req.PageCount, Valid: req.PageCount > 0}, Genre: pgtype.Text{String: req.Genre, Valid: req.Genre != ""}, CopyrightYear: pgtype.Int4{Int32: req.CopyrightYear, Valid: req.CopyrightYear > 0}, GoodreadsID: pgtype.Text{String: req.GoodreadsID, Valid: req.GoodreadsID != ""}, OpenlibraryID: pgtype.Text{String: req.OpenlibraryID, Valid: req.OpenlibraryID != ""}, GoogleBooksID: pgtype.Text{String: req.GoogleBooksID, Valid: req.GoogleBooksID != ""}, MangaType: pgtype.Text{String: req.MangaType, Valid: req.MangaType != ""}, ReadingDirection: pgtype.Text{String: req.ReadingDirection, Valid: req.ReadingDirection != ""}, SeriesCount: pgtype.Int4{Int32: req.SeriesCount, Valid: req.SeriesCount > 0}, Volume: pgtype.Int4{Int32: req.Volume, Valid: req.Volume > 0}, Imprint: pgtype.Text{String: req.Imprint, Valid: req.Imprint != ""}, AgeRating: pgtype.Text{String: req.AgeRating, Valid: req.AgeRating != ""}, WebUrl: pgtype.Text{String: req.WebURL, Valid: req.WebURL != ""}, MetadataNotes: pgtype.Text{String: req.MetadataNotes, Valid: req.MetadataNotes != ""}, CommunityRating: pgtype.Float8{Float64: req.CommunityRating, Valid: req.CommunityRating > 0}, StoryArc: pgtype.Text{String: req.StoryArc, Valid: req.StoryArc != ""}, IsBlackAndWhite: pgtype.Bool{Bool: req.IsBlackAndWhite, Valid: req.IsBlackAndWhite}, AlternateInfo: alternateInfoBytes, ScanInformation: pgtype.Text{String: req.ScanInformation, Valid: req.ScanInformation != ""}, Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""}, } // Fields the user actually changed become overrides so future scans keep // the custom values. An explicit cover upload/removal always overrides. overrides := utils.DetectMetadataOverrides(params, existing) if req.CoverAction == "remove" || coverUploaded { overrides = utils.MergeOverrides(overrides, utils.OverrideCoverImagePath) } params.MetadataOverrides = overrides item, err := mh.db.UpdateMediaItem(c.Request().Context(), params) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } if c.Request().Header.Get("HX-Request") == "true" { c.Response().Header().Set("HX-Redirect", "/media/"+mediaID) } return c.JSON(http.StatusOK, item) } // UnarchiveMediaItem handles POST /api/media-items/:id/unarchive (admin // only). Manually restores a hidden/archived item to library visibility // without waiting for its file to return. If the file is still gone, the // next scan hides it again. func (mh *MediaHandler) UnarchiveMediaItem(c *echo.Context) error { user := MustGetAuthenticatedUser(c) if user.Role != "admin" { return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } if err := mh.db.ClearMediaItemArchive(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.NoContent(http.StatusNoContent) } // MergeArchivedMediaItem handles POST /api/media-items/:id/merge (admin // only). Merges an archived/missing item's reading data (progress, // annotations, highlights, notes, bookmarks, ratings, collections, kobo // shelves) into an active item with identical content (same SHA-256), then // deletes the archived row. Covers the copy-then-delete-later workflow: the // active copy exists, the archived twin holds the real reading history. // Per-user collisions keep the active copy's data, mirroring hash-conflict // resolution. func (mh *MediaHandler) MergeArchivedMediaItem(c *echo.Context) error { user := MustGetAuthenticatedUser(c) if user.Role != "admin" { return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) } sourceUUID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } var req struct { TargetID string `json:"target_id"` } if err := c.Bind(&req); err != nil || req.TargetID == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "target_id is required"}) } targetUUID, err := uuid.Parse(req.TargetID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid target_id"}) } if sourceUUID == targetUUID { return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot merge an item into itself"}) } ctx := c.Request().Context() source, err := mh.db.GetMediaItem(ctx, pgtype.UUID{Bytes: sourceUUID, Valid: true}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusNotFound, map[string]string{"error": "archived item not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } if !source.ArchivedAt.Valid && source.MissingScanCount == 0 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "source item is not archived or missing"}) } target, err := mh.db.GetMediaItem(ctx, pgtype.UUID{Bytes: targetUUID, Valid: true}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusNotFound, map[string]string{"error": "target item not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } if target.ArchivedAt.Valid || target.MissingScanCount > 0 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "target item is not active"}) } if !source.FileSha256.Valid || source.FileSha256.String == "" || source.FileSha256.String != target.FileSha256.String { return c.JSON(http.StatusBadRequest, map[string]string{"error": "items do not share the same content hash"}) } if err := mh.db.ReparentMediaItemChildren(ctx, database.ReparentMediaItemChildrenParams{ Column1: pgtype.UUID{Bytes: targetUUID, Valid: true}, Column2: pgtype.UUID{Bytes: sourceUUID, Valid: true}, }); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to merge reading data: " + err.Error()}) } if err := mh.db.DeleteMediaItem(ctx, pgtype.UUID{Bytes: sourceUUID, Valid: true}); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to remove archived item: " + err.Error()}) } return c.JSON(http.StatusOK, map[string]string{ "message": "Merged reading progress, annotations, and collections into the active item", }) } // PurgeArchivedMediaItems handles POST /api/media-items/purge-archived // (admin only). Hard-deletes every archived item (files missing from disk for // 2+ scans) together with its reading history. The archive retention window // eventually does the same automatically; this is the manual bulk escape hatch. func (mh *MediaHandler) PurgeArchivedMediaItems(c *echo.Context) error { user := MustGetAuthenticatedUser(c) if user.Role != "admin" { return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) } purged, err := mh.db.PurgeAllArchivedMediaItems(c.Request().Context()) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]interface{}{ "purged": len(purged), }) } // RescanMediaItem handles POST /api/media-items/:id/rescan (admin only) func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error { user := MustGetAuthenticatedUser(c) if user.Role != "admin" { return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } if _, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } scanner := services.NewMediaScanner(mh.db) defer scanner.Close() // reset_overrides=true discards user customizations first, returning the // item to pure scanned defaults (the "Reset to Scanned" action). resetOverrides := c.QueryParam("reset_overrides") == "true" if err := scanner.RescanMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}, resetOverrides); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, item) } // DeleteMediaItem handles DELETE /api/media-items/:id (admin only) func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error { user := MustGetAuthenticatedUser(c) if user.Role != "admin" { return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } media, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } // Storage-down guard (see HandleBulkDelete): refuse while the library // storage is unreachable so the cascade can't destroy annotations for // a file that will re-import on the next scan. storage := mh.resolveMediaStorage(c.Request().Context(), uuid.UUID(media.LibraryID.Bytes).String(), media.FilePath) if !storage.Reachable { return c.JSON(http.StatusConflict, map[string]string{ "error": "storage unreachable — archive the item instead, or retry when the storage is mounted", }) } err = mh.db.DeleteMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } if storage.AbsolutePath != "" { if err := os.Remove(storage.AbsolutePath); err != nil { log.Printf("Warning: failed to remove file %s: %v", storage.AbsolutePath, err) } } return c.NoContent(http.StatusNoContent) } // GetMediaNotes handles GET /api/media-items/:id/notes func (mh *MediaHandler) GetMediaNotes(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } notes, err := mh.db.GetMediaNotes(c.Request().Context(), database.GetMediaNotesParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, notes) } // CreateMediaNote handles POST /api/media-items/:id/notes func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } var req CreateMediaNoteRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } result, err := mh.annotationSvc.SaveNote(c.Request().Context(), wsync.SaveNoteRequest{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, Content: req.Content, Position: req.Position, Source: "web", ModifiedAt: time.Now(), }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusCreated, result.Note) } // GetMediaNote handles GET /api/media-items/:id/notes/:noteId func (mh *MediaHandler) GetMediaNote(c *echo.Context) error { noteID := c.Param("noteId") noteUUID, err := uuid.Parse(noteID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"}) } note, err := mh.db.GetMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusNotFound, map[string]string{"error": "note not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, note) } // UpdateMediaNote handles PUT /api/media-items/:id/notes/:noteId func (mh *MediaHandler) UpdateMediaNote(c *echo.Context) error { noteID := c.Param("noteId") noteUUID, err := uuid.Parse(noteID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"}) } userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } var req UpdateMediaNoteRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } // Through the sync service (H3): the PUT lands as a proper LWW edit // targeted by id (locator columns coalesced with the stored row), so // source/modified_at bookkeeping is maintained — the plain query // bypassed it entirely. result, err := mh.annotationSvc.SaveNote(c.Request().Context(), wsync.SaveNoteRequest{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, Content: req.Content, Position: req.Position, Source: "web", ModifiedAt: time.Now(), NoteID: pgtype.UUID{Bytes: noteUUID, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, result.Note) } // DeleteMediaNote handles DELETE /api/media-items/:id/notes/:noteId func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error { noteID := c.Param("noteId") noteUUID, err := uuid.Parse(noteID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"}) } userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } if err := mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}, pgtype.UUID{Bytes: userUUID, Valid: true}); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.NoContent(http.StatusNoContent) } // GetMediaHighlights handles GET /api/media-items/:id/highlights func (mh *MediaHandler) GetMediaHighlights(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } highlights, err := mh.db.GetMediaHighlights(c.Request().Context(), database.GetMediaHighlightsParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, highlights) } // CreateMediaHighlight handles POST /api/media-items/:id/highlights func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } var req CreateMediaHighlightRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } color := "#ffff00" if req.Color != "" { color = req.Color } pgMediaID := pgtype.UUID{Bytes: mediaUUID, Valid: true} pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true} result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ MediaItemID: pgMediaID, UserID: pgUserID, SelectionText: req.SelectionText, StartPosition: req.StartPosition, EndPosition: req.EndPosition, EpubcfiStart: req.EpubcfiStart, EpubcfiEnd: req.EpubcfiEnd, Color: color, NoteText: req.NoteText, PercentageStart: req.PercentageStart, PercentageEnd: req.PercentageEnd, ChapterReference: req.ChapterReference, Source: "web", ModifiedAt: time.Now(), }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusCreated, result.Highlight) } // GetMediaHighlight handles GET /api/media-items/:id/highlights/:highlightId func (mh *MediaHandler) GetMediaHighlight(c *echo.Context) error { highlightID := c.Param("highlightId") highlightUUID, err := uuid.Parse(highlightID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"}) } highlight, err := mh.db.GetMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, highlight) } // UpdateMediaHighlight handles PUT /api/media-items/:id/highlights/:highlightId func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error { highlightID := c.Param("highlightId") highlightUUID, err := uuid.Parse(highlightID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"}) } var req UpdateMediaHighlightRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } color := "#ffff00" if req.Color != "" { color = req.Color } userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, SelectionText: req.SelectionText, StartPosition: req.StartPosition, EndPosition: req.EndPosition, EpubcfiStart: req.EpubcfiStart, EpubcfiEnd: req.EpubcfiEnd, Color: color, NoteText: req.NoteText, PercentageStart: req.PercentageStart, PercentageEnd: req.PercentageEnd, ChapterReference: req.ChapterReference, Source: "web", ModifiedAt: time.Now(), // The PUT targets this exact row (from the URL): identity must // not be re-derived from content — a device echo has usually // rewritten the stored CFI to point shape, so the computed key // would miss and mint a duplicate beside the edited row. HighlightID: pgtype.UUID{Bytes: highlightUUID, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, result.Highlight) } // DeleteMediaHighlight handles DELETE /api/media-items/:id/highlights/:highlightId func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error { highlightID := c.Param("highlightId") highlightUUID, err := uuid.Parse(highlightID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"}) } pgHighlightID := pgtype.UUID{Bytes: highlightUUID, Valid: true} userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true} if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgUserID, pgHighlightID, "web"); err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.NoContent(http.StatusNoContent) } // GetMediaBookmarks handles GET /api/media-items/:id/bookmarks func (mh *MediaHandler) GetMediaBookmarks(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } bookmarks, err := mh.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, bookmarks) } // CreateMediaBookmark handles POST /api/media-items/:id/bookmarks func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } var req CreateMediaBookmarkRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } // The sync-aware path (dedup + LWW + tombstones) is the only path: the // annotation service is a required constructor dependency. origin := req.Origin if origin == "" { origin = "web" } result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, Title: req.Title, Position: req.Position, Notes: req.Notes, PageNumber: req.PageNumber, ChapterNumber: req.ChapterNumber, CFIPosition: req.CfiPosition, PercentageLoc: req.Percentage, ChapterReference: req.ChapterReference, Source: "web", OriginSource: origin, ModifiedAt: time.Now(), }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusCreated, result.Bookmark) } // UpdateMediaBookmark handles PUT /api/media-items/:id/bookmarks/:bookmarkId func (mh *MediaHandler) UpdateMediaBookmark(c *echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } bookmarkID := c.Param("bookmarkId") bookmarkUUID, err := uuid.Parse(bookmarkID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid bookmark id"}) } mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } var req UpdateMediaBookmarkRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } // Through the sync service (H3): the PUT now lands as a proper LWW // edit — source/modified_at bookkeeping maintained, locator fields // carried (coalesced with the stored row for omitted ones), and the // row targeted by id so an edited title can't miss its dedup key. result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{ MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, Title: req.Title, Position: req.Position, Notes: req.Notes, PageNumber: req.PageNumber, ChapterNumber: req.ChapterNumber, CFIPosition: req.CfiPosition, PercentageLoc: req.Percentage, ChapterReference: req.ChapterReference, Source: "web", ModifiedAt: time.Now(), BookmarkID: pgtype.UUID{Bytes: bookmarkUUID, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, result.Bookmark) } // DeleteMediaBookmark handles DELETE /api/media-items/:id/bookmarks/:bookmarkId func (mh *MediaHandler) DeleteMediaBookmark(c *echo.Context) error { bookmarkID := c.Param("bookmarkId") bookmarkUUID, err := uuid.Parse(bookmarkID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid bookmark id"}) } pgBookmarkID := pgtype.UUID{Bytes: bookmarkUUID, Valid: true} userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"}) } pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true} if err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgUserID, pgBookmarkID, "web"); err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusNotFound, map[string]string{"error": "bookmark not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.NoContent(http.StatusNoContent) } // SearchMediaItems handles GET /api/media-items/search // Supports two modes: // 1. Autocomplete: author=value, genre=value, etc. → returns field values for dropdowns // 2. Search: q=value with optional filters → returns media items func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error { // Safely get user from context userID, ok := c.Get("user").(database.Users) if !ok { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"}) } // Detect autocomplete queries first (author=value, genre=value, etc.) if author := c.QueryParam("author"); author != "" { return mh.handleFieldValuesSearch(c, userID.ID, "author", author) } if genre := c.QueryParam("genre"); genre != "" { return mh.handleFieldValuesSearch(c, userID.ID, "genre", genre) } if tags := c.QueryParam("tags"); tags != "" { return mh.handleFieldValuesSearch(c, userID.ID, "tags", tags) } if narrator := c.QueryParam("narrator"); narrator != "" { return mh.handleFieldValuesSearch(c, userID.ID, "narrator", narrator) } if series := c.QueryParam("series"); series != "" { return mh.handleFieldValuesSearch(c, userID.ID, "series", series) } if language := c.QueryParam("language"); language != "" { return mh.handleFieldValuesSearch(c, userID.ID, "language", language) } // Handle media item search with filters query := c.QueryParam("q") libraryID := c.QueryParam("library_id") // Validate library_id if provided var libUUID pgtype.UUID if libraryID != "" { lib, err := uuid.Parse(libraryID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) } libUUID = pgtype.UUID{Bytes: lib, Valid: true} } else { libUUID = pgtype.UUID{Valid: false} } c.Logger().Info("Search parameters", "query", query, "library_id", libraryID, "libUUID_valid", libUUID.Valid, "libUUID_bytes", libUUID.Bytes) limit, _ := strconv.Atoi(c.QueryParam("limit")) if limit == 0 { limit = 50 } offset, _ := strconv.Atoi(c.QueryParam("offset")) // Extract filter parameters authorFilter := c.QueryParam("author_filter") seriesFilter := c.QueryParam("series_filter") genreFilter := c.QueryParam("genre_filter") publisherFilter := c.QueryParam("publisher_filter") tagsFilter := c.QueryParam("tags_filter") narratorFilter := c.QueryParam("narrator_filter") languageFilter := c.QueryParam("language_filter") yearMin, _ := strconv.Atoi(c.QueryParam("year_min")) yearMax, _ := strconv.Atoi(c.QueryParam("year_max")) hasCover := false hasCoverValid := false if _, exists := c.QueryParams()["has_cover"]; exists { hasCover = c.QueryParam("has_cover") == "true" hasCoverValid = true } // Extract sort parameter sortParam := c.QueryParam("sort") if sortParam == "" { sortParam = "title ASC" // Default sort } // Build search params params := services.SearchParams{ UserID: userID.ID, LibraryID: libUUID, SearchQuery: query, AuthorFilter: authorFilter, SeriesFilter: seriesFilter, GenreFilter: genreFilter, PublisherFilter: publisherFilter, TagsFilter: tagsFilter, NarratorFilter: narratorFilter, LanguageFilter: languageFilter, YearMin: yearMin, YearMax: yearMax, HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid}, Sort: sortParam, Limit: limit, Offset: offset, } c.Logger().Info("SearchMediaItems called", "query", query, "library_id_provided", libraryID != "", "library_id_uuid", libUUID.Valid, "library_id_bytes", libUUID.Bytes) // Call shared search service results, _, err := mh.searchService.ExecuteSearch(c.Request().Context(), params) if err != nil { c.Logger().Error("search error", "error", err.Error()) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } if len(results) == 0 { return c.JSON(http.StatusNotFound, map[string]interface{}{ "error": "no results found", "query": query, "results": []interface{}{}, }) } for i := range results { resolved := utils.ResolveMediaURL(results[i].LibraryID, results[i].CoverImagePath) results[i].CoverImagePath = pgtype.Text{String: resolved, Valid: resolved != ""} } return c.JSON(http.StatusOK, results) } // handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.) // Returns distinct field values with counts and similarity scores for dropdown population func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error { libraryID := c.QueryParam("library_id") if libraryID == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id is required"}) } libUUID, err := uuid.Parse(libraryID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) } limit, _ := strconv.Atoi(c.QueryParam("limit")) if limit == 0 { limit = 50 } offset, _ := strconv.Atoi(c.QueryParam("offset")) params := services.FieldSearchParams{ UserID: userID, LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, FieldType: fieldType, SearchQuery: searchQuery, Limit: limit, Offset: offset, } results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params) if err != nil { c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]interface{}{ "results": results, "total": len(results), }) } // getFullFilePath returns the absolute filesystem path for a media item // Uses LibraryService for resolution (one source of truth) func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) { if relativePath == "" { return "", fmt.Errorf("no file path") } // Check if already absolute (backward compatibility) if filepath.IsAbs(relativePath) { return relativePath, nil } // Use service for resolution (one source of truth) return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath) } // ServeFile serves stored library files (covers and books). // // Two URL forms funnel into this handler: // // /uploads/library-{libraryID}/{relativePath} (covers, reader files) // /api/media-items/{mediaItemID}/download (explicit book download) // // Both require JWT authentication and that the authenticated user can see // the library owning the file - library visibility is the permission gate. func (mh *MediaHandler) ServeFile(c *echo.Context) error { var libraryUUID pgtype.UUID var relativePath string rawPath := c.Param("*") if rawPath != "" { // Path form: /uploads/library-{libraryID}/{relativePath} libraryIDStr := c.Param("id") parsed, err := uuid.Parse(libraryIDStr) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library ID"}) } libraryUUID = pgtype.UUID{Bytes: parsed, Valid: true} relativePath, err = url.QueryUnescape(rawPath) if err != nil { relativePath = rawPath } if relativePath == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"}) } } else { // Item form: /api/media-items/{mediaItemID}/download itemUUID, err := uuid.Parse(c.Param("id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item ID"}) } mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: itemUUID, Valid: true}) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"}) } if !mediaItem.LibraryID.Valid || mediaItem.FilePath == "" { return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found"}) } libraryUUID = mediaItem.LibraryID relativePath = mediaItem.FilePath // Explicit download endpoint: suggest saving instead of inline display. filename := strings.Map(func(r rune) rune { if r == '"' || r == '\\' || r == '/' { return -1 } return r }, filepath.Base(relativePath)) c.Response().Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) } // Library visibility gate: the library is what grants permission to // see and download media. user := c.Get("user").(database.Users) visibleLibraries, err := mh.libraryService.GetUserVisibleLibraries(c.Request().Context(), user.ID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"}) } libraryVisible := false for _, lib := range visibleLibraries { if lib.ID.Valid && lib.ID.Bytes == libraryUUID.Bytes { libraryVisible = true break } } if !libraryVisible { return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"}) } // Resolve using service fullPath, err := mh.getFullFilePath(c.Request().Context(), libraryUUID, relativePath) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"}) } // Check if file exists if _, err := os.Stat(fullPath); os.IsNotExist(err) { return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"}) } ext := strings.ToLower(filepath.Ext(fullPath)) contentType := services.MimeTypes[ext] if contentType == "" { contentType = "application/octet-stream" } c.Response().Header().Set("Content-Type", contentType) c.Response().Header().Set("Cache-Control", "public, max-age=86400") http.ServeFile(c.Response(), c.Request(), fullPath) return nil } // numericToFloat converts pgtype.Numeric to float64, returning 0 if invalid func numericToFloat(n pgtype.Float8) float64 { if n.Valid { return n.Float64 } return 0 } // jsonBytesToMap converts []byte JSONB to map[string]interface{}, returning nil if invalid func jsonBytesToMap(b []byte) map[string]interface{} { if len(b) == 0 { return nil } var result map[string]interface{} if err := json.Unmarshal(b, &result); err != nil { return nil } return result } func (mh *MediaHandler) saveCoverImage(c echo.Context, mediaUUID uuid.UUID, file *multipart.FileHeader) (string, error) { src, err := file.Open() if err != nil { return "", fmt.Errorf("failed to open uploaded file: %w", err) } defer src.Close() imageData, err := io.ReadAll(src) if err != nil { return "", fmt.Errorf("failed to read uploaded file: %w", err) } if len(imageData) < 512 { return "", fmt.Errorf("file too small to be a valid image") } contentType := http.DetectContentType(imageData) if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" { return "", fmt.Errorf("invalid image type: %s", contentType) } mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}) if err != nil { return "", fmt.Errorf("media item not found: %w", err) } relativeFilePath := mediaItem.FilePath if relativeFilePath == "" { return "", fmt.Errorf("media item has no file path") } // Custom covers live at a dedicated sidecar path (distinct from the // scanner-generated {file}.cover.jpg) so scans can never overwrite a // user-uploaded cover and the metadata_overrides set can protect it. ext := ".jpg" switch contentType { case "image/png": ext = ".png" case "image/webp": ext = ".webp" } coverRelPath := relativeFilePath + ".custom_cover" + ext coverFullPath, err := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverRelPath) if err != nil { return "", fmt.Errorf("failed to resolve cover path: %w", err) } coverDir := filepath.Dir(coverFullPath) if err := os.MkdirAll(coverDir, 0755); err != nil { return "", fmt.Errorf("failed to create cover directory: %w", err) } if err := os.WriteFile(coverFullPath, imageData, 0644); err != nil { return "", fmt.Errorf("failed to write cover file: %w", err) } return coverRelPath, nil } // GetAudioChapters handles GET /api/media-items/:id/chapters — the // format-abstraction chapter list for audiobooks (media_items // .audio_metadata). Embedded m4b chapters and folder-as-book files write // the same shape. Empty list when the item isn't audio or hasn't been // probed. func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error { mediaID := c.Param("id") mediaUUID, err := uuid.Parse(mediaID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) } item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"}) } var payload services.AudioMetadataPayload if len(item.AudioMetadata) > 0 { if err := json.Unmarshal(item.AudioMetadata, &payload); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to parse audio metadata"}) } } return c.JSON(http.StatusOK, map[string]interface{}{ "media_item_id": item.ID, "chapters": payload.Chapters, "duration_seconds": payload.DurationSeconds, "codec_profile": payload.CodecProfile, }) }