diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 0a8290d..6803b77 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -1354,6 +1354,83 @@ func (mh *MediaHandler) UnarchiveMediaItem(c *echo.Context) error { return c.NoContent(http.StatusNoContent) } +// MergeArchivedMediaItem handles POST /api/media-items/:id/merge (admin +// only). Merges an archived/missing item's reading data (progress, +// annotations, highlights, notes, bookmarks, ratings, collections, kobo +// shelves) into an active item with identical content (same SHA-256), then +// deletes the archived row. Covers the copy-then-delete-later workflow: the +// active copy exists, the archived twin holds the real reading history. +// Per-user collisions keep the active copy's data, mirroring hash-conflict +// resolution. +func (mh *MediaHandler) MergeArchivedMediaItem(c *echo.Context) error { + user := MustGetAuthenticatedUser(c) + + if user.Role != "admin" { + return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) + } + + sourceUUID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) + } + + var req struct { + TargetID string `json:"target_id"` + } + if err := c.Bind(&req); err != nil || req.TargetID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "target_id is required"}) + } + targetUUID, err := uuid.Parse(req.TargetID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid target_id"}) + } + if sourceUUID == targetUUID { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot merge an item into itself"}) + } + + ctx := c.Request().Context() + source, err := mh.db.GetMediaItem(ctx, pgtype.UUID{Bytes: sourceUUID, Valid: true}) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return c.JSON(http.StatusNotFound, map[string]string{"error": "archived item not found"}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + if !source.ArchivedAt.Valid && source.MissingScanCount == 0 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "source item is not archived or missing"}) + } + + target, err := mh.db.GetMediaItem(ctx, pgtype.UUID{Bytes: targetUUID, Valid: true}) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return c.JSON(http.StatusNotFound, map[string]string{"error": "target item not found"}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + if target.ArchivedAt.Valid || target.MissingScanCount > 0 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "target item is not active"}) + } + + if !source.FileSha256.Valid || source.FileSha256.String == "" || + source.FileSha256.String != target.FileSha256.String { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "items do not share the same content hash"}) + } + + if err := mh.db.ReparentMediaItemChildren(ctx, database.ReparentMediaItemChildrenParams{ + Column1: pgtype.UUID{Bytes: targetUUID, Valid: true}, + Column2: pgtype.UUID{Bytes: sourceUUID, Valid: true}, + }); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to merge reading data: " + err.Error()}) + } + if err := mh.db.DeleteMediaItem(ctx, pgtype.UUID{Bytes: sourceUUID, Valid: true}); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to remove archived item: " + err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]string{ + "message": "Merged reading progress, annotations, and collections into the active item", + }) +} + // PurgeArchivedMediaItems handles POST /api/media-items/purge-archived // (admin only). Hard-deletes every archived item (files missing from disk for // 2+ scans) together with its reading history. The archive retention window diff --git a/internal/router/frontend.go b/internal/router/frontend.go index e8e1601..1708b73 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -873,6 +873,11 @@ func registerFrontendRoutes(cfg *Config) { FilePath: row.FilePath, MissingScans: row.MissingScanCount, } + if row.MatchID.Valid { + item.MatchID = uuid.UUID(row.MatchID.Bytes).String() + item.MatchTitle = row.MatchTitle + item.MatchLibraryName = getText(row.MatchLibraryName) + } if row.ArchivedAt.Valid { archived := row.ArchivedAt.Time item.ArchivedAt = &archived diff --git a/internal/router/media.go b/internal/router/media.go index 6420a6b..6228bfb 100644 --- a/internal/router/media.go +++ b/internal/router/media.go @@ -64,6 +64,7 @@ func registerMediaRoutes(cfg *Config) { admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem) admin.POST("/media-items/purge-archived", cfg.MediaHandler.PurgeArchivedMediaItems) admin.POST("/media-items/:id/unarchive", cfg.MediaHandler.UnarchiveMediaItem) + admin.POST("/media-items/:id/merge", cfg.MediaHandler.MergeArchivedMediaItem) // Shelf management (protected) protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf) diff --git a/templates/admin_archived.templ b/templates/admin_archived.templ index 2cdefad..beb1680 100644 --- a/templates/admin_archived.templ +++ b/templates/admin_archived.templ @@ -71,6 +71,18 @@ templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {