- Add libraryService dependency to CollectionHandler and OPDSHandler for centralized path resolution - Create internal/utils/mediaurl.go with ResolveMediaURL() function as single source of truth - Update GetMediaItem and ListMediaItems handlers to return resolved URLs in API responses - Update collection handlers (GetCollection, TestRules, PreviewCollection) to use resolved cover URLs - Update progress handler (GetAllProgress) to use resolved cover URLs - Add library_id to GetCollectionItems SQL query to enable URL resolution - Refactor media scanner to store relative paths instead of absolute filesystem paths - Add ResolveMediaPath() to LibraryService for resolving relative paths to absolute paths - Add ServeFile endpoint at /uploads/library-:id/* for authenticated file serving - Add MimeTypes map to library_service.go for consistent MIME type handling - Update DownloadBook handler to use resolved filesystem paths - Add getRelativePath() helper to MediaScanner for converting absolute to relative paths - Use strings.EqualFold for case-insensitive path comparisons in zip extraction This change enables the application to work with relative paths stored in the database, making it portable across different server environments while maintaining backward compatibility with existing absolute paths.
388 lines
12 KiB
Go
388 lines
12 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
wsync "bookhoard/internal/sync"
|
|
"bookhoard/internal/utils"
|
|
"context"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v4"
|
|
)
|
|
|
|
// 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 "📚"
|
|
}
|
|
}
|
|
|
|
// GetUniversalProgress retrieves progress with all location references
|
|
func (h *Handler) GetUniversalProgress(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")
|
|
}
|
|
|
|
progress, err := h.db.GetUniversalProgress(context.Background(), database.GetUniversalProgressParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
|
})
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"media_item_id": mediaItemID,
|
|
"progress": nil,
|
|
})
|
|
}
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get progress")
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"book_id": progress.MediaItemID.Bytes,
|
|
"format_group": progress.FormatGroup,
|
|
"universal_progress": map[string]interface{}{
|
|
"percentage": progress.Percentage.Float64,
|
|
},
|
|
"location_references": map[string]interface{}{
|
|
"percentage": progress.Percentage.Float64,
|
|
"page": int(progress.CurrentPage.Int32),
|
|
"total_pages": int(progress.TotalPages.Int32),
|
|
},
|
|
}
|
|
|
|
if progress.Epubcfi.Valid {
|
|
response["location_references"].(map[string]interface{})["epubcfi"] = progress.Epubcfi.String
|
|
}
|
|
if progress.Chapter.Valid {
|
|
response["location_references"].(map[string]interface{})["chapter"] = int(progress.Chapter.Int32)
|
|
}
|
|
if progress.ChapterProgress.Valid {
|
|
response["location_references"].(map[string]interface{})["chapter_progress"] = progress.ChapterProgress.Float64
|
|
}
|
|
if progress.CharacterOffset.Valid {
|
|
response["location_references"].(map[string]interface{})["character"] = int64(progress.CharacterOffset.Int64)
|
|
}
|
|
|
|
deviceSync := map[string]interface{}{}
|
|
if progress.LastSyncDevice.Valid {
|
|
deviceSync["device"] = progress.LastSyncDevice.String
|
|
}
|
|
if progress.LastSyncSource.Valid {
|
|
deviceSync["source"] = progress.LastSyncSource.String
|
|
}
|
|
if progress.LastSyncTimestamp.Valid {
|
|
deviceSync["last_sync"] = progress.LastSyncTimestamp.Time
|
|
}
|
|
if len(deviceSync) > 0 {
|
|
response["device_sync"] = deviceSync
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// UpdateUniversalProgress updates progress with automatic conversion
|
|
func (h *Handler) UpdateUniversalProgress(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")
|
|
}
|
|
|
|
var req struct {
|
|
Source string `json:"source"`
|
|
Location struct {
|
|
Percentage *float64 `json:"percentage"`
|
|
Page *int `json:"page"`
|
|
TotalPages *int `json:"total_pages"`
|
|
Epubcfi *string `json:"epubcfi"`
|
|
Character *int64 `json:"character"`
|
|
Chapter *int `json:"chapter"`
|
|
ViewportY *float64 `json:"viewport_y"`
|
|
} `json:"location"`
|
|
DeviceMetadata struct {
|
|
DeviceType string `json:"device_type"`
|
|
UserAgent string `json:"user_agent"`
|
|
} `json:"device_metadata"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
|
|
}
|
|
|
|
mediaItem, err := h.db.GetMediaItem(context.Background(), pgtype.UUID{Bytes: mediaItemID, Valid: true})
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusNotFound, "media item not found")
|
|
}
|
|
|
|
percentage := 0.0
|
|
if req.Location.Percentage != nil {
|
|
percentage = *req.Location.Percentage
|
|
} else if req.Location.Page != nil && req.Location.TotalPages != nil {
|
|
percentage = wsync.PageToPercentage(*req.Location.Page, *req.Location.TotalPages)
|
|
}
|
|
|
|
currentPage := 0
|
|
totalPages := 200
|
|
if req.Location.Page != nil {
|
|
currentPage = *req.Location.Page
|
|
}
|
|
if req.Location.TotalPages != nil {
|
|
totalPages = *req.Location.TotalPages
|
|
}
|
|
if mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
|
|
totalPages = int(mediaItem.PageCount.Int32)
|
|
}
|
|
|
|
var epubcfi pgtype.Text
|
|
if req.Location.Epubcfi != nil {
|
|
epubcfi = pgtype.Text{String: *req.Location.Epubcfi, Valid: true}
|
|
}
|
|
|
|
var chapter pgtype.Int4
|
|
if req.Location.Chapter != nil {
|
|
chapter = pgtype.Int4{Int32: int32(*req.Location.Chapter), Valid: true}
|
|
}
|
|
|
|
var viewportY pgtype.Float8
|
|
if req.Location.ViewportY != nil {
|
|
viewportY = pgtype.Float8{Float64: *req.Location.ViewportY, Valid: true}
|
|
}
|
|
|
|
var lastSyncDevice, lastSyncSource pgtype.Text
|
|
if req.DeviceMetadata.DeviceType != "" {
|
|
lastSyncDevice = pgtype.Text{String: req.DeviceMetadata.DeviceType, Valid: true}
|
|
lastSyncSource = pgtype.Text{String: req.Source, Valid: true}
|
|
}
|
|
|
|
updated, err := h.db.UpdateUniversalProgress(context.Background(), database.UpdateUniversalProgressParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
|
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
|
Epubcfi: epubcfi,
|
|
Chapter: chapter,
|
|
ChapterProgress: pgtype.Float8{Float64: percentage, Valid: true},
|
|
ViewportY: viewportY,
|
|
LastSyncDevice: lastSyncDevice,
|
|
LastSyncSource: lastSyncSource,
|
|
CurrentPage: pgtype.Int4{Int32: int32(currentPage), Valid: true},
|
|
TotalPages: pgtype.Int4{Int32: int32(totalPages), Valid: true},
|
|
})
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to update progress")
|
|
}
|
|
|
|
// Broadcast progress update to all connected WebSocket clients
|
|
deviceName := "Web"
|
|
deviceType := "web"
|
|
if req.DeviceMetadata.DeviceType != "" {
|
|
deviceType = req.DeviceMetadata.DeviceType
|
|
deviceName = req.DeviceMetadata.DeviceType
|
|
}
|
|
|
|
h.connManager.BroadcastProgressUpdate(mediaItemID, percentage, wsync.SourceDevice{
|
|
ID: uuid.UUID(user.ID.Bytes).String(),
|
|
Name: deviceName,
|
|
Type: deviceType,
|
|
})
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"sync_status": "success",
|
|
"progress_updated": true,
|
|
"percentage": percentage,
|
|
"current_page": currentPage,
|
|
"total_pages": totalPages,
|
|
"last_read_at": updated.LastReadAt.Time,
|
|
})
|
|
}
|
|
|
|
// 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:"-"`
|
|
}
|
|
|
|
// GetAllProgress retrieves all progress for a user with sync source info
|
|
func (h *Handler) GetAllProgress(c echo.Context) error {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
progressList := []ProgressWithMedia{}
|
|
|
|
mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
|
|
Limit: 1000, Offset: 0,
|
|
})
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get media items")
|
|
}
|
|
|
|
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 = progress.LastReadAt.Time.Format("2006-01-02 15:04")
|
|
}
|
|
|
|
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,
|
|
EpubCFI: epubcfi,
|
|
LastUpdated: lastUpdated,
|
|
DeviceIcon: getDeviceIcon(deviceName),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"progress": progressList,
|
|
"total": len(progressList),
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
return progressList, nil
|
|
}
|