Files
silo-server/internal/catalog/library_collection_repo.go
T
Silo Server Migration dce8978cdf Track template poster origin in collection sync
- add poster_from_template to collection records and update flows
- preserve template-applied posters while keeping admin uploads sticky
- improve template bundle sync/status reporting and MDBList limit handling
2026-05-23 14:19:04 -04:00

1294 lines
40 KiB
Go

package catalog
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/collectionutil"
"github.com/Silo-Server/silo-server/internal/idgen"
"github.com/Silo-Server/silo-server/internal/models"
)
var ErrLibraryCollectionNotFound = errors.New("library collection not found")
type CreateLibraryCollectionInput struct {
LibraryID int
LibraryIDs []int
Slug string
Title string
Description string
CollectionType string
Visibility string
SortOrder int
GroupID *string
Featured bool
PosterURL string
BackdropURL string
PosterThumbhash string
BackdropThumbhash string
PosterAutoGenerated bool
PosterFromTemplate bool
SourceURL string
QueryDefinition json.RawMessage
SortConfig json.RawMessage
SourceConfig json.RawMessage
ManagementMode string
ManagementSource string
ManagementKey string
SyncSchedule *string
}
type UpdateLibraryCollectionInput struct {
ID string
LibraryIDs *[]int
Slug *string
Title *string
Description *string
CollectionType *string
Visibility *string
SortOrder *int
SetGroupID **string
Featured *bool
PosterURL *string
BackdropURL *string
PosterThumbhash *string
BackdropThumbhash *string
PosterAutoGenerated *bool
PosterFromTemplate *bool
SourceURL *string
QueryDefinition json.RawMessage
SortConfig json.RawMessage
SourceConfig json.RawMessage
ManagementMode *string
ManagementSource *string
ManagementKey *string
SyncSchedule *string
NextSyncAt *time.Time
}
type ListLibraryCollectionsOptions struct {
IncludeHidden bool
}
type LibraryCollectionItemInput struct {
MediaItemID string
Position int
SourceRank int
}
type RecordLibraryCollectionSyncRunInput struct {
CollectionID string
Status string
Message string
ItemsAdded int
ItemsRemoved int
ItemsMatched int
ItemsUnmatched int
Warnings json.RawMessage
StartedAt time.Time
CompletedAt time.Time
}
type LibraryCollectionRepository struct {
pool *pgxpool.Pool
}
func NewLibraryCollectionRepository(pool *pgxpool.Pool) *LibraryCollectionRepository {
return &LibraryCollectionRepository{pool: pool}
}
const libraryCollectionColumns = `lc.id, lc.library_id, lc.slug, lc.title, lc.description,
lc.collection_type, lc.visibility, COALESCE(scope_lcl.sort_order, lc.sort_order), scope_lcl.group_id, lc.featured, lc.poster_url,
lc.backdrop_url, lc.poster_thumbhash, lc.backdrop_thumbhash, lc.poster_auto_generated, lc.poster_from_template,
lc.source_url, lc.query_definition, lc.sort_config, lc.source_config,
lc.management_mode, lc.management_source, lc.management_key, lc.last_sync_status,
lc.last_sync_message, lc.last_sync_at, lc.sync_schedule, lc.next_sync_at,
CASE WHEN lc.collection_type = 'smart' THEN lc.item_count_cached
ELSE COALESCE(COUNT(DISTINCT lci.media_item_id), 0) END,
lc.created_at, lc.updated_at,
COALESCE(ARRAY_AGG(DISTINCT lcl.library_id) FILTER (WHERE lcl.library_id IS NOT NULL), ARRAY[]::integer[])`
const libraryCollectionScopeFallbackJoin = `LEFT JOIN LATERAL (
SELECT group_id, sort_order
FROM library_collection_libraries
WHERE collection_id = lc.id
ORDER BY (group_id IS NULL) ASC, updated_at DESC, library_id ASC
LIMIT 1
) scope_lcl ON true`
func scanLibraryCollection(row pgx.Row) (*models.LibraryCollection, error) {
var collection models.LibraryCollection
var libraryIDs []int32
err := row.Scan(
&collection.ID,
&collection.LibraryID,
&collection.Slug,
&collection.Title,
&collection.Description,
&collection.CollectionType,
&collection.Visibility,
&collection.SortOrder,
&collection.GroupID,
&collection.Featured,
&collection.PosterURL,
&collection.BackdropURL,
&collection.PosterThumbhash,
&collection.BackdropThumbhash,
&collection.PosterAutoGenerated,
&collection.PosterFromTemplate,
&collection.SourceURL,
&collection.QueryDefinition,
&collection.SortConfig,
&collection.SourceConfig,
&collection.ManagementMode,
&collection.ManagementSource,
&collection.ManagementKey,
&collection.LastSyncStatus,
&collection.LastSyncMessage,
&collection.LastSyncAt,
&collection.SyncSchedule,
&collection.NextSyncAt,
&collection.ItemCount,
&collection.CreatedAt,
&collection.UpdatedAt,
&libraryIDs,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrLibraryCollectionNotFound
}
return nil, fmt.Errorf("scanning library collection: %w", err)
}
collection.LibraryIDs = int32SliceToInts(libraryIDs)
if len(collection.LibraryIDs) == 0 && collection.LibraryID > 0 {
collection.LibraryIDs = []int{collection.LibraryID}
}
return &collection, nil
}
func scanLibraryCollections(rows pgx.Rows) ([]*models.LibraryCollection, error) {
var collections []*models.LibraryCollection
for rows.Next() {
collection, err := scanLibraryCollection(rows)
if err != nil {
return nil, err
}
collections = append(collections, collection)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating library collections: %w", err)
}
return collections, nil
}
func (r *LibraryCollectionRepository) Create(ctx context.Context, input CreateLibraryCollectionInput) (*models.LibraryCollection, error) {
input.LibraryIDs = normalizeCollectionLibraryIDs(input.LibraryID, input.LibraryIDs)
if len(input.SourceConfig) == 0 {
input.SourceConfig = json.RawMessage(`{}`)
}
if len(input.QueryDefinition) == 0 {
input.QueryDefinition = json.RawMessage(`{}`)
}
if len(input.SortConfig) == 0 {
input.SortConfig = json.RawMessage(`{}`)
}
if input.Visibility == "" {
input.Visibility = "visible"
}
if input.CollectionType == "" {
input.CollectionType = "manual"
}
if input.ManagementMode == "" {
input.ManagementMode = "manual"
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("beginning library collection create: %w", err)
}
defer tx.Rollback(ctx)
query := `
INSERT INTO library_collections (
id, library_id, slug, title, description, collection_type, visibility,
sort_order, group_label, featured, poster_url, backdrop_url,
poster_thumbhash, backdrop_thumbhash, poster_auto_generated, poster_from_template,
source_url, query_definition, sort_config, source_config,
management_mode, management_source, management_key,
sync_schedule, next_sync_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7,
$8, '', $9, $10, $11,
$12, $13, $14, $15,
$16, $17, $18, $19,
$20, $21, $22,
$23, $24
)
RETURNING id`
id, err := idgen.NextID()
if err != nil {
return nil, fmt.Errorf("generate id: %w", err)
}
legacyLibraryID := input.LibraryID
if legacyLibraryID <= 0 && len(input.LibraryIDs) > 0 {
legacyLibraryID = input.LibraryIDs[0]
}
if err := tx.QueryRow(
ctx,
query,
id,
legacyLibraryID,
input.Slug,
input.Title,
input.Description,
input.CollectionType,
input.Visibility,
input.SortOrder,
input.Featured,
input.PosterURL,
input.BackdropURL,
input.PosterThumbhash,
input.BackdropThumbhash,
input.PosterAutoGenerated,
input.PosterFromTemplate,
input.SourceURL,
input.QueryDefinition,
input.SortConfig,
input.SourceConfig,
input.ManagementMode,
input.ManagementSource,
input.ManagementKey,
input.SyncSchedule,
computeNextSyncAt(input.SyncSchedule),
).Scan(&id); err != nil {
return nil, fmt.Errorf("creating library collection: %w", err)
}
var groupLibraryID *int
if input.GroupID != nil {
var (
libraryID int
kind models.LibraryCollectionGroupKind
)
if err := tx.QueryRow(ctx, `
SELECT library_id, kind
FROM library_collection_groups
WHERE id = $1
`, *input.GroupID).Scan(&libraryID, &kind); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrLibraryCollectionGroupNotFound
}
return nil, fmt.Errorf("loading collection group: %w", err)
}
if kind == models.GroupKindUserCollections {
return nil, fmt.Errorf("user-collections group cannot accept admin collections")
}
groupLibraryID = &libraryID
}
groupMatchedLibrary := input.GroupID == nil
for _, libraryID := range input.LibraryIDs {
var membershipGroupID *string
if groupLibraryID != nil && libraryID == *groupLibraryID {
membershipGroupID = input.GroupID
groupMatchedLibrary = true
}
if _, err := tx.Exec(ctx, `
INSERT INTO library_collection_libraries (collection_id, library_id, group_id, sort_order)
SELECT $1, $2, $3,
COALESCE((
SELECT MAX(sort_order) + 1
FROM library_collection_libraries
WHERE library_id = $2
AND group_id IS NOT DISTINCT FROM $3
), 0)
ON CONFLICT (collection_id, library_id) DO NOTHING
`, id, libraryID, membershipGroupID); err != nil {
return nil, fmt.Errorf("creating collection library scope: %w", err)
}
}
if !groupMatchedLibrary {
return nil, fmt.Errorf("collection group library is not included in collection scope")
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("committing library collection create: %w", err)
}
return r.GetByID(ctx, id)
}
func (r *LibraryCollectionRepository) GetByID(ctx context.Context, id string) (*models.LibraryCollection, error) {
query := fmt.Sprintf(`
SELECT %s
FROM library_collections lc
%s
LEFT JOIN library_collection_items lci ON lci.collection_id = lc.id
LEFT JOIN library_collection_libraries lcl ON lcl.collection_id = lc.id
WHERE lc.id = $1
GROUP BY lc.id, scope_lcl.group_id, scope_lcl.sort_order
`, libraryCollectionColumns, libraryCollectionScopeFallbackJoin)
return scanLibraryCollection(r.pool.QueryRow(ctx, query, id))
}
// GetByManagementKey returns the collection that owns the given management
// key within the supplied mode/source pair. Scoping by mode and source guards
// against future mode collisions on the unique-key index returning a
// surface-owned collection to a caller from a different surface.
func (r *LibraryCollectionRepository) GetByManagementKey(ctx context.Context, mode, source, key string) (*models.LibraryCollection, error) {
query := fmt.Sprintf(`
SELECT %s
FROM library_collections lc
%s
LEFT JOIN library_collection_items lci ON lci.collection_id = lc.id
LEFT JOIN library_collection_libraries lcl ON lcl.collection_id = lc.id
WHERE lc.management_mode = $1
AND lc.management_source = $2
AND lc.management_key = $3
GROUP BY lc.id, scope_lcl.group_id, scope_lcl.sort_order
`, libraryCollectionColumns, libraryCollectionScopeFallbackJoin)
return scanLibraryCollection(r.pool.QueryRow(ctx, query, mode, source, key))
}
func (r *LibraryCollectionRepository) ListByLibrary(ctx context.Context, libraryID int, opts ListLibraryCollectionsOptions) ([]*models.LibraryCollection, error) {
return r.ListAll(ctx, &libraryID, opts)
}
func (r *LibraryCollectionRepository) ListAll(ctx context.Context, libraryID *int, opts ListLibraryCollectionsOptions) ([]*models.LibraryCollection, error) {
var (
conditions []string
args []any
scopeJoin string
)
argIdx := 1
if libraryID != nil {
scopeJoin = fmt.Sprintf(`JOIN library_collection_libraries scope_lcl
ON scope_lcl.collection_id = lc.id AND scope_lcl.library_id = $%d`, argIdx)
args = append(args, *libraryID)
argIdx++
} else {
scopeJoin = libraryCollectionScopeFallbackJoin
}
if !opts.IncludeHidden {
conditions = append(conditions, "lc.visibility = 'visible'")
}
if len(conditions) == 0 {
conditions = append(conditions, "1 = 1")
}
query := fmt.Sprintf(`
SELECT %s
FROM library_collections lc
%s
LEFT JOIN library_collection_items lci ON lci.collection_id = lc.id
LEFT JOIN library_collection_libraries lcl ON lcl.collection_id = lc.id
WHERE %s
GROUP BY lc.id, scope_lcl.group_id, scope_lcl.sort_order
ORDER BY lc.featured DESC, COALESCE(scope_lcl.sort_order, lc.sort_order) ASC, lc.title ASC
`, libraryCollectionColumns, scopeJoin, strings.Join(conditions, " AND "))
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("listing library collections: %w", err)
}
defer rows.Close()
return scanLibraryCollections(rows)
}
func (r *LibraryCollectionRepository) Update(ctx context.Context, input UpdateLibraryCollectionInput) error {
var (
sets []string
args []any
)
argIdx := 1
if input.Slug != nil {
sets = append(sets, fmt.Sprintf("slug = $%d", argIdx))
args = append(args, *input.Slug)
argIdx++
}
if input.Title != nil {
sets = append(sets, fmt.Sprintf("title = $%d", argIdx))
args = append(args, *input.Title)
argIdx++
}
if input.Description != nil {
sets = append(sets, fmt.Sprintf("description = $%d", argIdx))
args = append(args, *input.Description)
argIdx++
}
if input.CollectionType != nil {
sets = append(sets, fmt.Sprintf("collection_type = $%d", argIdx))
args = append(args, *input.CollectionType)
argIdx++
}
if input.Visibility != nil {
sets = append(sets, fmt.Sprintf("visibility = $%d", argIdx))
args = append(args, *input.Visibility)
argIdx++
}
if input.SortOrder != nil {
sets = append(sets, fmt.Sprintf("sort_order = $%d", argIdx))
args = append(args, *input.SortOrder)
argIdx++
}
if input.Featured != nil {
sets = append(sets, fmt.Sprintf("featured = $%d", argIdx))
args = append(args, *input.Featured)
argIdx++
}
if input.PosterURL != nil {
sets = append(sets, fmt.Sprintf("poster_url = $%d", argIdx))
args = append(args, *input.PosterURL)
argIdx++
}
if input.BackdropURL != nil {
sets = append(sets, fmt.Sprintf("backdrop_url = $%d", argIdx))
args = append(args, *input.BackdropURL)
argIdx++
}
if input.PosterThumbhash != nil {
sets = append(sets, fmt.Sprintf("poster_thumbhash = $%d", argIdx))
args = append(args, *input.PosterThumbhash)
argIdx++
}
if input.BackdropThumbhash != nil {
sets = append(sets, fmt.Sprintf("backdrop_thumbhash = $%d", argIdx))
args = append(args, *input.BackdropThumbhash)
argIdx++
}
if input.PosterAutoGenerated != nil {
sets = append(sets, fmt.Sprintf("poster_auto_generated = $%d", argIdx))
args = append(args, *input.PosterAutoGenerated)
argIdx++
}
if input.PosterFromTemplate != nil {
sets = append(sets, fmt.Sprintf("poster_from_template = $%d", argIdx))
args = append(args, *input.PosterFromTemplate)
argIdx++
}
if input.SourceURL != nil {
sets = append(sets, fmt.Sprintf("source_url = $%d", argIdx))
args = append(args, *input.SourceURL)
argIdx++
}
if len(input.QueryDefinition) > 0 {
sets = append(sets, fmt.Sprintf("query_definition = $%d", argIdx))
args = append(args, input.QueryDefinition)
argIdx++
}
if len(input.SortConfig) > 0 {
sets = append(sets, fmt.Sprintf("sort_config = $%d", argIdx))
args = append(args, input.SortConfig)
argIdx++
}
if len(input.SourceConfig) > 0 {
sets = append(sets, fmt.Sprintf("source_config = $%d", argIdx))
args = append(args, input.SourceConfig)
argIdx++
}
if input.ManagementMode != nil {
sets = append(sets, fmt.Sprintf("management_mode = $%d", argIdx))
args = append(args, *input.ManagementMode)
argIdx++
}
if input.ManagementSource != nil {
sets = append(sets, fmt.Sprintf("management_source = $%d", argIdx))
args = append(args, *input.ManagementSource)
argIdx++
}
if input.ManagementKey != nil {
sets = append(sets, fmt.Sprintf("management_key = $%d", argIdx))
args = append(args, *input.ManagementKey)
argIdx++
}
if input.SyncSchedule != nil {
if *input.SyncSchedule == "" {
// Clearing schedule: null both columns.
sets = append(sets, fmt.Sprintf("sync_schedule = $%d", argIdx))
args = append(args, nil)
argIdx++
sets = append(sets, fmt.Sprintf("next_sync_at = $%d", argIdx))
args = append(args, nil)
argIdx++
} else {
sets = append(sets, fmt.Sprintf("sync_schedule = $%d", argIdx))
args = append(args, *input.SyncSchedule)
argIdx++
sets = append(sets, fmt.Sprintf("next_sync_at = $%d", argIdx))
args = append(args, computeNextSyncAt(input.SyncSchedule))
argIdx++
}
}
// Only set NextSyncAt directly if SyncSchedule was not already handled above
// (which computes next_sync_at automatically).
if input.NextSyncAt != nil && input.SyncSchedule == nil {
sets = append(sets, fmt.Sprintf("next_sync_at = $%d", argIdx))
args = append(args, *input.NextSyncAt)
argIdx++
}
if len(sets) == 0 && input.LibraryIDs == nil && input.SetGroupID == nil {
_, err := r.GetByID(ctx, input.ID)
return err
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning library collection update: %w", err)
}
defer tx.Rollback(ctx)
if len(sets) > 0 {
sets = append(sets, "updated_at = NOW()")
args = append(args, input.ID)
query := fmt.Sprintf("UPDATE library_collections SET %s WHERE id = $%d", strings.Join(sets, ", "), argIdx)
tag, err := tx.Exec(ctx, query, args...)
if err != nil {
return fmt.Errorf("updating library collection: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrLibraryCollectionNotFound
}
} else {
var exists bool
if err := tx.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM library_collections WHERE id = $1)", input.ID).Scan(&exists); err != nil {
return fmt.Errorf("checking library collection existence: %w", err)
}
if !exists {
return ErrLibraryCollectionNotFound
}
}
var updatedLibraryIDs []int
if input.LibraryIDs != nil {
updatedLibraryIDs = normalizeCollectionLibraryIDs(0, *input.LibraryIDs)
if _, err := tx.Exec(ctx, `
DELETE FROM library_collection_libraries
WHERE collection_id = $1
AND NOT (library_id = ANY($2::int[]))
`, input.ID, updatedLibraryIDs); err != nil {
return fmt.Errorf("deleting removed collection library scopes: %w", err)
}
for _, libraryID := range updatedLibraryIDs {
if _, err := tx.Exec(ctx, `
INSERT INTO library_collection_libraries (collection_id, library_id, group_id, sort_order)
SELECT $1, $2, NULL,
COALESCE((
SELECT MAX(sort_order) + 1
FROM library_collection_libraries
WHERE library_id = $2 AND group_id IS NULL
), 0)
ON CONFLICT (collection_id, library_id) DO NOTHING
`, input.ID, libraryID); err != nil {
return fmt.Errorf("writing collection library scope: %w", err)
}
}
legacyLibraryID := 0
if len(updatedLibraryIDs) > 0 {
legacyLibraryID = updatedLibraryIDs[0]
}
if _, err := tx.Exec(ctx, "UPDATE library_collections SET library_id = $2 WHERE id = $1", input.ID, legacyLibraryID); err != nil {
return fmt.Errorf("updating legacy library_id: %w", err)
}
}
if input.SetGroupID != nil {
targetGroupID := *input.SetGroupID
targetLibraryIDs := updatedLibraryIDs
if targetGroupID != nil {
var (
libraryID int
kind models.LibraryCollectionGroupKind
)
if err := tx.QueryRow(ctx, `
SELECT library_id, kind
FROM library_collection_groups
WHERE id = $1
`, *targetGroupID).Scan(&libraryID, &kind); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrLibraryCollectionGroupNotFound
}
return fmt.Errorf("loading collection group: %w", err)
}
if kind == models.GroupKindUserCollections {
return fmt.Errorf("user-collections group cannot accept admin collections")
}
targetLibraryIDs = []int{libraryID}
} else if input.LibraryIDs == nil {
rows, err := tx.Query(ctx, `
SELECT library_id
FROM library_collection_libraries
WHERE collection_id = $1
ORDER BY library_id ASC
`, input.ID)
if err != nil {
return fmt.Errorf("loading collection memberships for group update: %w", err)
}
for rows.Next() {
var libraryID int
if err := rows.Scan(&libraryID); err != nil {
rows.Close()
return fmt.Errorf("scanning collection membership: %w", err)
}
targetLibraryIDs = append(targetLibraryIDs, libraryID)
}
rows.Close()
if err := rows.Err(); err != nil {
return fmt.Errorf("iterating collection memberships: %w", err)
}
}
for _, libraryID := range targetLibraryIDs {
tag, err := tx.Exec(ctx, `
UPDATE library_collection_libraries
SET group_id = $1,
sort_order = COALESCE((
SELECT MAX(sort_order) + 1
FROM library_collection_libraries
WHERE library_id = $2
AND group_id IS NOT DISTINCT FROM $1
), 0),
updated_at = NOW()
WHERE collection_id = $3 AND library_id = $2
`, targetGroupID, libraryID, input.ID)
if err != nil {
return fmt.Errorf("updating collection group membership: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrLibraryCollectionNotFound
}
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("committing library collection update: %w", err)
}
return nil
}
func (r *LibraryCollectionRepository) Delete(ctx context.Context, id string) error {
tag, err := r.pool.Exec(ctx, "DELETE FROM library_collections WHERE id = $1", id)
if err != nil {
return fmt.Errorf("deleting library collection: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrLibraryCollectionNotFound
}
return nil
}
func (r *LibraryCollectionRepository) ReplaceItems(ctx context.Context, collectionID string, items []LibraryCollectionItemInput) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning collection item replacement: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, "DELETE FROM library_collection_items WHERE collection_id = $1", collectionID); err != nil {
return fmt.Errorf("clearing collection items: %w", err)
}
for _, item := range items {
if _, err := tx.Exec(ctx, `
INSERT INTO library_collection_items (
collection_id, media_item_id, position, source_rank
) VALUES ($1, $2, $3, $4)
`, collectionID, item.MediaItemID, item.Position, item.SourceRank); err != nil {
return fmt.Errorf("inserting collection item %s: %w", item.MediaItemID, err)
}
}
if _, err := tx.Exec(ctx, "UPDATE library_collections SET updated_at = NOW() WHERE id = $1", collectionID); err != nil {
return fmt.Errorf("touching library collection: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("committing collection item replacement: %w", err)
}
return nil
}
func (r *LibraryCollectionRepository) ListItems(ctx context.Context, collectionID string) ([]*models.LibraryCollectionItem, error) {
rows, err := r.pool.Query(ctx, `
SELECT collection_id, media_item_id, position, source_rank, created_at, updated_at
FROM library_collection_items
WHERE collection_id = $1
ORDER BY position ASC, source_rank ASC, media_item_id ASC
`, collectionID)
if err != nil {
return nil, fmt.Errorf("listing collection items: %w", err)
}
defer rows.Close()
var items []*models.LibraryCollectionItem
for rows.Next() {
var item models.LibraryCollectionItem
if err := rows.Scan(
&item.CollectionID,
&item.MediaItemID,
&item.Position,
&item.SourceRank,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scanning collection item: %w", err)
}
items = append(items, &item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating collection items: %w", err)
}
return items, nil
}
// AddItem inserts or upserts a single item in a manual collection. The
// caller is responsible for verifying the collection's type before invoking
// this — synced collections (mdblist/tmdb/trakt) overwrite manual edits on
// their next sync.
func (r *LibraryCollectionRepository) AddItem(ctx context.Context, collectionID, mediaItemID string, position int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning add collection item: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `
INSERT INTO library_collection_items (collection_id, media_item_id, position, source_rank)
VALUES ($1, $2, $3, 0)
ON CONFLICT (collection_id, media_item_id)
DO UPDATE SET position = EXCLUDED.position
`, collectionID, mediaItemID, position); err != nil {
return fmt.Errorf("upserting collection item: %w", err)
}
if _, err := tx.Exec(ctx, "UPDATE library_collections SET updated_at = NOW() WHERE id = $1", collectionID); err != nil {
return fmt.Errorf("touching library collection: %w", err)
}
return tx.Commit(ctx)
}
// RemoveItem deletes a single item from a collection.
func (r *LibraryCollectionRepository) RemoveItem(ctx context.Context, collectionID, mediaItemID string) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning remove collection item: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `
DELETE FROM library_collection_items
WHERE collection_id = $1 AND media_item_id = $2
`, collectionID, mediaItemID); err != nil {
return fmt.Errorf("deleting collection item: %w", err)
}
if _, err := tx.Exec(ctx, "UPDATE library_collections SET updated_at = NOW() WHERE id = $1", collectionID); err != nil {
return fmt.Errorf("touching library collection: %w", err)
}
return tx.Commit(ctx)
}
// ReorderItems sets each item's position to its index in the supplied list.
// The list must be a permutation of the existing membership.
func (r *LibraryCollectionRepository) ReorderItems(ctx context.Context, collectionID string, orderedMediaItemIDs []string) error {
if collectionutil.HasDuplicateOrderedIDs(orderedMediaItemIDs) {
return fmt.Errorf("ordered_ids contains duplicates")
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning collection items reorder: %w", err)
}
defer tx.Rollback(ctx)
var updated, total int
if err := tx.QueryRow(ctx, `
WITH supplied AS (
SELECT id, pos FROM unnest($1::text[]) WITH ORDINALITY AS u(id, pos)
),
upd AS (
UPDATE library_collection_items t
SET position = supplied.pos - 1
FROM supplied
WHERE t.collection_id = $2 AND t.media_item_id = supplied.id
RETURNING 1
)
SELECT (SELECT count(*) FROM upd),
(SELECT count(*) FROM library_collection_items WHERE collection_id = $2)
`, orderedMediaItemIDs, collectionID).Scan(&updated, &total); err != nil {
return fmt.Errorf("reordering collection items: %w", err)
}
if updated != len(orderedMediaItemIDs) || updated != total {
return collectionutil.ErrOrderedIDsMismatch
}
if _, err := tx.Exec(ctx, "UPDATE library_collections SET updated_at = NOW() WHERE id = $1", collectionID); err != nil {
return fmt.Errorf("touching library collection: %w", err)
}
return tx.Commit(ctx)
}
// ReorderCollections sets each membership row's sort_order to its index in the
// supplied list, scoped to the given library. The list must be a permutation of
// the collections currently exposed in that library, or when groupID is non-nil
// just the memberships in that group.
func (r *LibraryCollectionRepository) ReorderCollections(ctx context.Context, libraryID int, groupID *string, orderedIDs []string) error {
if collectionutil.HasDuplicateOrderedIDs(orderedIDs) {
return fmt.Errorf("ordered_ids contains duplicates")
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning collection reorder: %w", err)
}
defer tx.Rollback(ctx)
var updated, total int
if err := tx.QueryRow(ctx, `
WITH supplied AS (
SELECT id, pos FROM unnest($1::text[]) WITH ORDINALITY AS u(id, pos)
),
upd AS (
UPDATE library_collection_libraries t
SET sort_order = supplied.pos - 1, updated_at = NOW()
FROM supplied
WHERE t.collection_id = supplied.id
AND t.library_id = $2
AND t.group_id IS NOT DISTINCT FROM $3
RETURNING 1
)
SELECT (SELECT count(*) FROM upd),
(SELECT count(*) FROM library_collection_libraries
WHERE library_id = $2
AND group_id IS NOT DISTINCT FROM $3)
`, orderedIDs, libraryID, groupID).Scan(&updated, &total); err != nil {
return fmt.Errorf("reordering collections: %w", err)
}
if updated != len(orderedIDs) || updated != total {
return collectionutil.ErrOrderedIDsMismatch
}
return tx.Commit(ctx)
}
// ListByGroup returns all visible collections in a library that belong to the
// given group. Pass nil for groupID to list the implicit Ungrouped bucket.
func (r *LibraryCollectionRepository) ListByGroup(ctx context.Context, libraryID int, groupID *string) ([]*models.LibraryCollection, error) {
rows, err := r.pool.Query(ctx, fmt.Sprintf(`
SELECT %s
FROM library_collections lc
JOIN library_collection_libraries scope_lcl
ON scope_lcl.collection_id = lc.id AND scope_lcl.library_id = $1
LEFT JOIN library_collection_items lci ON lci.collection_id = lc.id
LEFT JOIN library_collection_libraries lcl ON lcl.collection_id = lc.id
WHERE lc.visibility = 'visible'
AND scope_lcl.group_id IS NOT DISTINCT FROM $2
GROUP BY lc.id, scope_lcl.group_id, scope_lcl.sort_order
ORDER BY scope_lcl.sort_order ASC, lc.title ASC
`, libraryCollectionColumns), libraryID, groupID)
if err != nil {
return nil, fmt.Errorf("listing collections by group: %w", err)
}
defer rows.Close()
return scanLibraryCollections(rows)
}
type MoveAndReorderInput struct {
LibraryID int
TargetGroupID *string
OrderedIDs []string
Strict bool
}
type StrictReorderError struct {
MissingIDs []string
}
func (e *StrictReorderError) Error() string {
return fmt.Sprintf("strict reorder: %d collection(s) currently in this group are missing from the request", len(e.MissingIDs))
}
// MoveAndReorder atomically assigns every collection in OrderedIDs to
// TargetGroupID within one library and rewrites the target group's sort_order.
func (r *LibraryCollectionRepository) MoveAndReorder(ctx context.Context, in MoveAndReorderInput) error {
if collectionutil.HasDuplicateOrderedIDs(in.OrderedIDs) {
return fmt.Errorf("ordered_ids contains duplicates")
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning collection move/reorder: %w", err)
}
defer tx.Rollback(ctx)
if in.TargetGroupID != nil {
var kind models.LibraryCollectionGroupKind
if err := tx.QueryRow(ctx, `
SELECT kind
FROM library_collection_groups
WHERE id = $1 AND library_id = $2
`, *in.TargetGroupID, in.LibraryID).Scan(&kind); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrLibraryCollectionGroupNotFound
}
return fmt.Errorf("loading target group: %w", err)
}
if kind == models.GroupKindUserCollections {
return fmt.Errorf("user-collections group cannot accept admin collections")
}
}
rows, err := tx.Query(ctx, `
SELECT collection_id, group_id
FROM library_collection_libraries
WHERE library_id = $1
AND collection_id = ANY($2::text[])
FOR UPDATE
`, in.LibraryID, in.OrderedIDs)
if err != nil {
return fmt.Errorf("locking collection memberships: %w", err)
}
seen := map[string]*string{}
sourceGroups := map[string]*string{}
for rows.Next() {
var id string
var groupID *string
if err := rows.Scan(&id, &groupID); err != nil {
rows.Close()
return fmt.Errorf("scanning collection membership: %w", err)
}
seen[id] = groupID
sourceGroups[groupKey(groupID)] = groupID
}
rows.Close()
if err := rows.Err(); err != nil {
return fmt.Errorf("iterating collection memberships: %w", err)
}
for _, id := range in.OrderedIDs {
if _, ok := seen[id]; !ok {
return fmt.Errorf("collection %q not found in library %d", id, in.LibraryID)
}
}
if in.Strict {
rows, err := tx.Query(ctx, `
SELECT collection_id
FROM library_collection_libraries
WHERE library_id = $1
AND group_id IS NOT DISTINCT FROM $2
`, in.LibraryID, in.TargetGroupID)
if err != nil {
return fmt.Errorf("listing target group membership: %w", err)
}
want := map[string]bool{}
for _, id := range in.OrderedIDs {
want[id] = true
}
var missing []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return fmt.Errorf("scanning target group membership: %w", err)
}
if !want[id] {
missing = append(missing, id)
}
}
rows.Close()
if err := rows.Err(); err != nil {
return fmt.Errorf("iterating target group membership: %w", err)
}
if len(missing) > 0 {
return &StrictReorderError{MissingIDs: missing}
}
}
for idx, id := range in.OrderedIDs {
if _, err := tx.Exec(ctx, `
UPDATE library_collection_libraries
SET group_id = $1, sort_order = $2, updated_at = NOW()
WHERE library_id = $3 AND collection_id = $4
`, in.TargetGroupID, idx, in.LibraryID, id); err != nil {
return fmt.Errorf("updating collection membership: %w", err)
}
}
sourceGroups[groupKey(in.TargetGroupID)] = in.TargetGroupID
for _, groupID := range sourceGroups {
if err := compactCollectionMembershipOrder(ctx, tx, in.LibraryID, groupID); err != nil {
return err
}
}
return tx.Commit(ctx)
}
func (r *LibraryCollectionRepository) RecordSyncRun(ctx context.Context, input RecordLibraryCollectionSyncRunInput) (*models.LibraryCollectionSyncRun, error) {
if len(input.Warnings) == 0 {
input.Warnings = json.RawMessage(`[]`)
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("beginning sync run insert: %w", err)
}
defer tx.Rollback(ctx)
id, err := idgen.NextID()
if err != nil {
return nil, fmt.Errorf("generate id: %w", err)
}
var run models.LibraryCollectionSyncRun
err = tx.QueryRow(ctx, `
INSERT INTO library_collection_sync_runs (
id, collection_id, status, message, items_added, items_removed,
items_matched, items_unmatched, warnings, started_at, completed_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11
)
RETURNING id, collection_id, status, message, items_added, items_removed,
items_matched, items_unmatched, warnings, started_at, completed_at, created_at
`,
id,
input.CollectionID,
input.Status,
input.Message,
input.ItemsAdded,
input.ItemsRemoved,
input.ItemsMatched,
input.ItemsUnmatched,
input.Warnings,
input.StartedAt,
input.CompletedAt,
).Scan(
&run.ID,
&run.CollectionID,
&run.Status,
&run.Message,
&run.ItemsAdded,
&run.ItemsRemoved,
&run.ItemsMatched,
&run.ItemsUnmatched,
&run.Warnings,
&run.StartedAt,
&run.CompletedAt,
&run.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("inserting sync run: %w", err)
}
lastSyncAt := input.CompletedAt
if _, err := tx.Exec(ctx, `
UPDATE library_collections
SET last_sync_status = $2,
last_sync_message = $3,
last_sync_at = $4,
updated_at = NOW()
WHERE id = $1
`, input.CollectionID, input.Status, input.Message, lastSyncAt); err != nil {
return nil, fmt.Errorf("updating collection sync state: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("committing sync run: %w", err)
}
return &run, nil
}
func (r *LibraryCollectionRepository) ListSyncRuns(ctx context.Context, collectionID string, limit int) ([]*models.LibraryCollectionSyncRun, error) {
if limit <= 0 {
limit = 20
}
rows, err := r.pool.Query(ctx, `
SELECT id, collection_id, status, message, items_added, items_removed,
items_matched, items_unmatched, warnings, started_at, completed_at, created_at
FROM library_collection_sync_runs
WHERE collection_id = $1
ORDER BY created_at DESC
LIMIT $2
`, collectionID, limit)
if err != nil {
return nil, fmt.Errorf("listing sync runs: %w", err)
}
defer rows.Close()
var runs []*models.LibraryCollectionSyncRun
for rows.Next() {
var run models.LibraryCollectionSyncRun
if err := rows.Scan(
&run.ID,
&run.CollectionID,
&run.Status,
&run.Message,
&run.ItemsAdded,
&run.ItemsRemoved,
&run.ItemsMatched,
&run.ItemsUnmatched,
&run.Warnings,
&run.StartedAt,
&run.CompletedAt,
&run.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scanning sync run: %w", err)
}
runs = append(runs, &run)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating sync runs: %w", err)
}
return runs, nil
}
func normalizeCollectionLibraryIDs(legacyLibraryID int, libraryIDs []int) []int {
if len(libraryIDs) == 0 && legacyLibraryID > 0 {
libraryIDs = []int{legacyLibraryID}
}
if len(libraryIDs) == 0 {
return nil
}
seen := make(map[int]struct{}, len(libraryIDs))
normalized := make([]int, 0, len(libraryIDs))
for _, id := range libraryIDs {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
normalized = append(normalized, id)
}
return normalized
}
func groupKey(groupID *string) string {
if groupID == nil {
return ""
}
return *groupID
}
func compactCollectionMembershipOrder(ctx context.Context, tx pgx.Tx, libraryID int, groupID *string) error {
_, err := tx.Exec(ctx, `
WITH ranked AS (
SELECT collection_id,
ROW_NUMBER() OVER (ORDER BY sort_order ASC, collection_id ASC) - 1 AS pos
FROM library_collection_libraries
WHERE library_id = $1
AND group_id IS NOT DISTINCT FROM $2
)
UPDATE library_collection_libraries lcl
SET sort_order = ranked.pos, updated_at = NOW()
FROM ranked
WHERE lcl.library_id = $1
AND lcl.collection_id = ranked.collection_id
AND lcl.group_id IS NOT DISTINCT FROM $2
`, libraryID, groupID)
if err != nil {
return fmt.Errorf("compacting collection membership order: %w", err)
}
return nil
}
// ListDueForSync returns syncable collections whose next_sync_at is in the past.
// Note: in multi-node deployments, concurrent callers may receive overlapping
// sets. The in-memory inFlight guard in CollectionSyncScheduler prevents
// double-syncs within a single process.
func (r *LibraryCollectionRepository) ListDueForSync(ctx context.Context) ([]*models.LibraryCollection, error) {
query := fmt.Sprintf(`
SELECT %s
FROM library_collections lc
%s
LEFT JOIN library_collection_items lci ON lci.collection_id = lc.id
LEFT JOIN library_collection_libraries lcl ON lcl.collection_id = lc.id
WHERE lc.sync_schedule IS NOT NULL
AND lc.next_sync_at IS NOT NULL
AND lc.next_sync_at <= NOW()
AND lc.collection_type IN ('mdblist', 'tmdb', 'trakt')
GROUP BY lc.id, scope_lcl.group_id, scope_lcl.sort_order
ORDER BY lc.next_sync_at ASC
`, libraryCollectionColumns, libraryCollectionScopeFallbackJoin)
rows, err := r.pool.Query(ctx, query)
if err != nil {
return nil, fmt.Errorf("listing due collections: %w", err)
}
defer rows.Close()
return scanLibraryCollections(rows)
}
// UpdateNextSyncAt advances a collection's next scheduled sync time.
func (r *LibraryCollectionRepository) UpdateNextSyncAt(ctx context.Context, id string, next *time.Time) error {
_, err := r.pool.Exec(ctx, `
UPDATE library_collections
SET next_sync_at = $2, updated_at = NOW()
WHERE id = $1
`, id, next)
if err != nil {
return fmt.Errorf("updating next sync at: %w", err)
}
return nil
}
// ListItemPosterPaths returns up to limit non-empty poster_path values for
// items in the given collection, ordered by position. Paths may be bare S3
// keys, plugin-prefixed URIs, or HTTP URLs — the caller is responsible for
// resolving them via PresignImageURL.
func (r *LibraryCollectionRepository) ListItemPosterPaths(ctx context.Context, collectionID string, limit int) ([]string, error) {
rows, err := r.pool.Query(ctx, `
SELECT mi.poster_path
FROM library_collection_items lci
JOIN media_items mi ON mi.content_id = lci.media_item_id
WHERE lci.collection_id = $1
AND mi.poster_path <> ''
ORDER BY lci.position ASC, lci.source_rank ASC
LIMIT $2
`, collectionID, limit)
if err != nil {
return nil, fmt.Errorf("listing item poster paths: %w", err)
}
defer rows.Close()
var paths []string
for rows.Next() {
var path string
if err := rows.Scan(&path); err != nil {
return nil, fmt.Errorf("scanning poster path: %w", err)
}
paths = append(paths, path)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating poster paths: %w", err)
}
return paths, nil
}
func int32SliceToInts(values []int32) []int {
if len(values) == 0 {
return nil
}
result := make([]int, 0, len(values))
for _, value := range values {
result = append(result, int(value))
}
return result
}