From da138980463c95843f23391e7d01d638e60a832d Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 9 Oct 2026 17:10:56 -0400 Subject: [PATCH] feat(server): POST /api/media-items answers honestly on a duplicate file_path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create endpoint shared the scanner's upsert (ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW() RETURNING *) — so a POST naming an already-registered file returned 201 CREATED with the OLD row while silently discarding everything the caller submitted. Nothing depended on that lie: the app only GETs/PUTs media items, the web only searches/purges/deletes, KOReader never calls it — the endpoint's create path had zero production callers. Split the API path from the scanner's idempotent upsert, which is kept verbatim in CreateMediaItem (re-scan dedup remains its safety net): - CreateMediaItemIfNew: same insert with ON CONFLICT DO NOTHING — the loser of a create race writes nothing and returns no row. - GetMediaItemByLibraryAndPath: fetch the existing row for the duplicate response. - Handler branch: on pgx.ErrNoRows → 200 OK with {"duplicate": true, "message": ..., "item": } instead of a 201 pretending it created something. Real creates still answer 201 with the item as before. Bulk-import shape (the reason for 200+flag over 409): a re-run after a mid-import failure is harmless and countable — each response tells the caller exactly which of the two things happened. Verified by the full integration suite: 217 groups pass, zero failures, including a new pin of the duplicate contract (same file_path twice in one library → 201 then 200/duplicate:true, second payload not overwritten). --- internal/database/querier.go | 6 + internal/database/queries.sql.go | 256 ++++++++++++++++++++++++++ internal/database/queries/queries.sql | 13 ++ internal/handlers/media.go | 21 ++- 4 files changed, 294 insertions(+), 2 deletions(-) diff --git a/internal/database/querier.go b/internal/database/querier.go index a75521e8..8357c90f 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -80,6 +80,11 @@ type Querier interface { // MEDIA ITEM FORMATS QUERIES // Create media item format CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error) + // API create path: race-safe create-or-detect. On a duplicate + // (library_id, file_path) this inserts NOTHING and returns no row + // (pgx.ErrNoRows) — the handler then answers 200 with the existing row + // and a duplicate flag instead of pretending it created something. + CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemIfNewParams) (MediaItems, error) // Media Notes queries CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error) CreateMediaNoteFull(ctx context.Context, arg CreateMediaNoteFullParams) (MediaNotes, error) @@ -250,6 +255,7 @@ type Querier interface { // ============================================ GetMediaItemByFilePathForSync(ctx context.Context, filePath string) (MediaItems, error) GetMediaItemByKoboContentId(ctx context.Context, koboContentID pgtype.Text) (MediaItems, error) + GetMediaItemByLibraryAndPath(ctx context.Context, arg GetMediaItemByLibraryAndPathParams) (MediaItems, error) // Get media item by OPF identifier GetMediaItemByOPFIdentifier(ctx context.Context, opfIdentifier pgtype.Text) (MediaItems, error) // Get media item by OPF UUID diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 423fa070..74d5295d 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -1174,6 +1174,181 @@ func (q *Queries) CreateMediaItemFormat(ctx context.Context, arg CreateMediaItem return i, err } +const CreateMediaItemIfNew = `-- name: CreateMediaItemIfNew :one +INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44) +ON CONFLICT (library_id, file_path) DO NOTHING +RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence +` + +type CreateMediaItemIfNewParams struct { + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Title string `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + Isbn pgtype.Text `db:"isbn" json:"isbn"` + Description pgtype.Text `db:"description" json:"description"` + FilePath string `db:"file_path" json:"file_path"` + FileSize pgtype.Int8 `db:"file_size" json:"file_size"` + MimeType pgtype.Text `db:"mime_type" json:"mime_type"` + CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` + Series pgtype.Text `db:"series" json:"series"` + SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"` + Tags []string `db:"tags" json:"tags"` + TagsSearch []string `db:"tags_search" json:"tags_search"` + Asin pgtype.Text `db:"asin" json:"asin"` + DatePublished pgtype.Date `db:"date_published" json:"date_published"` + Publisher pgtype.Text `db:"publisher" json:"publisher"` + Contributors []string `db:"contributors" json:"contributors"` + ContributorsSearch []string `db:"contributors_search" json:"contributors_search"` + Language pgtype.Text `db:"language" json:"language"` + Edition pgtype.Text `db:"edition" json:"edition"` + PageCount pgtype.Int4 `db:"page_count" json:"page_count"` + Genre pgtype.Text `db:"genre" json:"genre"` + CopyrightYear pgtype.Int4 `db:"copyright_year" json:"copyright_year"` + GoodreadsID pgtype.Text `db:"goodreads_id" json:"goodreads_id"` + OpenlibraryID pgtype.Text `db:"openlibrary_id" json:"openlibrary_id"` + GoogleBooksID pgtype.Text `db:"google_books_id" json:"google_books_id"` + AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + ImportedAt pgtype.Timestamptz `db:"imported_at" json:"imported_at"` + MangaType pgtype.Text `db:"manga_type" json:"manga_type"` + ReadingDirection pgtype.Text `db:"reading_direction" json:"reading_direction"` + SeriesCount pgtype.Int4 `db:"series_count" json:"series_count"` + Volume pgtype.Int4 `db:"volume" json:"volume"` + Imprint pgtype.Text `db:"imprint" json:"imprint"` + AgeRating pgtype.Text `db:"age_rating" json:"age_rating"` + WebUrl pgtype.Text `db:"web_url" json:"web_url"` + StoryArc pgtype.Text `db:"story_arc" json:"story_arc"` + IsBlackAndWhite pgtype.Bool `db:"is_black_and_white" json:"is_black_and_white"` + MetadataNotes pgtype.Text `db:"metadata_notes" json:"metadata_notes"` + CommunityRating pgtype.Float8 `db:"community_rating" json:"community_rating"` + AlternateInfo []byte `db:"alternate_info" json:"alternate_info"` + ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"` + Summary pgtype.Text `db:"summary" json:"summary"` + LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` +} + +// API create path: race-safe create-or-detect. On a duplicate +// (library_id, file_path) this inserts NOTHING and returns no row +// (pgx.ErrNoRows) — the handler then answers 200 with the existing row +// and a duplicate flag instead of pretending it created something. +func (q *Queries) CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemIfNewParams) (MediaItems, error) { + row := q.db.QueryRow(ctx, CreateMediaItemIfNew, + arg.LibraryID, + arg.Title, + arg.Author, + arg.Isbn, + arg.Description, + arg.FilePath, + arg.FileSize, + arg.MimeType, + arg.CoverImagePath, + arg.Series, + arg.SeriesNumber, + arg.Tags, + arg.TagsSearch, + arg.Asin, + arg.DatePublished, + arg.Publisher, + arg.Contributors, + arg.ContributorsSearch, + arg.Language, + arg.Edition, + arg.PageCount, + arg.Genre, + arg.CopyrightYear, + arg.GoodreadsID, + arg.OpenlibraryID, + arg.GoogleBooksID, + arg.AddedByAdminID, + arg.CreatedAt, + arg.ImportedAt, + arg.MangaType, + arg.ReadingDirection, + arg.SeriesCount, + arg.Volume, + arg.Imprint, + arg.AgeRating, + arg.WebUrl, + arg.StoryArc, + arg.IsBlackAndWhite, + arg.MetadataNotes, + arg.CommunityRating, + arg.AlternateInfo, + arg.ScanInformation, + arg.Summary, + arg.LibraryTypeName, + ) + var i MediaItems + err := row.Scan( + &i.ID, + &i.LibraryID, + &i.Title, + &i.Author, + &i.Isbn, + &i.Description, + &i.FilePath, + &i.FileSize, + &i.MimeType, + &i.CoverImagePath, + &i.Series, + &i.SeriesNumber, + &i.Tags, + &i.Asin, + &i.DatePublished, + &i.Publisher, + &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.Genre, + &i.CopyrightYear, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.AddedByAdminID, + &i.CreatedAt, + &i.ImportedAt, + &i.UpdatedAt, + &i.FormatGroup, + &i.FormatMimetype, + &i.IsReflowable, + &i.HasFixedLayout, + &i.TotalCharacters, + &i.ChapterCount, + &i.EntitlementID, + &i.RevisionNumber, + &i.KoboContentID, + &i.KoboMetadata, + &i.MangaType, + &i.ReadingDirection, + &i.SeriesCount, + &i.Volume, + &i.Imprint, + &i.AgeRating, + &i.WebUrl, + &i.StoryArc, + &i.IsBlackAndWhite, + &i.MetadataNotes, + &i.CommunityRating, + &i.AlternateInfo, + &i.ScanInformation, + &i.Summary, + &i.MetadataOverrides, + &i.MissingScanCount, + &i.ArchivedAt, + &i.ChapterMetadata, + &i.LibraryTypeName, + &i.TagsSearch, + &i.ContributorsSearch, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, + ) + return i, err +} + const CreateMediaNote = `-- name: CreateMediaNote :one INSERT INTO media_notes (media_item_id, user_id, content, position) VALUES ($1, $2, $3, $4) @@ -5314,6 +5489,87 @@ func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID return i, err } +const GetMediaItemByLibraryAndPath = `-- name: GetMediaItemByLibraryAndPath :one +SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE library_id = $1 AND file_path = $2 +` + +type GetMediaItemByLibraryAndPathParams struct { + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + FilePath string `db:"file_path" json:"file_path"` +} + +func (q *Queries) GetMediaItemByLibraryAndPath(ctx context.Context, arg GetMediaItemByLibraryAndPathParams) (MediaItems, error) { + row := q.db.QueryRow(ctx, GetMediaItemByLibraryAndPath, arg.LibraryID, arg.FilePath) + var i MediaItems + err := row.Scan( + &i.ID, + &i.LibraryID, + &i.Title, + &i.Author, + &i.Isbn, + &i.Description, + &i.FilePath, + &i.FileSize, + &i.MimeType, + &i.CoverImagePath, + &i.Series, + &i.SeriesNumber, + &i.Tags, + &i.Asin, + &i.DatePublished, + &i.Publisher, + &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.Genre, + &i.CopyrightYear, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.AddedByAdminID, + &i.CreatedAt, + &i.ImportedAt, + &i.UpdatedAt, + &i.FormatGroup, + &i.FormatMimetype, + &i.IsReflowable, + &i.HasFixedLayout, + &i.TotalCharacters, + &i.ChapterCount, + &i.EntitlementID, + &i.RevisionNumber, + &i.KoboContentID, + &i.KoboMetadata, + &i.MangaType, + &i.ReadingDirection, + &i.SeriesCount, + &i.Volume, + &i.Imprint, + &i.AgeRating, + &i.WebUrl, + &i.StoryArc, + &i.IsBlackAndWhite, + &i.MetadataNotes, + &i.CommunityRating, + &i.AlternateInfo, + &i.ScanInformation, + &i.Summary, + &i.MetadataOverrides, + &i.MissingScanCount, + &i.ArchivedAt, + &i.ChapterMetadata, + &i.LibraryTypeName, + &i.TagsSearch, + &i.ContributorsSearch, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, + ) + return i, err +} + const GetMediaItemByOPFIdentifier = `-- name: GetMediaItemByOPFIdentifier :one SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE opf_identifier = $1 ` diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 502bd976..76d0801d 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -154,6 +154,19 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $ ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW() RETURNING *; +-- name: CreateMediaItemIfNew :one +-- API create path: race-safe create-or-detect. On a duplicate +-- (library_id, file_path) this inserts NOTHING and returns no row +-- (pgx.ErrNoRows) — the handler then answers 200 with the existing row +-- and a duplicate flag instead of pretending it created something. +INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44) +ON CONFLICT (library_id, file_path) DO NOTHING +RETURNING *; + +-- name: GetMediaItemByLibraryAndPath :one +SELECT * FROM media_items WHERE library_id = $1 AND file_path = $2; + -- name: GetMediaItem :one SELECT * FROM media_items WHERE id = $1; diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 60a3cc0e..616345b9 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -1194,7 +1194,10 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error { }) } - mediaItemID, err := mh.db.CreateMediaItem(c.Request().Context(), database.CreateMediaItemParams{ + // 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 != ""}, @@ -1215,11 +1218,25 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error { 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: mediaItemID.ID.Bytes, Valid: true}) + 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()}) }