Files
silo-server/internal/catalog/library_collection_service.go
T
Silo Server Migration dce8978cdf Track template poster origin in collection sync
- 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
2026-05-23 14:19:04 -04:00

1290 lines
41 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package catalog
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/collage"
"github.com/Silo-Server/silo-server/internal/collectionutil"
"github.com/Silo-Server/silo-server/internal/models"
)
// TMDBCollectionEntry is a lightweight TMDB preset result used by the collection sync.
type TMDBCollectionEntry struct {
ID int
MediaType string
Title string
IMDbID string
TVDBID int
}
// TraktCollectionEntry is a lightweight Trakt discovery result used by collection sync.
type TraktCollectionEntry struct {
TraktID int
TMDBID int
TVDBID int
IMDbID string
MediaType string
Title string
Year int
Rank int
}
// TMDBCollectionFetcher abstracts the TMDB preset API so the catalog package
// does not import the tmdb package directly.
type TMDBCollectionFetcher interface {
GetCollectionPreset(ctx context.Context, preset, mediaType, timeWindow string, limit int) ([]TMDBCollectionEntry, error)
}
// TMDBCollectionByIDFetcher abstracts TMDB's /collection/{id} franchise/saga
// endpoint. It returns the curated, ordered list of parts in a TMDB
// collection (e.g. all MCU films in MCU collection 86311), already enriched
// with external IDs so the matcher can fall back to IMDb / TVDB when the
// local catalog doesn't carry a TMDB ID.
//
// Implementations are expected to deal with pagination internally — TMDB
// collection responses are single-page, so this is currently trivial.
type TMDBCollectionByIDFetcher interface {
GetCollection(ctx context.Context, id int) ([]TMDBCollectionEntry, error)
}
// TMDBDiscoverParams mirrors templates.TMDBDiscoverSpec so the catalog package
// can hand discover params to the fetcher without importing the tmdb package
// directly.
type TMDBDiscoverParams struct {
WithGenres []int
WithoutGenres []int
SortBy string
VoteCountGte int
VoteAverageGte float64
ReleaseDateGte string
ReleaseDateLte string
Certifications []string
CertificationLte string
WithRuntimeGte int
WithRuntimeLte int
OriginalLanguage string
}
// TMDBDiscoverFetcher abstracts TMDB's `/discover/{movie,tv}` endpoint so the
// catalog package does not import the tmdb package directly.
type TMDBDiscoverFetcher interface {
Discover(ctx context.Context, mediaType string, params TMDBDiscoverParams, limit int) ([]TMDBCollectionEntry, error)
}
// TraktCollectionFetcher abstracts the Trakt discovery API.
type TraktCollectionFetcher interface {
GetCollectionPreset(ctx context.Context, preset, mediaType string, limit int, accessToken string) ([]TraktCollectionEntry, error)
}
// TraktAccessTokenResolver returns a profile-scoped Trakt access token for
// personalized recommendation collection sync.
type TraktAccessTokenResolver interface {
ResolveTraktAccessToken(ctx context.Context, profileID string) (string, error)
}
// CollageGenerator generates a poster collage for a collection.
// It receives the collection ID, resolves item poster images, composes them,
// and stores the result. Returns the S3 path and thumbhash of the generated poster.
type CollageGenerator interface {
GenerateCollectionPoster(ctx context.Context, collectionID string) error
}
var ErrLibraryCollectionSyncUnsupported = errors.New("smart collections cannot be synchronized")
type LibraryCollectionService struct {
collections *LibraryCollectionRepository
items *ItemRepository
libraryItems *LibraryItemRepository
httpClient *http.Client
// TMDBCollections is nil when TMDB is not configured.
TMDBCollections TMDBCollectionFetcher
// TMDBFranchises is nil when TMDB is not configured. It serves the
// `tmdb_collection` source mode (curated franchises / sagas).
TMDBFranchises TMDBCollectionByIDFetcher
// TMDBDiscovers is nil when TMDB is not configured. It serves the
// `tmdb_discover` source mode (genre matrices, decade filters, etc.).
TMDBDiscovers TMDBDiscoverFetcher
// TraktCollections is nil when Trakt collection discovery is not configured.
TraktCollections TraktCollectionFetcher
// TraktTokenResolver is required for Trakt recommended collections.
TraktTokenResolver TraktAccessTokenResolver
// CollageGen is nil when S3/image processing is not configured.
CollageGen CollageGenerator
}
func NewLibraryCollectionService(
collections *LibraryCollectionRepository,
items *ItemRepository,
libraryItems *LibraryItemRepository,
httpClient *http.Client,
) *LibraryCollectionService {
if httpClient == nil {
httpClient = http.DefaultClient
}
return &LibraryCollectionService{
collections: collections,
items: items,
libraryItems: libraryItems,
httpClient: httpClient,
}
}
// CollectionBuilders holds per-builder item limits for synced collections.
type CollectionBuilders struct {
MDBList int `json:"mdblist,omitempty"`
}
type SyncCollectionOptions struct {
SkipCollage bool
}
type libraryCollectionSourceConfig struct {
Mode string `json:"mode"`
Provider string `json:"provider,omitempty"`
Preset string `json:"preset,omitempty"`
URL string `json:"url,omitempty"`
MediaType string `json:"media_type,omitempty"`
TimeWindow string `json:"time_window,omitempty"`
ProfileID string `json:"profile_id,omitempty"`
Limit *int `json:"limit,omitempty"`
Builders *CollectionBuilders `json:"builders,omitempty"`
// CollectionID is the TMDB collection ID for the `tmdb_collection` mode.
// Stored as a plain int (not *int) so zero round-trips as "unset" via the
// omitempty tag — the sync path treats 0 as a placeholder sentinel.
CollectionID int `json:"collection_id,omitempty"`
// Discover holds the TMDB /discover parameters for the `tmdb_discover`
// mode. The pointer lets the JSON omit the object entirely for non-
// discover modes so existing tmdb_preset / trakt_preset / mdblist_json
// configs round-trip unchanged.
Discover *libraryCollectionDiscoverConfig `json:"discover,omitempty"`
}
// libraryCollectionDiscoverConfig persists TMDB /discover filters in the
// collection's source_config JSON. Field names follow the TMDBDiscoverSpec
// JSON contract so the on-disk shape matches the template spec.
type libraryCollectionDiscoverConfig struct {
WithGenres []int `json:"with_genres,omitempty"`
WithoutGenres []int `json:"without_genres,omitempty"`
SortBy string `json:"sort_by"`
VoteCountGte int `json:"vote_count_gte,omitempty"`
VoteAverageGte float64 `json:"vote_average_gte,omitempty"`
ReleaseDateGte string `json:"release_date_gte,omitempty"`
ReleaseDateLte string `json:"release_date_lte,omitempty"`
Certifications []string `json:"certifications,omitempty"`
CertificationLte string `json:"certification_lte,omitempty"`
WithRuntimeGte int `json:"with_runtime_gte,omitempty"`
WithRuntimeLte int `json:"with_runtime_lte,omitempty"`
OriginalLanguage string `json:"original_language,omitempty"`
}
type mdblistEntry struct {
ID int `json:"id"`
Rank int `json:"rank"`
TVDBID *int `json:"tvdbid"`
IMDbID string `json:"imdb_id"`
MediaType string `json:"mediatype"`
Title string `json:"title"`
ReleaseYear int `json:"release_year"`
}
func (s *LibraryCollectionService) SyncCollection(ctx context.Context, collectionID string) (*models.LibraryCollectionSyncRun, error) {
return s.SyncCollectionWithOptions(ctx, collectionID, SyncCollectionOptions{})
}
func (s *LibraryCollectionService) SyncCollectionWithOptions(ctx context.Context, collectionID string, opts SyncCollectionOptions) (*models.LibraryCollectionSyncRun, error) {
collection, err := s.collections.GetByID(ctx, collectionID)
if err != nil {
return nil, err
}
if IsLiveQueryType(collection.CollectionType) {
return nil, ErrLibraryCollectionSyncUnsupported
}
var source libraryCollectionSourceConfig
if err := json.Unmarshal(collection.SourceConfig, &source); err != nil {
return nil, fmt.Errorf("parsing collection source config: %w", err)
}
switch source.Mode {
case "smart":
return nil, ErrLibraryCollectionSyncUnsupported
case "mdblist_json":
return s.syncMDBListCollection(ctx, collection, source.URL, source.Limit, opts)
case "tmdb_preset":
return s.syncTMDBPresetCollection(ctx, collection, source, opts)
case "tmdb_collection":
return s.syncTMDBFranchiseCollection(ctx, collection, source, opts)
case "tmdb_discover":
return s.syncTMDBDiscoverCollection(ctx, collection, source, opts)
case "trakt_preset":
return s.syncTraktPresetCollection(ctx, collection, source, opts)
default:
return nil, fmt.Errorf("unsupported collection sync mode: %s", source.Mode)
}
}
func (s *LibraryCollectionService) syncMDBListCollection(ctx context.Context, collection *models.LibraryCollection, listURL string, limit *int, opts SyncCollectionOptions) (*models.LibraryCollectionSyncRun, error) {
startedAt := syncTimestamp()
entries, err := s.fetchMDBListEntries(ctx, listURL)
if err != nil {
return nil, err
}
// Trim the entry list to the same fetch-multiplier bound used for TMDB and
// Trakt sources before building the external-ID batches. MDBList lists can
// return hundreds of entries; without this, the two GetByExternalIDs IN
// arrays balloon to the full list size even when the user's limit is small.
if fetchLimit := collectionutil.SourceFetchLimit(limit); fetchLimit > 0 && len(entries) > fetchLimit {
entries = entries[:fetchLimit]
}
// Pre-fetch all external-ID lookups grouped by item type (movie vs series)
// in two batched queries instead of up to 3×N GetByExternalID calls
// (audit 2026-05-01 §3.7).
var movieBatch, seriesBatch ExternalIDBatch
for _, entry := range entries {
itemType := mdbListEntryItemType(entry)
if entry.ID > 0 {
idStr := fmt.Sprintf("%d", entry.ID)
if itemType == "movie" {
movieBatch.TMDBIDs = append(movieBatch.TMDBIDs, idStr)
} else {
seriesBatch.TMDBIDs = append(seriesBatch.TMDBIDs, idStr)
}
}
if entry.IMDbID != "" {
if itemType == "movie" {
movieBatch.IMDbIDs = append(movieBatch.IMDbIDs, entry.IMDbID)
} else {
seriesBatch.IMDbIDs = append(seriesBatch.IMDbIDs, entry.IMDbID)
}
}
if entry.TVDBID != nil && *entry.TVDBID > 0 && itemType != "movie" {
seriesBatch.TVDBIDs = append(seriesBatch.TVDBIDs, fmt.Sprintf("%d", *entry.TVDBID))
}
}
movieLookup, err := s.items.GetByExternalIDs(ctx, movieBatch, "movie")
if err != nil {
return nil, err
}
seriesLookup, err := s.items.GetByExternalIDs(ctx, seriesBatch, "series")
if err != nil {
return nil, err
}
// First pass: collect ALL candidate content_ids per entry in priority
// order. The legacy resolveMDBListEntry walked every external-ID hit and
// returned the first library-resident match, so we must keep the full
// candidate list (not just the highest-priority hit) for the membership
// check below.
type resolvedEntry struct {
entryIndex int
candidates []string
sourceRank int
}
resolved := make([]resolvedEntry, 0, len(entries))
candidateIDs := make([]string, 0, len(entries))
candidateSet := make(map[string]struct{}, len(entries))
matchedItems := make([]LibraryCollectionItemInput, 0, len(entries))
warnings := make([]string, 0)
for index, entry := range entries {
itemType := mdbListEntryItemType(entry)
var lookup *ExternalIDLookup
if itemType == "movie" {
lookup = movieLookup
} else {
lookup = seriesLookup
}
candidates := pickCandidatesByPriority(lookup, entry, itemType)
if len(candidates) == 0 {
continue
}
sourceRank := entry.Rank
if sourceRank <= 0 {
sourceRank = index + 1
}
resolved = append(resolved, resolvedEntry{entryIndex: index, candidates: candidates, sourceRank: sourceRank})
for _, candidate := range candidates {
if _, exists := candidateSet[candidate]; !exists {
candidateSet[candidate] = struct{}{}
candidateIDs = append(candidateIDs, candidate)
}
}
}
// Single batched library membership query covering EVERY candidate across
// all entries (preserves the libraryID filter from the legacy
// resolveMDBListEntry).
libraryMembers, err := s.libraryItems.GetItemsInFolder(ctx, candidateIDs, collection.LibraryID)
if err != nil {
return nil, err
}
resolvedByIndex := make(map[int]resolvedEntry, len(resolved))
for _, r := range resolved {
resolvedByIndex[r.entryIndex] = r
}
scannedEntries := 0
limitReached := false
for index, entry := range entries {
scannedEntries = index + 1
r, ok := resolvedByIndex[index]
if !ok {
warnings = append(warnings, fmt.Sprintf("No match in library %d for %s", collection.LibraryID, entry.Title))
continue
}
var chosen string
for _, candidate := range r.candidates {
if libraryMembers[candidate] {
chosen = candidate
break
}
}
if chosen == "" {
warnings = append(warnings, fmt.Sprintf("No match in library %d for %s", collection.LibraryID, entry.Title))
continue
}
matchedItems = append(matchedItems, LibraryCollectionItemInput{
MediaItemID: chosen,
Position: len(matchedItems),
SourceRank: r.sourceRank,
})
if collectionutil.ItemLimitReached(len(matchedItems), limit) {
limitReached = true
break
}
}
if err := s.collections.ReplaceItems(ctx, collection.ID, matchedItems); err != nil {
return nil, err
}
status := "success"
if len(warnings) > 0 {
status = "warning"
}
warningsJSON, err := json.Marshal(warnings)
if err != nil {
return nil, fmt.Errorf("marshaling sync warnings: %w", err)
}
// Report the full source size as the denominator so operators can tell
// "limit reached" apart from "perfect coverage of a small source"; the
// extra clause exposes how many entries were actually scanned before the
// break.
message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), len(entries))
if limitReached {
message = fmt.Sprintf("%s (item limit reached after %d scanned)", message, scannedEntries)
}
completedAt := syncTimestamp()
run, err := s.collections.RecordSyncRun(ctx, RecordLibraryCollectionSyncRunInput{
CollectionID: collection.ID,
Status: status,
Message: message,
ItemsAdded: len(matchedItems),
ItemsRemoved: 0,
ItemsMatched: len(matchedItems),
ItemsUnmatched: scannedEntries - len(matchedItems),
Warnings: warningsJSON,
StartedAt: startedAt,
CompletedAt: completedAt,
})
if err != nil {
return nil, err
}
if !opts.SkipCollage {
s.maybeGenerateCollage(ctx, collection.ID)
}
return run, nil
}
func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context, collection *models.LibraryCollection, cfg libraryCollectionSourceConfig, opts SyncCollectionOptions) (*models.LibraryCollectionSyncRun, error) {
startedAt := syncTimestamp()
if s.TMDBCollections == nil {
return nil, fmt.Errorf("TMDB preset sync requires configured TMDB access")
}
preset := cfg.Preset
mediaType := cfg.MediaType
timeWindow := cfg.TimeWindow
if timeWindow == "" && preset == "trending" {
timeWindow = "day"
}
if mediaType == "" {
switch preset {
case "trending":
mediaType = "all"
case "popular", "top_rated", "now_playing", "upcoming":
mediaType = "movie"
case "airing_today", "on_the_air":
mediaType = "tv"
}
}
fetchLimit := collectionutil.SourceFetchLimit(cfg.Limit)
results, err := s.TMDBCollections.GetCollectionPreset(ctx, preset, mediaType, timeWindow, fetchLimit)
if err != nil {
return nil, fmt.Errorf("fetching TMDB preset: %w", err)
}
slog.Info("TMDB preset sync: fetched results",
"collection_id", collection.ID,
"preset", preset,
"media_type", mediaType,
"time_window", timeWindow,
"count", len(results),
)
matchedItems := make([]LibraryCollectionItemInput, 0, len(results))
seenContentIDs := make(map[string]int, len(results))
warnings := make([]string, 0)
unmatchedCount := 0
duplicateCount := 0
scannedEntries := 0
limitReached := false
for i, entry := range results {
scannedEntries = i + 1
item, err := s.resolveTMDBEntry(ctx, collection.LibraryID, entry)
if err != nil {
return nil, err
}
if item == nil {
slog.Debug("TMDB preset sync: no match",
"rank", i+1,
"title", entry.Title,
"type", entry.MediaType,
"tmdb_id", entry.ID,
"imdb_id", entry.IMDbID,
"tvdb_id", entry.TVDBID,
)
unmatchedCount++
warnings = append(warnings, fmt.Sprintf("No match in library %d for %s", collection.LibraryID, entry.Title))
continue
}
if firstRank, exists := seenContentIDs[item.ContentID]; exists {
slog.Debug("TMDB preset sync: duplicate match skipped",
"rank", i+1,
"title", entry.Title,
"type", entry.MediaType,
"tmdb_id", entry.ID,
"imdb_id", entry.IMDbID,
"tvdb_id", entry.TVDBID,
"content_id", item.ContentID,
"first_rank", firstRank,
)
duplicateCount++
warnings = append(warnings, fmt.Sprintf("Duplicate TMDB entry for %s matched existing item %s", entry.Title, item.ContentID))
continue
}
seenContentIDs[item.ContentID] = i + 1
slog.Debug("TMDB preset sync: matched",
"rank", i+1,
"title", entry.Title,
"type", entry.MediaType,
"tmdb_id", entry.ID,
"imdb_id", entry.IMDbID,
"tvdb_id", entry.TVDBID,
"content_id", item.ContentID,
)
matchedItems = append(matchedItems, LibraryCollectionItemInput{
MediaItemID: item.ContentID,
Position: len(matchedItems),
SourceRank: i + 1,
})
if collectionutil.ItemLimitReached(len(matchedItems), cfg.Limit) {
limitReached = true
break
}
}
slog.Info("TMDB preset sync: complete",
"collection_id", collection.ID,
"preset", preset,
"matched", len(matchedItems),
"unmatched", unmatchedCount,
"duplicates", duplicateCount,
"scanned", scannedEntries,
"total", len(results),
)
if err := s.collections.ReplaceItems(ctx, collection.ID, matchedItems); err != nil {
return nil, err
}
status := "success"
if len(warnings) > 0 {
status = "warning"
}
message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), len(results))
if limitReached {
message = fmt.Sprintf("%s (item limit reached after %d scanned)", message, scannedEntries)
}
if duplicateCount > 0 {
message = fmt.Sprintf("%s (%d duplicates skipped)", message, duplicateCount)
}
warningsJSON, err := json.Marshal(warnings)
if err != nil {
return nil, fmt.Errorf("marshaling sync warnings: %w", err)
}
completedAt := syncTimestamp()
run, err := s.collections.RecordSyncRun(ctx, RecordLibraryCollectionSyncRunInput{
CollectionID: collection.ID,
Status: status,
Message: message,
ItemsAdded: len(matchedItems),
ItemsRemoved: 0,
ItemsMatched: len(matchedItems),
ItemsUnmatched: unmatchedCount,
Warnings: warningsJSON,
StartedAt: startedAt,
CompletedAt: completedAt,
})
if err != nil {
return nil, err
}
if !opts.SkipCollage {
s.maybeGenerateCollage(ctx, collection.ID)
}
return run, nil
}
// syncTMDBFranchiseCollection populates a collection from TMDB's curated
// /collection/{id} franchise/saga endpoint. Items are written in TMDB's
// returned order (typically chronological by release date) without any
// re-sorting; the matcher resolves each part against the local library via
// TMDB / IMDb / TVDB IDs.
//
// A configured CollectionID of 0 is treated as a placeholder sentinel — the
// generic "TMDB Franchise" catalog template ships with collection_id=0 so
// admins can edit it after apply. Sync records a failed run with a clear
// message rather than fetching collection 0 (which TMDB does not have).
// validateTMDBFranchiseConfig returns a non-empty admin-facing failure message
// when the collection_id field is unsuitable for sync (0 = placeholder, < 0 =
// programmer error). Extracted as a pure helper so the message format can be
// unit-tested without spinning up the full sync stack.
func validateTMDBFranchiseConfig(collectionID int) string {
switch {
case collectionID == 0:
return "TMDB franchise template requires a collection_id — edit the collection's source config and supply a real TMDB collection ID"
case collectionID < 0:
return fmt.Sprintf("TMDB collection_id must be > 0 (got %d)", collectionID)
default:
return ""
}
}
func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Context, collection *models.LibraryCollection, cfg libraryCollectionSourceConfig, opts SyncCollectionOptions) (*models.LibraryCollectionSyncRun, error) {
startedAt := syncTimestamp()
if reason := validateTMDBFranchiseConfig(cfg.CollectionID); reason != "" {
return s.recordFailedCollectionSync(ctx, collection.ID, startedAt, reason)
}
if s.TMDBFranchises == nil {
return nil, fmt.Errorf("TMDB franchise sync requires configured TMDB access")
}
results, err := s.TMDBFranchises.GetCollection(ctx, cfg.CollectionID)
if err != nil {
return nil, fmt.Errorf("fetching TMDB collection: %w", err)
}
slog.Info("TMDB franchise sync: fetched results",
"collection_id", collection.ID,
"tmdb_collection_id", cfg.CollectionID,
"count", len(results),
)
matchedItems := make([]LibraryCollectionItemInput, 0, len(results))
seenContentIDs := make(map[string]int, len(results))
warnings := make([]string, 0)
unmatchedCount := 0
duplicateCount := 0
scannedEntries := 0
limitReached := false
for i, entry := range results {
scannedEntries = i + 1
item, err := s.resolveTMDBEntry(ctx, collection.LibraryID, entry)
if err != nil {
return nil, err
}
if item == nil {
slog.Debug("TMDB franchise sync: no match",
"rank", i+1,
"title", entry.Title,
"tmdb_id", entry.ID,
"imdb_id", entry.IMDbID,
)
unmatchedCount++
warnings = append(warnings, fmt.Sprintf("No match in library %d for %s", collection.LibraryID, entry.Title))
continue
}
if firstRank, exists := seenContentIDs[item.ContentID]; exists {
slog.Debug("TMDB franchise sync: duplicate match skipped",
"rank", i+1,
"title", entry.Title,
"content_id", item.ContentID,
"first_rank", firstRank,
)
duplicateCount++
warnings = append(warnings, fmt.Sprintf("Duplicate TMDB entry for %s matched existing item %s", entry.Title, item.ContentID))
continue
}
seenContentIDs[item.ContentID] = i + 1
slog.Debug("TMDB franchise sync: matched",
"rank", i+1,
"title", entry.Title,
"tmdb_id", entry.ID,
"content_id", item.ContentID,
)
matchedItems = append(matchedItems, LibraryCollectionItemInput{
MediaItemID: item.ContentID,
Position: len(matchedItems),
SourceRank: i + 1,
})
if collectionutil.ItemLimitReached(len(matchedItems), cfg.Limit) {
limitReached = true
break
}
}
slog.Info("TMDB franchise sync: complete",
"collection_id", collection.ID,
"tmdb_collection_id", cfg.CollectionID,
"matched", len(matchedItems),
"unmatched", unmatchedCount,
"duplicates", duplicateCount,
"scanned", scannedEntries,
"total", len(results),
)
if err := s.collections.ReplaceItems(ctx, collection.ID, matchedItems); err != nil {
return nil, err
}
status := "success"
if len(warnings) > 0 {
status = "warning"
}
message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), len(results))
if limitReached {
message = fmt.Sprintf("%s (item limit reached after %d scanned)", message, scannedEntries)
}
if duplicateCount > 0 {
message = fmt.Sprintf("%s (%d duplicates skipped)", message, duplicateCount)
}
warningsJSON, err := json.Marshal(warnings)
if err != nil {
return nil, fmt.Errorf("marshaling sync warnings: %w", err)
}
completedAt := syncTimestamp()
run, err := s.collections.RecordSyncRun(ctx, RecordLibraryCollectionSyncRunInput{
CollectionID: collection.ID,
Status: status,
Message: message,
ItemsAdded: len(matchedItems),
ItemsRemoved: 0,
ItemsMatched: len(matchedItems),
ItemsUnmatched: unmatchedCount,
Warnings: warningsJSON,
StartedAt: startedAt,
CompletedAt: completedAt,
})
if err != nil {
return nil, err
}
if !opts.SkipCollage {
s.maybeGenerateCollage(ctx, collection.ID)
}
return run, nil
}
// validateTMDBDiscoverConfig returns a non-empty admin-facing failure message
// when the source_config is unsuitable for the discover sync path. Extracted
// as a pure helper so the message format can be unit-tested without spinning
// up the full sync stack.
//
// Returns the normalized media_type alongside the failure message: when the
// config is valid, the second return is the canonical media_type to use.
func validateTMDBDiscoverConfig(cfg libraryCollectionSourceConfig) (string, string) {
if cfg.Discover == nil {
return "TMDB discover sync requires a discover spec — edit the collection's source config", ""
}
mediaType := strings.TrimSpace(cfg.MediaType)
if mediaType == "" {
// Default to "movie" if unset so older configs without an explicit
// media_type still run; discover collections must pick one or the
// other on the TMDB side.
mediaType = "movie"
}
if mediaType != "movie" && mediaType != "tv" {
return fmt.Sprintf("TMDB discover sync: unsupported media_type %q", mediaType), ""
}
if strings.TrimSpace(cfg.Discover.SortBy) == "" {
return "TMDB discover sync: sort_by is required", ""
}
return "", mediaType
}
func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Context, collection *models.LibraryCollection, cfg libraryCollectionSourceConfig, opts SyncCollectionOptions) (*models.LibraryCollectionSyncRun, error) {
startedAt := syncTimestamp()
if s.TMDBDiscovers == nil {
return nil, fmt.Errorf("TMDB discover sync requires configured TMDB access")
}
reason, mediaType := validateTMDBDiscoverConfig(cfg)
if reason != "" {
return s.recordFailedCollectionSync(ctx, collection.ID, startedAt, reason)
}
params := TMDBDiscoverParams{
WithGenres: cfg.Discover.WithGenres,
WithoutGenres: cfg.Discover.WithoutGenres,
SortBy: cfg.Discover.SortBy,
VoteCountGte: cfg.Discover.VoteCountGte,
VoteAverageGte: cfg.Discover.VoteAverageGte,
ReleaseDateGte: cfg.Discover.ReleaseDateGte,
ReleaseDateLte: cfg.Discover.ReleaseDateLte,
Certifications: cfg.Discover.Certifications,
CertificationLte: cfg.Discover.CertificationLte,
WithRuntimeGte: cfg.Discover.WithRuntimeGte,
WithRuntimeLte: cfg.Discover.WithRuntimeLte,
OriginalLanguage: cfg.Discover.OriginalLanguage,
}
fetchLimit := collectionutil.SourceFetchLimit(cfg.Limit)
results, err := s.TMDBDiscovers.Discover(ctx, mediaType, params, fetchLimit)
if err != nil {
return nil, fmt.Errorf("fetching TMDB discover: %w", err)
}
slog.Info("TMDB discover sync: fetched results",
"collection_id", collection.ID,
"media_type", mediaType,
"sort_by", params.SortBy,
"count", len(results),
)
matchedItems := make([]LibraryCollectionItemInput, 0, len(results))
seenContentIDs := make(map[string]int, len(results))
warnings := make([]string, 0)
unmatchedCount := 0
duplicateCount := 0
scannedEntries := 0
limitReached := false
for i, entry := range results {
scannedEntries = i + 1
item, err := s.resolveTMDBEntry(ctx, collection.LibraryID, entry)
if err != nil {
return nil, err
}
if item == nil {
slog.Debug("TMDB discover sync: no match",
"rank", i+1,
"title", entry.Title,
"type", entry.MediaType,
"tmdb_id", entry.ID,
"imdb_id", entry.IMDbID,
"tvdb_id", entry.TVDBID,
)
unmatchedCount++
warnings = append(warnings, fmt.Sprintf("No match in library %d for %s", collection.LibraryID, entry.Title))
continue
}
if firstRank, exists := seenContentIDs[item.ContentID]; exists {
slog.Debug("TMDB discover sync: duplicate match skipped",
"rank", i+1,
"title", entry.Title,
"content_id", item.ContentID,
"first_rank", firstRank,
)
duplicateCount++
warnings = append(warnings, fmt.Sprintf("Duplicate TMDB entry for %s matched existing item %s", entry.Title, item.ContentID))
continue
}
seenContentIDs[item.ContentID] = i + 1
matchedItems = append(matchedItems, LibraryCollectionItemInput{
MediaItemID: item.ContentID,
Position: len(matchedItems),
SourceRank: i + 1,
})
if collectionutil.ItemLimitReached(len(matchedItems), cfg.Limit) {
limitReached = true
break
}
}
slog.Info("TMDB discover sync: complete",
"collection_id", collection.ID,
"media_type", mediaType,
"matched", len(matchedItems),
"unmatched", unmatchedCount,
"duplicates", duplicateCount,
"scanned", scannedEntries,
"total", len(results),
)
if err := s.collections.ReplaceItems(ctx, collection.ID, matchedItems); err != nil {
return nil, err
}
status := "success"
if len(warnings) > 0 {
status = "warning"
}
message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), len(results))
if limitReached {
message = fmt.Sprintf("%s (item limit reached after %d scanned)", message, scannedEntries)
}
if duplicateCount > 0 {
message = fmt.Sprintf("%s (%d duplicates skipped)", message, duplicateCount)
}
warningsJSON, err := json.Marshal(warnings)
if err != nil {
return nil, fmt.Errorf("marshaling sync warnings: %w", err)
}
completedAt := syncTimestamp()
run, err := s.collections.RecordSyncRun(ctx, RecordLibraryCollectionSyncRunInput{
CollectionID: collection.ID,
Status: status,
Message: message,
ItemsAdded: len(matchedItems),
ItemsRemoved: 0,
ItemsMatched: len(matchedItems),
ItemsUnmatched: unmatchedCount,
Warnings: warningsJSON,
StartedAt: startedAt,
CompletedAt: completedAt,
})
if err != nil {
return nil, err
}
if !opts.SkipCollage {
s.maybeGenerateCollage(ctx, collection.ID)
}
return run, nil
}
func (s *LibraryCollectionService) syncTraktPresetCollection(ctx context.Context, collection *models.LibraryCollection, cfg libraryCollectionSourceConfig, opts SyncCollectionOptions) (*models.LibraryCollectionSyncRun, error) {
startedAt := syncTimestamp()
if s.TraktCollections == nil {
return nil, fmt.Errorf("Trakt preset sync requires configured Trakt access")
}
preset := strings.TrimSpace(cfg.Preset)
mediaType := strings.TrimSpace(cfg.MediaType)
if mediaType == "" {
mediaType = "movie"
}
if preset != "trending" && preset != "popular" && preset != "recommended" {
return nil, fmt.Errorf("unsupported Trakt preset: %s", preset)
}
if mediaType != "movie" && mediaType != "tv" {
return nil, fmt.Errorf("unsupported Trakt media type: %s", mediaType)
}
accessToken := ""
if preset == "recommended" {
profileID := strings.TrimSpace(cfg.ProfileID)
if profileID == "" {
return s.recordFailedCollectionSync(ctx, collection.ID, startedAt, "Trakt recommendations require a profile")
}
if s.TraktTokenResolver == nil {
slog.Error("Trakt recommendations: token resolver not configured",
"collection_id", collection.ID,
"profile_id", profileID,
)
return s.recordFailedCollectionSync(ctx, collection.ID, startedAt, "Trakt recommendations: server is not configured for Trakt sync")
}
token, err := s.TraktTokenResolver.ResolveTraktAccessToken(ctx, profileID)
if err != nil {
slog.Error("Trakt recommendations: failed to resolve access token",
"collection_id", collection.ID,
"profile_id", profileID,
"error", err,
)
return s.recordFailedCollectionSync(ctx, collection.ID, startedAt, fmt.Sprintf("Trakt recommendations: failed to resolve access token: %v", err))
}
accessToken = token
}
fetchLimit := collectionutil.SourceFetchLimit(cfg.Limit)
results, err := s.TraktCollections.GetCollectionPreset(ctx, preset, mediaType, fetchLimit, accessToken)
if err != nil {
return nil, fmt.Errorf("fetching Trakt preset: %w", err)
}
slog.Info("Trakt preset sync: fetched results",
"collection_id", collection.ID,
"preset", preset,
"media_type", mediaType,
"count", len(results),
)
matchedItems := make([]LibraryCollectionItemInput, 0, len(results))
seenContentIDs := make(map[string]int, len(results))
warnings := make([]string, 0)
unmatchedCount := 0
duplicateCount := 0
scannedEntries := 0
limitReached := false
for i, entry := range results {
scannedEntries = i + 1
item, err := s.resolveTraktEntry(ctx, collection.LibraryID, entry)
if err != nil {
return nil, err
}
if item == nil {
unmatchedCount++
warnings = append(warnings, fmt.Sprintf("No match in library %d for %s", collection.LibraryID, entry.Title))
continue
}
if firstRank, exists := seenContentIDs[item.ContentID]; exists {
duplicateCount++
warnings = append(warnings, fmt.Sprintf("Duplicate Trakt entry for %s matched existing item %s", entry.Title, item.ContentID))
slog.Info("Trakt preset sync: duplicate match skipped",
"rank", i+1,
"title", entry.Title,
"content_id", item.ContentID,
"first_rank", firstRank,
)
continue
}
seenContentIDs[item.ContentID] = i + 1
sourceRank := entry.Rank
if sourceRank <= 0 {
sourceRank = i + 1
}
matchedItems = append(matchedItems, LibraryCollectionItemInput{
MediaItemID: item.ContentID,
Position: len(matchedItems),
SourceRank: sourceRank,
})
if collectionutil.ItemLimitReached(len(matchedItems), cfg.Limit) {
limitReached = true
break
}
}
if err := s.collections.ReplaceItems(ctx, collection.ID, matchedItems); err != nil {
return nil, err
}
status := "success"
if len(warnings) > 0 {
status = "warning"
}
message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), len(results))
if limitReached {
message = fmt.Sprintf("%s (item limit reached after %d scanned)", message, scannedEntries)
}
if duplicateCount > 0 {
message = fmt.Sprintf("%s (%d duplicates skipped)", message, duplicateCount)
}
warningsJSON, err := json.Marshal(warnings)
if err != nil {
return nil, fmt.Errorf("marshaling sync warnings: %w", err)
}
completedAt := syncTimestamp()
run, err := s.collections.RecordSyncRun(ctx, RecordLibraryCollectionSyncRunInput{
CollectionID: collection.ID,
Status: status,
Message: message,
ItemsAdded: len(matchedItems),
ItemsRemoved: 0,
ItemsMatched: len(matchedItems),
ItemsUnmatched: unmatchedCount,
Warnings: warningsJSON,
StartedAt: startedAt,
CompletedAt: completedAt,
})
if err != nil {
return nil, err
}
if !opts.SkipCollage {
s.maybeGenerateCollage(ctx, collection.ID)
}
return run, nil
}
func (s *LibraryCollectionService) recordFailedCollectionSync(ctx context.Context, collectionID string, startedAt time.Time, message string) (*models.LibraryCollectionSyncRun, error) {
warningsJSON, err := json.Marshal([]string{message})
if err != nil {
return nil, fmt.Errorf("marshaling sync warnings: %w", err)
}
return s.collections.RecordSyncRun(ctx, RecordLibraryCollectionSyncRunInput{
CollectionID: collectionID,
Status: "failed",
Message: message,
Warnings: warningsJSON,
StartedAt: startedAt,
CompletedAt: syncTimestamp(),
})
}
// resolveTMDBEntry finds a media item in the library matching a TMDB preset entry.
// It tries TMDB ID, IMDb ID, and TVDB ID (for TV shows) to maximize match rate.
func (s *LibraryCollectionService) resolveTMDBEntry(ctx context.Context, libraryID int, entry TMDBCollectionEntry) (*models.MediaItem, error) {
itemType := "movie"
if entry.MediaType == "tv" {
itemType = "series"
}
tmdbID := ""
if entry.ID > 0 {
tmdbID = fmt.Sprintf("%d", entry.ID)
}
tvdbID := ""
if entry.TVDBID > 0 {
tvdbID = fmt.Sprintf("%d", entry.TVDBID)
}
item, err := s.items.GetByExternalID(ctx, tmdbID, entry.IMDbID, tvdbID, itemType)
if err != nil {
if !errors.Is(err, ErrItemNotFound) {
return nil, err
}
return nil, nil
}
membership, err := s.libraryItems.GetItemsInFolder(ctx, []string{item.ContentID}, libraryID)
if err != nil {
return nil, err
}
if !membership[item.ContentID] {
return nil, nil
}
return item, nil
}
// resolveTraktEntry finds a local item matching a Trakt discovery entry.
func (s *LibraryCollectionService) resolveTraktEntry(ctx context.Context, libraryID int, entry TraktCollectionEntry) (*models.MediaItem, error) {
itemType := "movie"
if entry.MediaType == "tv" {
itemType = "series"
}
batch := ExternalIDBatch{}
if entry.TMDBID > 0 {
batch.TMDBIDs = []string{fmt.Sprintf("%d", entry.TMDBID)}
}
if entry.IMDbID != "" {
batch.IMDbIDs = []string{entry.IMDbID}
}
if entry.TVDBID > 0 && itemType == "series" {
batch.TVDBIDs = []string{fmt.Sprintf("%d", entry.TVDBID)}
}
lookup, err := s.items.GetByExternalIDs(ctx, batch, itemType)
if err != nil {
return nil, err
}
candidates := traktCandidatesByPriority(lookup, entry, itemType)
if len(candidates) == 0 {
return nil, nil
}
membership, err := s.libraryItems.GetItemsInFolder(ctx, candidates, libraryID)
if err != nil {
return nil, err
}
for _, candidate := range candidates {
if membership[candidate] {
return s.items.GetByID(ctx, candidate)
}
}
return nil, nil
}
func traktCandidatesByPriority(lookup *ExternalIDLookup, entry TraktCollectionEntry, itemType string) []string {
if lookup == nil {
return nil
}
var candidates []string
seen := make(map[string]struct{}, 3)
add := func(id string) {
if id == "" {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
candidates = append(candidates, id)
}
if itemType == "series" && entry.TVDBID > 0 {
add(lookup.ByTVDB[fmt.Sprintf("%d", entry.TVDBID)])
}
if entry.TMDBID > 0 {
add(lookup.ByTMDB[fmt.Sprintf("%d", entry.TMDBID)])
}
if entry.IMDbID != "" {
add(lookup.ByIMDb[entry.IMDbID])
}
return candidates
}
func (s *LibraryCollectionService) fetchMDBListEntries(ctx context.Context, listURL string) ([]mdblistEntry, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil)
if err != nil {
return nil, fmt.Errorf("creating mdblist request: %w", err)
}
res, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching mdblist list: %w", err)
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("mdblist request failed with status %d", res.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(res.Body, 4<<20))
if err != nil {
return nil, fmt.Errorf("reading mdblist response: %w", err)
}
var entries []mdblistEntry
if err := json.Unmarshal(body, &entries); err != nil {
return nil, fmt.Errorf("parsing mdblist response: %w", err)
}
return entries, nil
}
// mdbListEntryItemType normalizes an MDBList entry's media_type field to the
// internal "movie"/"series" item type taxonomy.
func mdbListEntryItemType(entry mdblistEntry) string {
switch strings.ToLower(entry.MediaType) {
case "show", "tv", "series":
return "series"
default:
return "movie"
}
}
// pickCandidatesByPriority returns all matching content_ids in priority order
// (highest first), deduped. The caller resolves library membership against
// this list and picks the first hit — matching the legacy resolveMDBListEntry
// fallback semantic where any library-resident candidate wins regardless of
// which external ID resolved it.
//
// Priority order: for series TVDB > TMDB > IMDb, for movies TMDB > IMDb.
func pickCandidatesByPriority(lookup *ExternalIDLookup, entry mdblistEntry, itemType string) []string {
if lookup == nil {
return nil
}
var candidates []string
seen := make(map[string]bool, 3)
add := func(id string) {
if id == "" || seen[id] {
return
}
seen[id] = true
candidates = append(candidates, id)
}
if itemType == "series" && entry.TVDBID != nil && *entry.TVDBID > 0 {
add(lookup.ByTVDB[fmt.Sprintf("%d", *entry.TVDBID)])
}
if entry.ID > 0 {
add(lookup.ByTMDB[fmt.Sprintf("%d", entry.ID)])
}
if entry.IMDbID != "" {
add(lookup.ByIMDb[entry.IMDbID])
}
return candidates
}
func slugifyCollectionTitle(title string) string {
title = strings.ToLower(strings.TrimSpace(title))
title = strings.ReplaceAll(title, "'", "")
var builder strings.Builder
lastHyphen := false
for _, r := range title {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
builder.WriteRune(r)
lastHyphen = false
continue
}
if !lastHyphen {
builder.WriteByte('-')
lastHyphen = true
}
}
return strings.Trim(builder.String(), "-")
}
// maybeGenerateCollage triggers poster collage generation for a collection
// if no admin-uploaded poster exists. Errors are logged but never propagated
// so that sync operations are not blocked by collage failures.
func (s *LibraryCollectionService) maybeGenerateCollage(ctx context.Context, collectionID string) {
if s.CollageGen == nil {
return
}
collection, err := s.collections.GetByID(ctx, collectionID)
if err != nil {
slog.Warn("collage: failed to load collection", "collection_id", collectionID, "error", err)
return
}
// Skip if an admin-uploaded poster exists.
if collection.PosterURL != "" && !collection.PosterAutoGenerated {
return
}
if err := s.CollageGen.GenerateCollectionPoster(ctx, collectionID); err != nil {
if errors.Is(err, collage.ErrNotEnoughImages) {
slog.Debug("collage: not enough images", "collection_id", collectionID)
} else {
slog.Warn("collage: poster generation failed", "collection_id", collectionID, "error", err)
}
}
}
func syncTimestamp() time.Time {
return time.Now().UTC().Truncate(time.Microsecond)
}