fix(handlers): wire all 37 fields in UpdateMediaItem, add cover upload support

UpdateMediaItem handler:
- Add form: tags to UpdateMediaItemRequest for dual JSON/multipart binding
- Add 8 missing fields (Language, Edition, PageCount, Genre, CopyrightYear,
  GoodreadsID, OpenlibraryID, GoogleBooksID)
- Add CoverAction field (keep/upload/remove) with multipart cover handling
- Fetch existing record before update to preserve cover_image_path when
  cover_action is "keep" (was clearing cover on every JSON save)
- Add saveCoverImage() method: validates image type, resolves library path,
  saves as {file_path}.cover.jpg
- Add HX-Redirect response header for HTMX clients

HandleBulkUpdate:
- Copy all 37 fields from existingMedia (was missing GoogleBooksID + 14
  new fields), preventing data loss on bulk metadata updates.
This commit is contained in:
2026-05-10 11:52:04 -04:00
parent f199918775
commit a92739d210
+157 -30
View File
@@ -12,6 +12,7 @@ import (
"io"
"log"
"mime"
"mime/multipart"
"net/http"
"net/url"
"os"
@@ -62,33 +63,41 @@ type CreateMediaItemRequest struct {
// UpdateMediaItemRequest represents the request for updating a media item
type UpdateMediaItemRequest struct {
Title string `json:"title" validate:"required,min=1,max=500"`
Author string `json:"author"`
ISBN string `json:"isbn"`
Description string `json:"description"`
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"`
Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
Author string `form:"author" json:"author"`
ISBN string `form:"isbn" json:"isbn"`
Description string `form:"description" json:"description"`
CoverImagePath string `form:"cover_image_path" json:"cover_image_path"`
CoverAction string `form:"cover_action" json:"cover_action"`
Series string `form:"series" json:"series"`
SeriesNumber int32 `form:"series_number" json:"series_number"`
Tags []string `form:"tags" json:"tags"`
ASIN string `form:"asin" json:"asin"`
DatePublished string `form:"date_published" json:"date_published"`
Publisher string `form:"publisher" json:"publisher"`
Contributors []string `form:"contributors" json:"contributors"`
Language string `form:"language" json:"language"`
Edition string `form:"edition" json:"edition"`
PageCount int32 `form:"page_count" json:"page_count"`
Genre string `form:"genre" json:"genre"`
CopyrightYear int32 `form:"copyright_year" json:"copyright_year"`
GoodreadsID string `form:"goodreads_id" json:"goodreads_id"`
OpenlibraryID string `form:"openlibrary_id" json:"openlibrary_id"`
GoogleBooksID string `form:"google_books_id" json:"google_books_id"`
MangaType string `form:"manga_type" json:"manga_type"`
ReadingDirection string `form:"reading_direction" json:"reading_direction"`
SeriesCount int32 `form:"series_count" json:"series_count"`
Volume int32 `form:"volume" json:"volume"`
Imprint string `form:"imprint" json:"imprint"`
AgeRating string `form:"age_rating" json:"age_rating"`
WebURL string `form:"web_url" json:"web_url"`
MetadataNotes string `form:"metadata_notes" json:"metadata_notes"`
CommunityRating float64 `form:"community_rating" json:"community_rating"`
StoryArc string `form:"story_arc" json:"story_arc"`
IsBlackAndWhite bool `form:"is_black_and_white" json:"is_black_and_white"`
AlternateInfo string `form:"alternate_info" json:"alternate_info"`
ScanInformation string `form:"scan_information" json:"scan_information"`
Summary string `form:"summary" json:"summary"`
}
// CreateMediaNoteRequest represents the request for creating a media note
@@ -555,7 +564,22 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
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,
}
if update.Updates.Title != nil {
@@ -1194,25 +1218,52 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
// Validate and normalize ISBN
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
if err != nil && req.ISBN != "" {
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"})
}
// Use normalized ISBN if valid, otherwise empty string
isbnValue := normalizedISBN
if err != nil {
isbnValue = ""
}
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
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
}
}
var alternateInfoBytes []byte
if req.AlternateInfo != "" {
alternateInfoBytes = []byte(req.AlternateInfo)
}
item, err := mh.db.UpdateMediaItem(c.Request().Context(), 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: req.CoverImagePath, Valid: req.CoverImagePath != ""},
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,
@@ -1222,11 +1273,37 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
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 != ""},
})
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)
}
@@ -1769,3 +1846,53 @@ func jsonBytesToMap(b []byte) map[string]interface{} {
}
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")
}
coverRelPath := relativeFilePath + ".cover.jpg"
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
}