feat(server): POST /api/media-items answers honestly on a duplicate file_path

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": <existing row>} 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).
This commit is contained in:
John O'Keefe committed 2026-10-09 17:10:56 -04:00
1 parent 1a2d42a244
commit da13898046
4 files changed
+294 -2

No files matched your search

+19 -2
View File
@@ -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()})
}