package handlers import ( "bookhoard/internal/database" wsync "bookhoard/internal/sync" "bookhoard/internal/utils" "context" "encoding/json" "net/http" "strconv" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) // getDeviceIcon returns an emoji icon for device type func getDeviceIcon(deviceType string) string { switch deviceType { case "kobo": return "📚" case "koreader": return "📖" case "kindle": return "📱" default: return "📚" } } // UpdateUniversalProgress updates progress with automatic conversion // GetProgressHistory retrieves reading session history func (h *Handler) GetProgressHistory(c *echo.Context) error { user := MustGetAuthenticatedUser(c) mediaItemID, err := uuid.Parse(c.Param("id")) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "invalid media item ID") } limit := 50 if l := c.QueryParam("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 { limit = parsed } } history, err := h.db.GetReadingHistory(context.Background(), database.GetReadingHistoryParams{ UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true}, MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, Limit: int32(limit), }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to get history") } return c.JSON(http.StatusOK, map[string]interface{}{ "media_item_id": mediaItemID, "sessions": history, "count": len(history), }) } type ProgressWithMedia struct { MediaItemID uuid.UUID `json:"media_item_id"` Title string `json:"title"` Author string `json:"author"` CoverImagePath string `json:"cover_image_path"` Percentage float64 `json:"percentage"` CurrentPage int32 `json:"current_page"` TotalPages int32 `json:"total_pages"` LastReadAt time.Time `json:"last_read_at"` Epubcfi string `json:"epubcfi"` LastSyncDevice string `json:"last_sync_device"` DeviceName string `json:"device_name,omitempty"` DeviceType string `json:"device_type,omitempty"` DeviceIcon string `json:"device_icon,omitempty"` ProgressPercentage float64 `json:"-"` EpubCFI string `json:"-"` LastUpdated string `json:"-"` FormatGroup string `json:"format_group"` EstimatedPages int `json:"estimated_pages"` // Audiobook whole-book duration (seconds) for H:M:S progress display DurationSeconds float64 `json:"duration_seconds,omitempty"` } // audioDurationFrom pulls duration_seconds from the scan's audio_metadata // JSONB (0 when the item isn't audio or hasn't been probed). func audioDurationFrom(raw []byte) float64 { if len(raw) == 0 { return 0 } var payload struct { DurationSeconds float64 `json:"duration_seconds"` } if json.Unmarshal(raw, &payload) != nil { return 0 } return payload.DurationSeconds } // GetAllProgressData retrieves all progress for a user with sync source info. // The registered /progress SSR page renders this (frontend.go); the old // GetAllProgress HTTP handler (an unregistered near-copy) was deleted. func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, error) { user := MustGetAuthenticatedUser(c) progressList := []ProgressWithMedia{} mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{ Limit: 1000, Offset: 0, }) if err != nil { return nil, err } for _, mediaItem := range mediaItems { progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{ MediaItemID: mediaItem.ID, UserID: user.ID, }) if err != nil { continue } coverPath := utils.ResolveMediaURL(mediaItem.LibraryID, mediaItem.CoverImagePath) author := "" if mediaItem.Author.Valid { author = mediaItem.Author.String } epubcfi := "" if progress.Epubcfi.Valid { epubcfi = progress.Epubcfi.String } deviceName := "" if progress.LastSyncDevice.Valid { deviceName = progress.LastSyncDevice.String } lastUpdated := "" if progress.LastReadAt.Valid { lastUpdated = utils.FormatTimeInTimezone(progress.LastReadAt.Time, user.Timezone.String) } progressList = append(progressList, ProgressWithMedia{ MediaItemID: progress.MediaItemID.Bytes, Title: mediaItem.Title, Author: author, CoverImagePath: coverPath, Percentage: progress.Percentage.Float64, CurrentPage: progress.CurrentPage.Int32, TotalPages: progress.TotalPages.Int32, LastReadAt: progress.LastReadAt.Time, Epubcfi: epubcfi, LastSyncDevice: deviceName, ProgressPercentage: progress.Percentage.Float64 * 100, EpubCFI: epubcfi, LastUpdated: lastUpdated, DeviceIcon: getDeviceIcon(deviceName), DeviceName: deviceName, DeviceType: deviceName, FormatGroup: mediaItem.FormatGroup, // Audiobook "current position" needs whole-book duration // (H:M:S display); parsed from the scanner's audio_metadata. DurationSeconds: audioDurationFrom(mediaItem.AudioMetadata), EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64), }) } return progressList, nil }