- Add library_type_name to GetMediaItem handler response in media.go - Remove empty LibraryName and LibraryTypeName fields from: - ListMediaItemsRow in collections.go handler - ListMediaItemsRow in dashboard_service.go - These fields are now populated at the database level via trigger The library_type_name is now automatically populated in the database when a media item is created, so we remove the manual empty string assignments and expose the actual value in the API response.
448 lines
14 KiB
Go
448 lines
14 KiB
Go
package services
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/utils"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
func mediaItemsToListMediaItemsRow(item database.MediaItems) database.ListMediaItemsRow {
|
|
return database.ListMediaItemsRow{
|
|
ID: item.ID,
|
|
LibraryID: item.LibraryID,
|
|
Title: item.Title,
|
|
Author: item.Author,
|
|
Isbn: item.Isbn,
|
|
Description: item.Description,
|
|
FilePath: utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: true}),
|
|
FileSize: item.FileSize,
|
|
MimeType: item.MimeType,
|
|
CoverImagePath: pgtype.Text{String: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), Valid: true},
|
|
Series: item.Series,
|
|
SeriesNumber: item.SeriesNumber,
|
|
Tags: item.Tags,
|
|
Asin: item.Asin,
|
|
DatePublished: item.DatePublished,
|
|
Publisher: item.Publisher,
|
|
Contributors: item.Contributors,
|
|
Language: item.Language,
|
|
Edition: item.Edition,
|
|
PageCount: item.PageCount,
|
|
Genre: item.Genre,
|
|
CopyrightYear: item.CopyrightYear,
|
|
GoodreadsID: item.GoodreadsID,
|
|
OpenlibraryID: item.OpenlibraryID,
|
|
GoogleBooksID: item.GoogleBooksID,
|
|
AddedByAdminID: item.AddedByAdminID,
|
|
CreatedAt: item.CreatedAt,
|
|
UpdatedAt: item.UpdatedAt,
|
|
FormatGroup: item.FormatGroup,
|
|
FormatMimetype: item.FormatMimetype,
|
|
IsReflowable: item.IsReflowable,
|
|
HasFixedLayout: item.HasFixedLayout,
|
|
TotalCharacters: item.TotalCharacters,
|
|
ChapterCount: item.ChapterCount,
|
|
EntitlementID: item.EntitlementID,
|
|
RevisionNumber: item.RevisionNumber,
|
|
KoboContentID: item.KoboContentID,
|
|
KoboMetadata: item.KoboMetadata,
|
|
TagsSearch: item.TagsSearch,
|
|
ContributorsSearch: item.ContributorsSearch,
|
|
FileSha256: item.FileSha256,
|
|
OpfIdentifier: item.OpfIdentifier,
|
|
OpfUuid: item.OpfUuid,
|
|
HashConfidence: item.HashConfidence,
|
|
}
|
|
}
|
|
|
|
func getCollectionItemsRowToMediaItems(item database.GetCollectionItemsForDashboardRow) database.MediaItems {
|
|
return database.MediaItems{
|
|
ID: item.ID,
|
|
LibraryID: item.LibraryID,
|
|
Title: item.Title,
|
|
Author: item.Author,
|
|
Isbn: item.Isbn,
|
|
Description: item.Description,
|
|
FilePath: utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: true}),
|
|
FileSize: item.FileSize,
|
|
MimeType: item.MimeType,
|
|
CoverImagePath: pgtype.Text{String: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), Valid: true},
|
|
Series: item.Series,
|
|
SeriesNumber: item.SeriesNumber,
|
|
Tags: item.Tags,
|
|
Asin: item.Asin,
|
|
DatePublished: item.DatePublished,
|
|
Publisher: item.Publisher,
|
|
Contributors: item.Contributors,
|
|
Language: item.Language,
|
|
Edition: item.Edition,
|
|
PageCount: item.PageCount,
|
|
Genre: item.Genre,
|
|
CopyrightYear: item.CopyrightYear,
|
|
GoodreadsID: item.GoodreadsID,
|
|
OpenlibraryID: item.OpenlibraryID,
|
|
GoogleBooksID: item.GoogleBooksID,
|
|
AddedByAdminID: item.AddedByAdminID,
|
|
CreatedAt: item.CreatedAt,
|
|
UpdatedAt: item.UpdatedAt,
|
|
FormatGroup: item.FormatGroup,
|
|
FormatMimetype: item.FormatMimetype,
|
|
IsReflowable: item.IsReflowable,
|
|
HasFixedLayout: item.HasFixedLayout,
|
|
TotalCharacters: item.TotalCharacters,
|
|
ChapterCount: item.ChapterCount,
|
|
EntitlementID: item.EntitlementID,
|
|
RevisionNumber: item.RevisionNumber,
|
|
KoboContentID: item.KoboContentID,
|
|
KoboMetadata: item.KoboMetadata,
|
|
TagsSearch: item.TagsSearch,
|
|
ContributorsSearch: item.ContributorsSearch,
|
|
FileSha256: item.FileSha256,
|
|
OpfIdentifier: item.OpfIdentifier,
|
|
OpfUuid: item.OpfUuid,
|
|
HashConfidence: item.HashConfidence,
|
|
}
|
|
}
|
|
|
|
type DashboardService struct {
|
|
db *database.Queries
|
|
collectionService *CollectionService
|
|
}
|
|
|
|
func NewDashboardService(db *database.Queries) *DashboardService {
|
|
return &DashboardService{
|
|
db: db,
|
|
collectionService: NewCollectionService(db),
|
|
}
|
|
}
|
|
|
|
type DashboardSection struct {
|
|
CollectionID uuid.UUID
|
|
CollectionName string
|
|
Items []database.MediaItems
|
|
QueryType string
|
|
Priority int
|
|
IsSystem bool
|
|
Title string
|
|
Description string
|
|
Icon string
|
|
}
|
|
|
|
func (s *DashboardService) GetDashboardSections(
|
|
ctx context.Context,
|
|
userID, libraryID uuid.UUID,
|
|
limit int,
|
|
collectionOrder []string,
|
|
hiddenCollections []string,
|
|
) ([]DashboardSection, error) {
|
|
var results []DashboardSection
|
|
|
|
systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, coll := range systemCollections {
|
|
items, err := s.getCollectionItemsByQueryType(ctx, coll, userID, libraryID, limit)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
results = append(results, DashboardSection{
|
|
CollectionID: uuid.UUID(coll.ID.Bytes),
|
|
CollectionName: coll.Name,
|
|
Items: items,
|
|
QueryType: coll.QueryType.String,
|
|
Priority: int(coll.Priority.Int32),
|
|
IsSystem: coll.IsSystemCollection.Bool,
|
|
Title: coll.Name,
|
|
Description: coll.Description.String,
|
|
Icon: coll.Icon.String,
|
|
})
|
|
}
|
|
|
|
userCollections, err := s.db.GetUserCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, coll := range userCollections {
|
|
items, err := s.getUserCollectionItems(ctx, coll, userID, libraryID, limit)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if len(items) == 0 {
|
|
continue
|
|
}
|
|
|
|
results = append(results, DashboardSection{
|
|
CollectionID: uuid.UUID(coll.ID.Bytes),
|
|
CollectionName: coll.Name,
|
|
Items: items,
|
|
QueryType: coll.QueryType.String,
|
|
Priority: int(coll.Priority.Int32),
|
|
IsSystem: false,
|
|
Title: coll.Name,
|
|
Description: coll.Description.String,
|
|
Icon: coll.Icon.String,
|
|
})
|
|
}
|
|
|
|
results = s.FilterHiddenCollections(results, hiddenCollections)
|
|
results = s.reorderCollections(results, collectionOrder)
|
|
|
|
if len(collectionOrder) == 0 {
|
|
results = s.sortByPriority(results)
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func (s *DashboardService) FilterHiddenCollections(sections []DashboardSection, hidden []string) []DashboardSection {
|
|
if len(hidden) == 0 {
|
|
return sections
|
|
}
|
|
|
|
var filtered []DashboardSection
|
|
for _, section := range sections {
|
|
isHidden := false
|
|
for _, h := range hidden {
|
|
if section.CollectionName == h {
|
|
isHidden = true
|
|
break
|
|
}
|
|
}
|
|
if !isHidden {
|
|
filtered = append(filtered, section)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func (s *DashboardService) reorderCollections(sections []DashboardSection, order []string) []DashboardSection {
|
|
if len(order) == 0 {
|
|
return sections
|
|
}
|
|
|
|
var ordered []DashboardSection
|
|
remaining := make(map[string]DashboardSection)
|
|
for _, section := range sections {
|
|
remaining[section.CollectionName] = section
|
|
}
|
|
|
|
for _, name := range order {
|
|
if section, exists := remaining[name]; exists {
|
|
ordered = append(ordered, section)
|
|
delete(remaining, name)
|
|
}
|
|
}
|
|
|
|
for _, section := range sections {
|
|
if _, exists := remaining[section.CollectionName]; exists {
|
|
ordered = append(ordered, section)
|
|
}
|
|
}
|
|
|
|
return ordered
|
|
}
|
|
|
|
func (s *DashboardService) sortByPriority(sections []DashboardSection) []DashboardSection {
|
|
sorted := make([]DashboardSection, len(sections))
|
|
copy(sorted, sections)
|
|
|
|
for i := 0; i < len(sorted)-1; i++ {
|
|
for j := 0; j < len(sorted)-i-1; j++ {
|
|
if sorted[j].Priority > sorted[j+1].Priority {
|
|
sorted[j], sorted[j+1] = sorted[j+1], sorted[j]
|
|
}
|
|
}
|
|
}
|
|
|
|
return sorted
|
|
}
|
|
|
|
func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
|
|
switch coll.QueryType.String {
|
|
case "continue-reading":
|
|
return s.db.GetContinueReadingItems(ctx, database.GetContinueReadingItemsParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
|
Limit: int32(limit),
|
|
})
|
|
case "recently-added":
|
|
return s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{
|
|
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
|
Limit: int32(limit),
|
|
})
|
|
case "recently-read":
|
|
return s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
|
Limit: int32(limit),
|
|
})
|
|
case "not-started":
|
|
return s.db.GetNotStartedItems(ctx, database.GetNotStartedItemsParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
|
Limit: int32(limit),
|
|
})
|
|
default:
|
|
return []database.MediaItems{}, nil
|
|
}
|
|
}
|
|
|
|
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
|
|
collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
|
|
|
|
manualItems, err := s.db.GetCollectionItemsForDashboard(ctx, database.GetCollectionItemsForDashboardParams{
|
|
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
|
|
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
|
Limit: int32(limit),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var manualNonExcluded []database.MediaItems
|
|
for _, item := range manualItems {
|
|
if !item.Excluded.Valid || !item.Excluded.Bool {
|
|
manualNonExcluded = append(manualNonExcluded, getCollectionItemsRowToMediaItems(item))
|
|
}
|
|
}
|
|
|
|
var autoItems []database.MediaItems
|
|
if len(coll.AutoAssignRules) > 0 {
|
|
var rules []Rule
|
|
if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 {
|
|
allLibraryItems, err := s.db.GetLibraryItems(ctx, pgtype.UUID{Bytes: libraryID, Valid: true})
|
|
if err == nil {
|
|
for _, item := range allLibraryItems {
|
|
alreadyInCollection := false
|
|
for _, manualItem := range manualNonExcluded {
|
|
manualUUID := uuid.UUID(manualItem.ID.Bytes)
|
|
itemUUID := uuid.UUID(item.ID.Bytes)
|
|
if manualUUID == itemUUID {
|
|
alreadyInCollection = true
|
|
break
|
|
}
|
|
}
|
|
if alreadyInCollection {
|
|
continue
|
|
}
|
|
|
|
listItem := mediaItemsToListMediaItemsRow(item)
|
|
evaluations := s.collectionService.EvaluateRules(listItem, rules)
|
|
for _, eval := range evaluations {
|
|
if eval.Matches {
|
|
autoItems = append(autoItems, item)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var finalItems []database.MediaItems
|
|
finalItems = append(finalItems, manualNonExcluded...)
|
|
finalItems = append(finalItems, autoItems...)
|
|
|
|
if len(finalItems) > limit {
|
|
finalItems = finalItems[:limit]
|
|
}
|
|
|
|
return finalItems, nil
|
|
}
|
|
|
|
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID, libraryID uuid.UUID) (database.UserDashboardPreferences, error) {
|
|
return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
|
})
|
|
}
|
|
|
|
func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, params database.UpsertDashboardPreferencesParams) (database.UserDashboardPreferences, error) {
|
|
return s.db.UpsertDashboardPreferences(ctx, params)
|
|
}
|
|
|
|
func (s *DashboardService) SanitizeDashboardPreferences(hiddenCollections, collectionOrder []string) ([]string, []string) {
|
|
// Deduplicate collection_order while preserving order
|
|
seen := make(map[string]bool)
|
|
var sanitizedOrder []string
|
|
for _, id := range collectionOrder {
|
|
if !seen[id] {
|
|
seen[id] = true
|
|
sanitizedOrder = append(sanitizedOrder, id)
|
|
}
|
|
}
|
|
|
|
// Deduplicate hidden_collections
|
|
seen = make(map[string]bool)
|
|
var sanitizedHidden []string
|
|
for _, id := range hiddenCollections {
|
|
if !seen[id] {
|
|
seen[id] = true
|
|
sanitizedHidden = append(sanitizedHidden, id)
|
|
}
|
|
}
|
|
|
|
return sanitizedHidden, sanitizedOrder
|
|
}
|
|
|
|
func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string, resetType string) error {
|
|
defaultMetadata := map[string]struct {
|
|
Description string
|
|
Icon string
|
|
Color string
|
|
Priority int32
|
|
QueryType string
|
|
}{
|
|
"Continue Reading": {"Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", 1, "continue-reading"},
|
|
"Recently Added": {"Newly added items to this library", "🆕", "#9ece6a", 2, "recently-added"},
|
|
"Recently Read": {"Books you've finished (progress >= 1)", "✅", "#e0af68", 3, "recently-read"},
|
|
"Not Started": {"Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", 4, "not-started"},
|
|
}
|
|
|
|
meta, exists := defaultMetadata[collectionName]
|
|
if !exists {
|
|
return fmt.Errorf("unknown collection: %s", collectionName)
|
|
}
|
|
|
|
if resetType == "keep_books" {
|
|
return s.db.ResetSystemCollectionMetadata(ctx, database.ResetSystemCollectionMetadataParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Name: collectionName,
|
|
Description: pgtype.Text{String: meta.Description, Valid: true},
|
|
Icon: pgtype.Text{String: meta.Icon, Valid: true},
|
|
Color: pgtype.Text{String: meta.Color, Valid: true},
|
|
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
|
|
})
|
|
}
|
|
|
|
err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Name: collectionName,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = s.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Name: collectionName,
|
|
Description: pgtype.Text{String: meta.Description, Valid: true},
|
|
Icon: pgtype.Text{String: meta.Icon, Valid: true},
|
|
Color: pgtype.Text{String: meta.Color, Valid: true},
|
|
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true},
|
|
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
|
|
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
|
|
})
|
|
return err
|
|
}
|