- 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
1294 lines
40 KiB
Go
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
|
|
}
|