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"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"mime"
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -62,33 +63,41 @@ type CreateMediaItemRequest struct {
|
|||||||
|
|
||||||
// UpdateMediaItemRequest represents the request for updating a media item
|
// UpdateMediaItemRequest represents the request for updating a media item
|
||||||
type UpdateMediaItemRequest struct {
|
type UpdateMediaItemRequest struct {
|
||||||
Title string `json:"title" validate:"required,min=1,max=500"`
|
Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
|
||||||
Author string `json:"author"`
|
Author string `form:"author" json:"author"`
|
||||||
ISBN string `json:"isbn"`
|
ISBN string `form:"isbn" json:"isbn"`
|
||||||
Description string `json:"description"`
|
Description string `form:"description" json:"description"`
|
||||||
CoverImagePath string `json:"cover_image_path"`
|
CoverImagePath string `form:"cover_image_path" json:"cover_image_path"`
|
||||||
Series string `json:"series"`
|
CoverAction string `form:"cover_action" json:"cover_action"`
|
||||||
SeriesNumber int32 `json:"series_number"`
|
Series string `form:"series" json:"series"`
|
||||||
Tags []string `json:"tags"`
|
SeriesNumber int32 `form:"series_number" json:"series_number"`
|
||||||
ASIN string `json:"asin"`
|
Tags []string `form:"tags" json:"tags"`
|
||||||
DatePublished string `json:"date_published"`
|
ASIN string `form:"asin" json:"asin"`
|
||||||
Publisher string `json:"publisher"`
|
DatePublished string `form:"date_published" json:"date_published"`
|
||||||
Contributors []string `json:"contributors"`
|
Publisher string `form:"publisher" json:"publisher"`
|
||||||
// NEW: Reading direction and comic metadata fields
|
Contributors []string `form:"contributors" json:"contributors"`
|
||||||
MangaType string `json:"manga_type"` // 'unknown' | 'no' | 'yes' | 'yes_and_right_to_left'
|
Language string `form:"language" json:"language"`
|
||||||
ReadingDirection string `json:"reading_direction"` // 'auto' | 'ltr' | 'rtl' | 'vertical'
|
Edition string `form:"edition" json:"edition"`
|
||||||
SeriesCount int32 `json:"series_count"`
|
PageCount int32 `form:"page_count" json:"page_count"`
|
||||||
Volume int32 `json:"volume"`
|
Genre string `form:"genre" json:"genre"`
|
||||||
Imprint string `json:"imprint"`
|
CopyrightYear int32 `form:"copyright_year" json:"copyright_year"`
|
||||||
AgeRating string `json:"age_rating"` // 'Everyone' | 'Teen' | 'Mature' | 'Adult'
|
GoodreadsID string `form:"goodreads_id" json:"goodreads_id"`
|
||||||
WebURL string `json:"web_url"`
|
OpenlibraryID string `form:"openlibrary_id" json:"openlibrary_id"`
|
||||||
MetadataNotes string `json:"metadata_notes"`
|
GoogleBooksID string `form:"google_books_id" json:"google_books_id"`
|
||||||
CommunityRating float64 `json:"community_rating"`
|
MangaType string `form:"manga_type" json:"manga_type"`
|
||||||
StoryArc string `json:"story_arc"`
|
ReadingDirection string `form:"reading_direction" json:"reading_direction"`
|
||||||
IsBlackAndWhite bool `json:"is_black_and_white"`
|
SeriesCount int32 `form:"series_count" json:"series_count"`
|
||||||
AlternateInfo string `json:"alternate_info"` // JSON string
|
Volume int32 `form:"volume" json:"volume"`
|
||||||
ScanInformation string `json:"scan_information"`
|
Imprint string `form:"imprint" json:"imprint"`
|
||||||
Summary string `json:"summary"`
|
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
|
// CreateMediaNoteRequest represents the request for creating a media note
|
||||||
@@ -555,7 +564,22 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
|
|||||||
PageCount: existingMedia.PageCount,
|
PageCount: existingMedia.PageCount,
|
||||||
GoodreadsID: existingMedia.GoodreadsID,
|
GoodreadsID: existingMedia.GoodreadsID,
|
||||||
OpenlibraryID: existingMedia.OpenlibraryID,
|
OpenlibraryID: existingMedia.OpenlibraryID,
|
||||||
|
GoogleBooksID: existingMedia.GoogleBooksID,
|
||||||
CoverImagePath: existingMedia.CoverImagePath,
|
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 {
|
if update.Updates.Title != nil {
|
||||||
@@ -1194,25 +1218,52 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|||||||
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
|
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
|
||||||
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
|
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
|
||||||
|
|
||||||
// Validate and normalize ISBN
|
|
||||||
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
|
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
|
||||||
if err != nil && req.ISBN != "" {
|
if err != nil && req.ISBN != "" {
|
||||||
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"})
|
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use normalized ISBN if valid, otherwise empty string
|
|
||||||
isbnValue := normalizedISBN
|
isbnValue := normalizedISBN
|
||||||
if err != nil {
|
if err != nil {
|
||||||
isbnValue = ""
|
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{
|
item, err := mh.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{
|
||||||
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
||||||
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
|
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
|
||||||
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
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 != ""},
|
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
|
||||||
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
||||||
Tags: req.Tags,
|
Tags: req.Tags,
|
||||||
@@ -1222,11 +1273,37 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|||||||
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
||||||
Contributors: req.Contributors,
|
Contributors: req.Contributors,
|
||||||
ContributorsSearch: contributorsSearch,
|
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 {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
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)
|
return c.JSON(http.StatusOK, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1769,3 +1846,53 @@ func jsonBytesToMap(b []byte) map[string]interface{} {
|
|||||||
}
|
}
|
||||||
return result
|
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