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:
+157
-30
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user