367 lines
10 KiB
Go
367 lines
10 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
var (
|
|
// ErrMediaItemProviderIDNotFound is returned when no provider IDs exist for
|
|
// a given content item.
|
|
ErrMediaItemProviderIDNotFound = errors.New("media item provider ids not found")
|
|
)
|
|
|
|
// ProviderIDRepository persists durable provider IDs for media items.
|
|
type ProviderIDRepository struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
// NewProviderIDRepository creates a new provider ID repository backed by the
|
|
// given pool.
|
|
func NewProviderIDRepository(pool *pgxpool.Pool) *ProviderIDRepository {
|
|
return &ProviderIDRepository{pool: pool}
|
|
}
|
|
|
|
func (r *ProviderIDRepository) AttachTMDBID(ctx context.Context, contentID, itemType string, tmdbID int) error {
|
|
contentID = strings.TrimSpace(contentID)
|
|
itemType = strings.TrimSpace(itemType)
|
|
if contentID == "" {
|
|
return fmt.Errorf("content_id is required")
|
|
}
|
|
if itemType == "" {
|
|
return fmt.Errorf("item_type is required")
|
|
}
|
|
if tmdbID <= 0 {
|
|
return fmt.Errorf("tmdb_id must be positive")
|
|
}
|
|
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("begin attach tmdb transaction: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx) //nolint:errcheck
|
|
|
|
tmdbText := strconv.Itoa(tmdbID)
|
|
var existingType, existingTMDBID string
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT type, COALESCE(tmdb_id, '')
|
|
FROM media_items
|
|
WHERE content_id = $1
|
|
FOR UPDATE
|
|
`, contentID).Scan(&existingType, &existingTMDBID); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return fmt.Errorf("loading media item for tmdb attach: content not found")
|
|
}
|
|
return fmt.Errorf("loading media item for tmdb attach: %w", err)
|
|
}
|
|
if existingType != itemType {
|
|
return fmt.Errorf("media item type mismatch: got %q, want %q", existingType, itemType)
|
|
}
|
|
if existingTMDBID != "" && existingTMDBID != tmdbText {
|
|
return fmt.Errorf("media item tmdb id conflict: got %q, want %q", existingTMDBID, tmdbText)
|
|
}
|
|
|
|
var existingProviderTMDBID string
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT provider_id
|
|
FROM media_item_provider_ids
|
|
WHERE content_id = $1 AND provider = 'tmdb'
|
|
FOR UPDATE
|
|
`, contentID).Scan(&existingProviderTMDBID)
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return fmt.Errorf("loading media item tmdb provider id: %w", err)
|
|
}
|
|
if existingProviderTMDBID != "" && existingProviderTMDBID != tmdbText {
|
|
return fmt.Errorf("media item tmdb provider id conflict: got %q, want %q", existingProviderTMDBID, tmdbText)
|
|
}
|
|
|
|
var existingOwnerContentID string
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT content_id
|
|
FROM media_items
|
|
WHERE type = $1
|
|
AND tmdb_id = $2
|
|
AND content_id <> $3
|
|
LIMIT 1
|
|
`, itemType, tmdbText, contentID).Scan(&existingOwnerContentID)
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return fmt.Errorf("checking tmdb id owner: %w", err)
|
|
}
|
|
if existingOwnerContentID != "" {
|
|
return fmt.Errorf("tmdb id %q already belongs to content_id %q", tmdbText, existingOwnerContentID)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE media_items
|
|
SET tmdb_id = $1,
|
|
updated_at = NOW()
|
|
WHERE content_id = $2
|
|
AND type = $3
|
|
`, tmdbText, contentID, itemType); err != nil {
|
|
return fmt.Errorf("updating media item tmdb id: %w", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO media_item_provider_ids (content_id, item_type, provider, provider_id, created_at, updated_at)
|
|
VALUES ($1, $2, 'tmdb', $3, NOW(), NOW())
|
|
ON CONFLICT (content_id, provider) DO UPDATE
|
|
SET item_type = EXCLUDED.item_type,
|
|
provider_id = EXCLUDED.provider_id,
|
|
updated_at = NOW()
|
|
`, contentID, itemType, tmdbText); err != nil {
|
|
return fmt.Errorf("upserting media item tmdb provider id: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("commit attach tmdb transaction: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const providerIDColumns = `content_id, item_type, provider, provider_id, created_at, updated_at`
|
|
|
|
var excludedProviderIDs = map[string]struct{}{
|
|
"metadb": {},
|
|
"_filepath": {},
|
|
"oshash": {},
|
|
}
|
|
|
|
var preferredProviderIDOrder = map[string]int{
|
|
"tmdb": 0,
|
|
"tvdb": 1,
|
|
"imdb": 2,
|
|
}
|
|
|
|
func normalizeDurableProviderIDs(providerIDs map[string]string) []models.MediaItemProviderID {
|
|
if len(providerIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
entries := make([]models.MediaItemProviderID, 0, len(providerIDs))
|
|
for provider, providerID := range providerIDs {
|
|
provider = strings.TrimSpace(provider)
|
|
providerID = strings.TrimSpace(providerID)
|
|
if provider == "" || providerID == "" {
|
|
continue
|
|
}
|
|
if _, excluded := excludedProviderIDs[strings.ToLower(provider)]; excluded {
|
|
continue
|
|
}
|
|
entries = append(entries, models.MediaItemProviderID{
|
|
Provider: provider,
|
|
ProviderID: providerID,
|
|
})
|
|
}
|
|
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
leftRank, leftOK := preferredProviderIDOrder[strings.ToLower(entries[i].Provider)]
|
|
rightRank, rightOK := preferredProviderIDOrder[strings.ToLower(entries[j].Provider)]
|
|
switch {
|
|
case leftOK && rightOK && leftRank != rightRank:
|
|
return leftRank < rightRank
|
|
case leftOK != rightOK:
|
|
return leftOK
|
|
case strings.ToLower(entries[i].Provider) != strings.ToLower(entries[j].Provider):
|
|
return strings.ToLower(entries[i].Provider) < strings.ToLower(entries[j].Provider)
|
|
default:
|
|
return entries[i].ProviderID < entries[j].ProviderID
|
|
}
|
|
})
|
|
|
|
return entries
|
|
}
|
|
|
|
func scanProviderID(row pgx.Row) (*models.MediaItemProviderID, error) {
|
|
var id models.MediaItemProviderID
|
|
if err := row.Scan(
|
|
&id.ContentID,
|
|
&id.ItemType,
|
|
&id.Provider,
|
|
&id.ProviderID,
|
|
&id.CreatedAt,
|
|
&id.UpdatedAt,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scanning media item provider id: %w", err)
|
|
}
|
|
return &id, nil
|
|
}
|
|
|
|
func scanProviderIDs(rows pgx.Rows) ([]*models.MediaItemProviderID, error) {
|
|
var ids []*models.MediaItemProviderID
|
|
for rows.Next() {
|
|
var id models.MediaItemProviderID
|
|
if err := rows.Scan(
|
|
&id.ContentID,
|
|
&id.ItemType,
|
|
&id.Provider,
|
|
&id.ProviderID,
|
|
&id.CreatedAt,
|
|
&id.UpdatedAt,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scanning media item provider id row: %w", err)
|
|
}
|
|
ids = append(ids, &id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating media item provider id rows: %w", err)
|
|
}
|
|
if ids == nil {
|
|
ids = []*models.MediaItemProviderID{}
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
// GetByContentID loads all durable provider IDs for the given media item.
|
|
func (r *ProviderIDRepository) GetByContentID(ctx context.Context, contentID string) ([]*models.MediaItemProviderID, error) {
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT `+providerIDColumns+`
|
|
FROM media_item_provider_ids
|
|
WHERE content_id = $1
|
|
ORDER BY
|
|
CASE LOWER(provider)
|
|
WHEN 'tmdb' THEN 0
|
|
WHEN 'tvdb' THEN 1
|
|
WHEN 'imdb' THEN 2
|
|
ELSE 3
|
|
END,
|
|
LOWER(provider) ASC,
|
|
provider_id ASC
|
|
`, contentID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting provider IDs by content_id: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return scanProviderIDs(rows)
|
|
}
|
|
|
|
// ReplaceByContentID replaces all durable provider IDs for a content item.
|
|
func (r *ProviderIDRepository) ReplaceByContentID(ctx context.Context, contentID string, providerIDs map[string]string) error {
|
|
if strings.TrimSpace(contentID) == "" {
|
|
return fmt.Errorf("content_id is required")
|
|
}
|
|
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("begin provider id replace transaction: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx) //nolint:errcheck
|
|
|
|
var itemType string
|
|
if err := tx.QueryRow(ctx, `SELECT type FROM media_items WHERE content_id = $1`, contentID).Scan(&itemType); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return fmt.Errorf("loading item type for %s: content not found", contentID)
|
|
}
|
|
return fmt.Errorf("loading item type for %s: %w", contentID, err)
|
|
}
|
|
|
|
if err := r.ReplaceByContentIDTx(ctx, tx, contentID, itemType, providerIDs); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("commit provider id replace transaction: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReplaceByContentIDTx replaces durable provider IDs using the caller's transaction.
|
|
func (r *ProviderIDRepository) ReplaceByContentIDTx(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
contentID string,
|
|
itemType string,
|
|
providerIDs map[string]string,
|
|
) error {
|
|
if strings.TrimSpace(contentID) == "" {
|
|
return fmt.Errorf("content_id is required")
|
|
}
|
|
itemType = strings.TrimSpace(itemType)
|
|
if itemType == "" {
|
|
return fmt.Errorf("item_type is required")
|
|
}
|
|
entries := normalizeDurableProviderIDs(providerIDs)
|
|
|
|
if _, err := tx.Exec(ctx, `DELETE FROM media_item_provider_ids WHERE content_id = $1`, contentID); err != nil {
|
|
return fmt.Errorf("deleting provider IDs for %s: %w", contentID, err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO media_item_provider_ids (content_id, item_type, provider, provider_id, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
|
`, contentID, itemType, entry.Provider, entry.ProviderID)
|
|
if err != nil {
|
|
return fmt.Errorf("inserting provider ID %s for %s: %w", entry.Provider, contentID, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FindContentIDByProviderIDs looks up the first item matching any durable
|
|
// provider ID in priority order. Empty provider maps return no match.
|
|
func (r *ProviderIDRepository) FindContentIDByProviderIDs(
|
|
ctx context.Context,
|
|
providerIDs map[string]string,
|
|
itemType string,
|
|
excludeContentID string,
|
|
) (string, error) {
|
|
entries := normalizeDurableProviderIDs(providerIDs)
|
|
if len(entries) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
providers := make([]string, len(entries))
|
|
values := make([]string, len(entries))
|
|
ordinals := make([]int32, len(entries))
|
|
for i, entry := range entries {
|
|
providers[i] = entry.Provider
|
|
values[i] = entry.ProviderID
|
|
ordinals[i] = int32(i)
|
|
}
|
|
|
|
query := `
|
|
WITH requested(provider, provider_id, ord) AS (
|
|
SELECT * FROM unnest($1::text[], $2::text[], $3::int[])
|
|
)
|
|
SELECT mi.content_id
|
|
FROM requested r
|
|
JOIN media_item_provider_ids mip
|
|
ON mip.provider = r.provider
|
|
AND mip.provider_id = r.provider_id
|
|
JOIN media_items mi
|
|
ON mi.content_id = mip.content_id
|
|
WHERE ($4 = '' OR mip.item_type = $4)
|
|
AND ($5 = '' OR mi.content_id <> $5)
|
|
ORDER BY
|
|
r.ord ASC,
|
|
CASE lower(trim(mi.status))
|
|
WHEN 'matched' THEN 0
|
|
WHEN 'pending' THEN 1
|
|
WHEN 'unmatched' THEN 2
|
|
ELSE 3
|
|
END,
|
|
mi.updated_at DESC,
|
|
mi.content_id ASC
|
|
LIMIT 1`
|
|
|
|
var contentID string
|
|
err := r.pool.QueryRow(ctx, query, providers, values, ordinals, strings.TrimSpace(itemType), strings.TrimSpace(excludeContentID)).Scan(&contentID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", nil
|
|
}
|
|
return "", fmt.Errorf("finding content_id by provider IDs: %w", err)
|
|
}
|
|
|
|
return contentID, nil
|
|
}
|