361 lines
11 KiB
Go
361 lines
11 KiB
Go
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
|
|
}
|