package catalog import ( "context" "fmt" "slices" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/Silo-Server/silo-server/internal/models" ) // LibraryItemRepository provides CRUD operations for the media_item_libraries // junction table. type LibraryItemRepository struct { pool *pgxpool.Pool } // NewLibraryItemRepository creates a new LibraryItemRepository backed by the // given pool. func NewLibraryItemRepository(pool *pgxpool.Pool) *LibraryItemRepository { return &LibraryItemRepository{pool: pool} } // libraryItemColumns is the list of columns returned by all SELECT queries on // media_item_libraries. const libraryItemColumns = `content_id, media_folder_id, first_seen_at` // scanLibraryItem scans a single row into a *models.MediaItemLibrary. func scanLibraryItem(row pgx.Row) (*models.MediaItemLibrary, error) { var lib models.MediaItemLibrary err := row.Scan( &lib.ContentID, &lib.MediaFolderID, &lib.FirstSeenAt, ) if err != nil { return nil, fmt.Errorf("scanning media item library: %w", err) } return &lib, nil } // scanLibraryItems scans multiple rows into a []*models.MediaItemLibrary slice. func scanLibraryItems(rows pgx.Rows) ([]*models.MediaItemLibrary, error) { var items []*models.MediaItemLibrary for rows.Next() { var lib models.MediaItemLibrary err := rows.Scan( &lib.ContentID, &lib.MediaFolderID, &lib.FirstSeenAt, ) if err != nil { return nil, fmt.Errorf("scanning media item library row: %w", err) } items = append(items, &lib) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterating media item library rows: %w", err) } return items, nil } // Upsert inserts a junction record linking a media item to a media folder. // If the record already exists (same content_id and media_folder_id), the // operation is a no-op via ON CONFLICT DO NOTHING. func (r *LibraryItemRepository) Upsert(ctx context.Context, contentID string, folderID int, firstSeenAt time.Time) error { query := ` INSERT INTO media_item_libraries (content_id, media_folder_id, first_seen_at) VALUES ($1, $2, $3) ON CONFLICT (content_id, media_folder_id) DO NOTHING` _, err := r.pool.Exec(ctx, query, contentID, folderID, firstSeenAt) if err != nil { return fmt.Errorf("upserting media item library: %w", err) } return nil } // GetByItem returns all library junction records for a given content ID. func (r *LibraryItemRepository) GetByItem(ctx context.Context, contentID string) ([]*models.MediaItemLibrary, error) { query := `SELECT ` + libraryItemColumns + ` FROM media_item_libraries WHERE content_id = $1 ORDER BY media_folder_id ASC` rows, err := r.pool.Query(ctx, query, contentID) if err != nil { return nil, fmt.Errorf("getting library items by content: %w", err) } defer rows.Close() return scanLibraryItems(rows) } // GetByFolder returns all library junction records for a given media folder ID. func (r *LibraryItemRepository) GetByFolder(ctx context.Context, folderID int) ([]*models.MediaItemLibrary, error) { query := `SELECT ` + libraryItemColumns + ` FROM media_item_libraries WHERE media_folder_id = $1 ORDER BY first_seen_at DESC` rows, err := r.pool.Query(ctx, query, folderID) if err != nil { return nil, fmt.Errorf("getting library items by folder: %w", err) } defer rows.Close() return scanLibraryItems(rows) } // GetItemsInFolder returns a membership map for the provided content IDs within // a single library folder. func (r *LibraryItemRepository) GetItemsInFolder(ctx context.Context, contentIDs []string, folderID int) (map[string]bool, error) { result := make(map[string]bool, len(contentIDs)) if len(contentIDs) == 0 { return result, nil } rows, err := r.pool.Query(ctx, `SELECT req.content_id FROM unnest($2::text[]) AS req(content_id) WHERE EXISTS ( SELECT 1 FROM media_item_libraries mil WHERE mil.media_folder_id = $1 AND mil.content_id = req.content_id ) OR EXISTS ( SELECT 1 FROM episode_libraries el WHERE el.media_folder_id = $1 AND el.episode_id = req.content_id )`, folderID, contentIDs, ) if err != nil { return nil, fmt.Errorf("getting folder membership for items: %w", err) } defer rows.Close() for rows.Next() { var contentID string if err := rows.Scan(&contentID); err != nil { return nil, fmt.Errorf("scanning folder membership row: %w", err) } result[contentID] = true } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterating folder membership rows: %w", err) } return result, nil } // GetItemsInFolders returns membership for the provided content IDs across // any of the supplied folders. Used by the user-collection sync service to // constrain imports to a chosen subset of libraries in one query rather than // looping GetItemsInFolder per library. func (r *LibraryItemRepository) GetItemsInFolders(ctx context.Context, contentIDs []string, folderIDs []int) (map[string]bool, error) { result := make(map[string]bool, len(contentIDs)) if len(contentIDs) == 0 || len(folderIDs) == 0 { return result, nil } rows, err := r.pool.Query(ctx, `SELECT req.content_id FROM unnest($2::text[]) AS req(content_id) WHERE EXISTS ( SELECT 1 FROM media_item_libraries mil WHERE mil.media_folder_id = ANY($1) AND mil.content_id = req.content_id ) OR EXISTS ( SELECT 1 FROM episode_libraries el WHERE el.media_folder_id = ANY($1) AND el.episode_id = req.content_id )`, folderIDs, contentIDs, ) if err != nil { return nil, fmt.Errorf("getting multi-folder membership for items: %w", err) } defer rows.Close() for rows.Next() { var contentID string if err := rows.Scan(&contentID); err != nil { return nil, fmt.Errorf("scanning multi-folder membership row: %w", err) } result[contentID] = true } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterating multi-folder membership rows: %w", err) } return result, nil } func (r *LibraryItemRepository) GetFolderIDsForItem(ctx context.Context, contentID string) ([]int, error) { rows, err := r.pool.Query(ctx, ` SELECT media_folder_id FROM media_item_libraries WHERE content_id = $1 ORDER BY media_folder_id ASC `, contentID) if err != nil { return nil, fmt.Errorf("getting folder IDs for item: %w", err) } defer rows.Close() var ids []int for rows.Next() { var id int if err := rows.Scan(&id); err != nil { return nil, fmt.Errorf("scanning folder ID for item: %w", err) } ids = append(ids, id) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterating folder IDs for item: %w", err) } return ids, nil } func (r *LibraryItemRepository) CountFoldersForItem(ctx context.Context, contentID string) (int, error) { var count int if err := r.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM media_item_libraries WHERE content_id = $1 `, contentID).Scan(&count); err != nil { return 0, fmt.Errorf("counting folders for item: %w", err) } return count, nil } func (r *LibraryItemRepository) GetDistinctMetadataLanguagesForItem(ctx context.Context, contentID string) ([]string, error) { rows, err := r.pool.Query(ctx, ` SELECT DISTINCT COALESCE(NULLIF(mf.metadata_language, ''), 'en') AS language FROM media_item_libraries mil JOIN media_folders mf ON mf.id = mil.media_folder_id WHERE mil.content_id = $1 ORDER BY language ASC `, contentID) if err != nil { return nil, fmt.Errorf("getting metadata languages for item: %w", err) } defer rows.Close() var languages []string for rows.Next() { var language string if err := rows.Scan(&language); err != nil { return nil, fmt.Errorf("scanning metadata language for item: %w", err) } languages = append(languages, language) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterating metadata languages for item: %w", err) } return slices.Compact(languages), nil } // Delete removes a junction record linking a media item to a media folder. func (r *LibraryItemRepository) Delete(ctx context.Context, contentID string, folderID int) error { _, err := r.pool.Exec(ctx, "DELETE FROM media_item_libraries WHERE content_id = $1 AND media_folder_id = $2", contentID, folderID, ) if err != nil { return fmt.Errorf("deleting media item library: %w", err) } return nil } // ReconcileFolderMembership removes library memberships for content that no // longer has any non-missing files in the given folder. It also deletes orphaned // media items once they no longer belong to any library. Returns removed // membership count, deleted item count, orphaned S3 image dirs, and any error. func (r *LibraryItemRepository) ReconcileFolderMembership(ctx context.Context, folderID int) (int, int, []string, error) { tx, err := r.pool.Begin(ctx) if err != nil { return 0, 0, nil, fmt.Errorf("beginning membership reconciliation transaction: %w", err) } defer func() { _ = tx.Rollback(ctx) }() rows, err := tx.Query(ctx, ` DELETE FROM media_item_libraries mil WHERE mil.media_folder_id = $1 AND NOT EXISTS ( SELECT 1 FROM media_files mf WHERE mf.media_folder_id = mil.media_folder_id AND mf.content_id = mil.content_id AND mf.missing_since IS NULL ) RETURNING mil.content_id `, folderID) if err != nil { return 0, 0, nil, fmt.Errorf("deleting stale folder memberships: %w", err) } defer rows.Close() removedContentIDs := make([]string, 0) for rows.Next() { var contentID string if err := rows.Scan(&contentID); err != nil { return 0, 0, nil, fmt.Errorf("scanning removed folder membership: %w", err) } removedContentIDs = append(removedContentIDs, contentID) } if err := rows.Err(); err != nil { return 0, 0, nil, fmt.Errorf("iterating removed folder memberships: %w", err) } rows.Close() deletedItems := 0 var orphanedImageDirs []string if len(removedContentIDs) > 0 { // Find items that will become orphaned (no remaining library memberships). orphanIDs, err := collectOrphanIDs(ctx, tx, removedContentIDs) if err != nil { return 0, 0, nil, err } // Collect image paths before deletion. if len(orphanIDs) > 0 { orphanedImageDirs, err = collectImageDirs(ctx, tx, orphanIDs) if err != nil { return 0, 0, nil, err } } tag, err := tx.Exec(ctx, ` DELETE FROM media_items mi WHERE mi.content_id = ANY($1) AND NOT EXISTS ( SELECT 1 FROM media_item_libraries mil WHERE mil.content_id = mi.content_id ) `, removedContentIDs) if err != nil { return 0, 0, nil, fmt.Errorf("deleting orphaned media items after folder reconciliation: %w", err) } deletedItems = int(tag.RowsAffected()) } if err := tx.Commit(ctx); err != nil { return 0, 0, nil, fmt.Errorf("committing membership reconciliation transaction: %w", err) } return len(removedContentIDs), deletedItems, orphanedImageDirs, nil }