feat(catalog): lookup media presence by external ids

This commit is contained in:
Silo Server Migration
2026-05-24 22:27:18 -04:00
parent 2cb7ca3918
commit 84b3318ca1
4 changed files with 202 additions and 23 deletions
+120 -23
View File
@@ -1330,6 +1330,20 @@ type MediaTMDBRow struct {
Title string
}
type ExternalIDLookupCandidate struct {
TMDBID string
TVDBID string
IMDbID string
}
type ExternalIDMatchRow struct {
QueryTMDBID string
MediaID string
MatchedProvider string
LibraryID string
Title string
}
// LookupTMDBIDs returns one row per matching media item that has its tmdb_id
// in the supplied list and is linked to at least one enabled library.
// mediaType is "movie" or "series" (silo's internal naming).
@@ -1341,38 +1355,121 @@ func (r *ItemRepository) LookupTMDBIDs(ctx context.Context, mediaType string, tm
if len(tmdbIDs) == 0 {
return nil, nil
}
// Convert string IDs to int: TMDB IDs are stored as text in media_items
// but the plugin sends them as strings. We do an ANY match directly on
// the text column so no int conversion is required.
rows, err := r.pool.Query(ctx, `
SELECT DISTINCT ON (mi.content_id)
mi.content_id,
COALESCE(mi.tmdb_id, ''),
mil.media_folder_id::text,
mi.title
FROM media_items mi
JOIN media_item_libraries mil ON mil.content_id = mi.content_id
JOIN media_folders mf ON mf.id = mil.media_folder_id
WHERE mi.tmdb_id = ANY($1)
AND mi.type = $2
AND mf.enabled = true
ORDER BY mi.content_id, mil.media_folder_id ASC
`, tmdbIDs, mediaType)
candidates := make([]ExternalIDLookupCandidate, 0, len(tmdbIDs))
for _, id := range tmdbIDs {
if strings.TrimSpace(id) != "" {
candidates = append(candidates, ExternalIDLookupCandidate{TMDBID: id})
}
}
rows, err := r.LookupExternalIDs(ctx, mediaType, candidates)
if err != nil {
return nil, fmt.Errorf("lookup tmdb ids: %w", err)
return nil, err
}
out := make([]MediaTMDBRow, 0, len(rows))
for _, row := range rows {
out = append(out, MediaTMDBRow{
MediaID: row.MediaID,
TMDBID: row.QueryTMDBID,
LibraryID: row.LibraryID,
Title: row.Title,
})
}
return out, nil
}
func lookupExternalIDsSQL() string {
return `
WITH requested(query_tmdb_id, provider, provider_id, ord) AS (
SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::int[])
),
direct_matches AS (
SELECT r.query_tmdb_id, mi.content_id, r.provider, mil.media_folder_id::text, mi.title, r.ord,
CASE r.provider WHEN 'tmdb' THEN 0 WHEN 'tvdb' THEN 1 WHEN 'imdb' THEN 2 ELSE 3 END AS provider_rank
FROM requested r
JOIN media_items mi
ON mi.type = $5
AND (
(r.provider = 'tmdb' AND COALESCE(mi.tmdb_id, '') = r.provider_id)
OR (r.provider = 'tvdb' AND COALESCE(mi.tvdb_id, '') = r.provider_id)
OR (r.provider = 'imdb' AND COALESCE(mi.imdb_id, '') = r.provider_id)
)
JOIN media_item_libraries mil ON mil.content_id = mi.content_id
JOIN media_folders mf ON mf.id = mil.media_folder_id
WHERE mf.enabled = true
),
provider_matches AS (
SELECT r.query_tmdb_id, mi.content_id, r.provider, mil.media_folder_id::text, mi.title, r.ord,
CASE r.provider WHEN 'tmdb' THEN 0 WHEN 'tvdb' THEN 1 WHEN 'imdb' THEN 2 ELSE 3 END AS provider_rank
FROM requested r
JOIN media_item_provider_ids mip
ON mip.provider = r.provider
AND mip.provider_id = r.provider_id
AND mip.item_type = $5
JOIN media_items mi ON mi.content_id = mip.content_id AND mi.type = $5
JOIN media_item_libraries mil ON mil.content_id = mi.content_id
JOIN media_folders mf ON mf.id = mil.media_folder_id
WHERE mf.enabled = true
)
SELECT DISTINCT ON (query_tmdb_id)
query_tmdb_id, content_id, provider, media_folder_id, title
FROM (
SELECT * FROM direct_matches
UNION ALL
SELECT * FROM provider_matches
) matches
ORDER BY query_tmdb_id, provider_rank ASC, ord ASC, content_id ASC, media_folder_id ASC`
}
func (r *ItemRepository) LookupExternalIDs(
ctx context.Context,
mediaType string,
candidates []ExternalIDLookupCandidate,
) ([]ExternalIDMatchRow, error) {
if len(candidates) == 0 {
return nil, nil
}
queryTMDBIDs := make([]string, 0, len(candidates)*3)
providers := make([]string, 0, len(candidates)*3)
providerIDs := make([]string, 0, len(candidates)*3)
ordinals := make([]int32, 0, len(candidates)*3)
appendID := func(candidate ExternalIDLookupCandidate, provider, providerID string, ordinal int) {
providerID = strings.TrimSpace(providerID)
if providerID == "" {
return
}
queryTMDBIDs = append(queryTMDBIDs, strings.TrimSpace(candidate.TMDBID))
providers = append(providers, provider)
providerIDs = append(providerIDs, providerID)
ordinals = append(ordinals, int32(ordinal))
}
for i, candidate := range candidates {
appendID(candidate, "tmdb", candidate.TMDBID, i)
appendID(candidate, "tvdb", candidate.TVDBID, i)
appendID(candidate, "imdb", candidate.IMDbID, i)
}
if len(providerIDs) == 0 {
return nil, nil
}
rows, err := r.pool.Query(ctx, lookupExternalIDsSQL(), queryTMDBIDs, providers, providerIDs, ordinals, mediaType)
if err != nil {
return nil, fmt.Errorf("lookup external ids: %w", err)
}
defer rows.Close()
var out []MediaTMDBRow
out := make([]ExternalIDMatchRow, 0)
for rows.Next() {
var row MediaTMDBRow
if err := rows.Scan(&row.MediaID, &row.TMDBID, &row.LibraryID, &row.Title); err != nil {
return nil, fmt.Errorf("scanning tmdb lookup row: %w", err)
var row ExternalIDMatchRow
if err := rows.Scan(&row.QueryTMDBID, &row.MediaID, &row.MatchedProvider, &row.LibraryID, &row.Title); err != nil {
return nil, fmt.Errorf("scanning external id lookup row: %w", err)
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating tmdb lookup rows: %w", err)
return nil, fmt.Errorf("iterating external id lookup rows: %w", err)
}
return out, nil
}
+20
View File
@@ -191,6 +191,26 @@ func TestItemRepo_GetByExternalIDs_NilSliceStillBindsArg(t *testing.T) {
_ = args
}
func TestLookupExternalIDsSQLChecksProviderTableAndDirectColumns(t *testing.T) {
sql := lookupExternalIDsSQL()
for _, want := range []string{
"FROM requested r",
"JOIN media_item_provider_ids mip",
"mip.provider = r.provider",
"mip.provider_id = r.provider_id",
"COALESCE(mi.tmdb_id, '') = r.provider_id",
"COALESCE(mi.tvdb_id, '') = r.provider_id",
"COALESCE(mi.imdb_id, '') = r.provider_id",
"JOIN media_folders mf ON mf.id = mil.media_folder_id",
"mf.enabled = true",
} {
if !strings.Contains(sql, want) {
t.Fatalf("lookupExternalIDsSQL missing %q:\n%s", want, sql)
}
}
}
// TestItemRepo_Search_UsesWindowCount asserts that buildSearchSQL emits a
// single-pass paged SELECT that includes COUNT(*) OVER () so Search no longer
// needs a separate count query before the data fetch (audit 2026-05-01 §3.11).
+48
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
@@ -30,6 +31,53 @@ 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)
if _, err := tx.Exec(ctx, `
UPDATE media_items
SET tmdb_id = COALESCE(NULLIF(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{}{
+14
View File
@@ -40,3 +40,17 @@ func TestNormalizeDurableProviderIDsOrdersCanonicalKeysFirst(t *testing.T) {
}
}
}
func TestNormalizeDurableProviderIDsKeepsTMDBFirstForBackfill(t *testing.T) {
entries := normalizeDurableProviderIDs(map[string]string{
"tvdb": "420105",
"imdb": "tt18076310",
"tmdb": "201992",
})
if len(entries) != 3 {
t.Fatalf("len(entries) = %d, want 3", len(entries))
}
if entries[0].Provider != "tmdb" || entries[0].ProviderID != "201992" {
t.Fatalf("first entry = (%q, %q), want tmdb/201992", entries[0].Provider, entries[0].ProviderID)
}
}