* fix(metadata): re-fetch titles/overviews when a library's metadata language changes An item's default_metadata_language was stamped once at first match and never updated: the canonical-language pin in mergeAndPersist routed any refresh in a different language into the localization tables, the upserts' COALESCE kept the old stamp forever, quick-mode library refresh skipped complete items entirely, and changing the language in HandleUpdateLibrary triggered nothing. Items stayed in the old language no matter how often the admin refreshed (#211). Four coupled changes: - ProcessRequest.AdoptLanguage: folder-scoped manual refreshes adopt the library's language as the item's new canonical language when it differs from the stamp, rewriting the base row instead of localizing to the side. Only ModeManualRefresh adopts — scheduled refreshes merge fill-empty and would restamp without rewriting the text. - Upsert language pins inverted (media_items, seasons, episodes): prefer the incoming non-empty default_metadata_language over the existing stamp. All existing callers send the unchanged stamp or empty, so behavior is unchanged outside adoption; the restamp is atomic with the canonical write. - Quick-mode refresh lister now includes complete items whose stamp differs from the library's configured language. - HandleUpdateLibrary enqueues a quick library metadata refresh when the metadata language changes, mirroring the paths-change rescan trigger. Fixes #211 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(metadata): gate language adoption on field locks and library agreement Two hardening fixes for AdoptLanguage from review of #278: - Locked fields defeat the restamp: title/overview merges honor per-field locks, but the restamp was unconditional. An item with both language- bearing fields locked kept its old-language text yet got stamped the new language, so the quick-refresh mismatch predicate never flagged it again. mergeAndPersist now skips adoption when both name and overview are locked, falling back to the non-adopting behavior: the stamp stays put, isCanonicalWrite goes false, and the fetch routes to the localization tables exactly like a non-adopting refresh in that language does today. One locked field still adopts — the other is actually rewritten. - Multi-library flip-flop: an item in libraries with different metadata languages had its canonical base row rewritten to whichever library refreshed last, oscillating forever. Process now requires every library containing the item (media_item_libraries) to resolve to the adoption target before setting AdoptLanguage, via the existing GetDistinctMetadataLanguagesForItem (which applies the same empty→en default as resolveFolderLanguage). Disagreement or a lookup failure keeps the current stamp — stable beats flip-flopping. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
466 lines
13 KiB
Go
466 lines
13 KiB
Go
package adminjob
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/cache"
|
|
"github.com/Silo-Server/silo-server/internal/libraryingest"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/notifications"
|
|
)
|
|
|
|
const (
|
|
JobTypeLibraryRefresh = "library_refresh"
|
|
|
|
defaultUnmatchedRefreshDelay = 2 * time.Second
|
|
libraryRefreshWorkerCount = 4
|
|
)
|
|
|
|
type LibraryRefreshMode string
|
|
|
|
const (
|
|
LibraryRefreshModeQuick LibraryRefreshMode = "quick"
|
|
LibraryRefreshModeFull LibraryRefreshMode = "full"
|
|
)
|
|
|
|
type LibraryRefreshRequest struct {
|
|
LibraryID int `json:"library_id"`
|
|
LibraryName string `json:"library_name"`
|
|
Mode LibraryRefreshMode `json:"mode,omitempty"`
|
|
}
|
|
|
|
type LibraryRefreshResult struct {
|
|
LibraryID int `json:"library_id"`
|
|
LibraryName string `json:"library_name"`
|
|
Mode LibraryRefreshMode `json:"mode"`
|
|
TotalItems int `json:"total_items"`
|
|
ItemsWithIDs int `json:"items_with_ids"`
|
|
ItemsWithoutIDs int `json:"items_without_ids"`
|
|
RefreshedOK int `json:"refreshed_ok"`
|
|
RefreshedFailed int `json:"refreshed_failed"`
|
|
PipelineOK int `json:"pipeline_ok"`
|
|
PipelineFailed int `json:"pipeline_failed"`
|
|
}
|
|
|
|
type LibraryRefreshItem struct {
|
|
ContentID string
|
|
TmdbID string
|
|
TvdbID string
|
|
ImdbID string
|
|
}
|
|
|
|
type libraryRefreshItemLister interface {
|
|
ListLibraryItems(ctx context.Context, libraryID int, mode LibraryRefreshMode) ([]LibraryRefreshItem, error)
|
|
}
|
|
|
|
type libraryRefreshFolderRepo interface {
|
|
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
|
|
}
|
|
|
|
type libraryRefreshScopeResolver interface {
|
|
ResolveForLibrary(ctx context.Context, contentID string, libraryID int) (*ItemRefreshRequest, error)
|
|
}
|
|
|
|
type libraryRefreshIngester interface {
|
|
IngestSubtree(ctx context.Context, folder *models.MediaFolder, subtreePath string) (*libraryingest.Result, error)
|
|
}
|
|
|
|
type libraryRefreshRefresher interface {
|
|
RefreshItem(ctx context.Context, contentID string) error
|
|
RefreshItemForLibrary(ctx context.Context, contentID string, folderID int) error
|
|
}
|
|
|
|
type PGLibraryRefreshItemLister struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewPGLibraryRefreshItemLister(pool *pgxpool.Pool) *PGLibraryRefreshItemLister {
|
|
return &PGLibraryRefreshItemLister{pool: pool}
|
|
}
|
|
|
|
func (l *PGLibraryRefreshItemLister) ListLibraryItems(ctx context.Context, libraryID int, mode LibraryRefreshMode) ([]LibraryRefreshItem, error) {
|
|
query := `
|
|
SELECT mi.content_id, COALESCE(mi.tmdb_id, ''), COALESCE(mi.tvdb_id, ''), COALESCE(mi.imdb_id, '')
|
|
FROM media_item_libraries mil
|
|
JOIN media_folders f ON f.id = mil.media_folder_id
|
|
JOIN media_items mi ON mi.content_id = mil.content_id
|
|
WHERE mil.media_folder_id = $1`
|
|
args := []any{libraryID}
|
|
|
|
if normalizeLibraryRefreshMode(mode) == LibraryRefreshModeQuick {
|
|
query += `
|
|
AND (
|
|
COALESCE(mi.tmdb_id, '') <> ''
|
|
OR COALESCE(mi.tvdb_id, '') <> ''
|
|
OR COALESCE(mi.imdb_id, '') <> ''
|
|
)
|
|
AND (
|
|
mi.last_refreshed IS NULL
|
|
OR COALESCE(mi.overview, '') = ''
|
|
OR COALESCE(mi.poster_path, '') = ''
|
|
OR COALESCE(mi.backdrop_path, '') = ''
|
|
OR COALESCE(mi.poster_path, '') LIKE '%//poster/%'
|
|
OR COALESCE(mi.backdrop_path, '') LIKE '%//backdrop/%'
|
|
OR COALESCE(mi.logo_path, '') LIKE '%//logo/%'
|
|
OR mi.refresh_failures > 0
|
|
OR mi.episode_metadata_incomplete = TRUE
|
|
OR (
|
|
LOWER(TRIM(COALESCE(mi.status, ''))) = 'matched'
|
|
AND COALESCE(mi.tmdb_id, '') = ''
|
|
AND (
|
|
COALESCE(mi.tvdb_id, '') <> ''
|
|
OR COALESCE(mi.imdb_id, '') <> ''
|
|
)
|
|
)
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM stale_media_ids smi
|
|
WHERE smi.content_id = mi.content_id
|
|
)
|
|
OR (
|
|
COALESCE(mi.default_metadata_language, '') <> ''
|
|
AND LOWER(TRIM(mi.default_metadata_language))
|
|
<> LOWER(TRIM(COALESCE(NULLIF(f.metadata_language, ''), 'en')))
|
|
)
|
|
)`
|
|
}
|
|
|
|
query += "\n\t\tORDER BY mi.content_id ASC"
|
|
|
|
rows, err := l.pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query library items: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]LibraryRefreshItem, 0)
|
|
for rows.Next() {
|
|
var item LibraryRefreshItem
|
|
if err := rows.Scan(&item.ContentID, &item.TmdbID, &item.TvdbID, &item.ImdbID); err != nil {
|
|
return nil, fmt.Errorf("scan library refresh item: %w", err)
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate library refresh items: %w", err)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
type LibraryRefreshExecutor struct {
|
|
itemLister libraryRefreshItemLister
|
|
folderRepo libraryRefreshFolderRepo
|
|
resolver libraryRefreshScopeResolver
|
|
ingester libraryRefreshIngester
|
|
refresher libraryRefreshRefresher
|
|
eventBus cache.EventBus
|
|
realtimeHub *notifications.Hub
|
|
unmatchedDelay time.Duration
|
|
wait func(ctx context.Context, delay time.Duration) error
|
|
}
|
|
|
|
func NewLibraryRefreshExecutor(
|
|
itemLister libraryRefreshItemLister,
|
|
folderRepo libraryRefreshFolderRepo,
|
|
resolver libraryRefreshScopeResolver,
|
|
ingester libraryRefreshIngester,
|
|
refresher libraryRefreshRefresher,
|
|
eventBus cache.EventBus,
|
|
realtimeHub *notifications.Hub,
|
|
) *LibraryRefreshExecutor {
|
|
return &LibraryRefreshExecutor{
|
|
itemLister: itemLister,
|
|
folderRepo: folderRepo,
|
|
resolver: resolver,
|
|
ingester: ingester,
|
|
refresher: refresher,
|
|
eventBus: eventBus,
|
|
realtimeHub: realtimeHub,
|
|
unmatchedDelay: defaultUnmatchedRefreshDelay,
|
|
wait: waitWithContext,
|
|
}
|
|
}
|
|
|
|
func (e *LibraryRefreshExecutor) Execute(
|
|
ctx context.Context,
|
|
req LibraryRefreshRequest,
|
|
progress func(current, total int, message string),
|
|
) (*LibraryRefreshResult, error) {
|
|
if e == nil || e.itemLister == nil || e.folderRepo == nil || e.refresher == nil {
|
|
return nil, fmt.Errorf("library refresh executor is not fully configured")
|
|
}
|
|
req.Mode = normalizeLibraryRefreshMode(req.Mode)
|
|
if req.LibraryID <= 0 {
|
|
return nil, fmt.Errorf("library_id is required")
|
|
}
|
|
if err := e.ensureLibraryEnabled(ctx, req.LibraryID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
items, err := e.itemLister.ListLibraryItems(ctx, req.LibraryID, req.Mode)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load library items: %w", err)
|
|
}
|
|
|
|
result := &LibraryRefreshResult{
|
|
LibraryID: req.LibraryID,
|
|
LibraryName: req.LibraryName,
|
|
Mode: req.Mode,
|
|
TotalItems: len(items),
|
|
}
|
|
if result.TotalItems == 0 {
|
|
if progress != nil {
|
|
progress(0, 0, "No library items need refresh")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
withIDs := make([]string, 0, len(items))
|
|
withoutIDs := make([]string, 0, len(items))
|
|
for _, item := range items {
|
|
if item.TmdbID == "" && item.TvdbID == "" && item.ImdbID == "" {
|
|
withoutIDs = append(withoutIDs, item.ContentID)
|
|
} else {
|
|
withIDs = append(withIDs, item.ContentID)
|
|
}
|
|
}
|
|
result.ItemsWithIDs = len(withIDs)
|
|
result.ItemsWithoutIDs = len(withoutIDs)
|
|
|
|
current := 0
|
|
advance := func(message string) {
|
|
current++
|
|
if progress != nil {
|
|
progress(current, result.TotalItems, message)
|
|
}
|
|
}
|
|
|
|
if len(withIDs) > 0 {
|
|
if err := e.refreshItemsWithIDs(ctx, withIDs, result, advance); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if req.Mode == LibraryRefreshModeFull && len(withoutIDs) > 0 {
|
|
if err := e.refreshItemsWithoutIDs(ctx, req.LibraryID, withoutIDs, result, advance); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if result.RefreshedOK > 0 || result.PipelineOK > 0 {
|
|
e.publish(cache.EventMetadataUpdated, strconv.Itoa(req.LibraryID))
|
|
if e.realtimeHub != nil {
|
|
_ = e.realtimeHub.PublishCatalogItemChanged(ctx, notifications.MetadataUpdateEvent{
|
|
LibraryID: req.LibraryID,
|
|
Change: "metadata_updated",
|
|
})
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (e *LibraryRefreshExecutor) refreshItemsWithIDs(
|
|
ctx context.Context,
|
|
contentIDs []string,
|
|
result *LibraryRefreshResult,
|
|
advance func(message string),
|
|
) error {
|
|
workerCount := libraryRefreshWorkerCount
|
|
if len(contentIDs) < workerCount {
|
|
workerCount = len(contentIDs)
|
|
}
|
|
|
|
type refreshResult struct {
|
|
contentID string
|
|
err error
|
|
}
|
|
|
|
jobs := make(chan string, len(contentIDs))
|
|
results := make(chan refreshResult, len(contentIDs))
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < workerCount; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for contentID := range jobs {
|
|
results <- refreshResult{
|
|
contentID: contentID,
|
|
err: e.refresher.RefreshItemForLibrary(ctx, contentID, result.LibraryID),
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
scheduled := 0
|
|
for _, contentID := range contentIDs {
|
|
if err := e.ensureLibraryEnabled(ctx, result.LibraryID); err != nil {
|
|
close(jobs)
|
|
wg.Wait()
|
|
return err
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
close(jobs)
|
|
wg.Wait()
|
|
return ctx.Err()
|
|
case jobs <- contentID:
|
|
scheduled++
|
|
}
|
|
}
|
|
close(jobs)
|
|
|
|
for i := 0; i < scheduled; i++ {
|
|
select {
|
|
case <-ctx.Done():
|
|
wg.Wait()
|
|
return ctx.Err()
|
|
case itemResult := <-results:
|
|
if itemResult.err != nil {
|
|
result.RefreshedFailed++
|
|
} else {
|
|
result.RefreshedOK++
|
|
e.publishCatalogItemChanged(ctx, result.LibraryID, itemResult.contentID)
|
|
}
|
|
advance("Refreshing items with external IDs")
|
|
}
|
|
}
|
|
|
|
wg.Wait()
|
|
return nil
|
|
}
|
|
|
|
func (e *LibraryRefreshExecutor) refreshItemsWithoutIDs(
|
|
ctx context.Context,
|
|
libraryID int,
|
|
contentIDs []string,
|
|
result *LibraryRefreshResult,
|
|
advance func(message string),
|
|
) error {
|
|
fullPipelineAvailable := e.resolver != nil && e.ingester != nil
|
|
for i, contentID := range contentIDs {
|
|
if err := e.ensureLibraryEnabled(ctx, libraryID); err != nil {
|
|
return err
|
|
}
|
|
if i > 0 && e.unmatchedDelay > 0 {
|
|
if err := e.wait(ctx, e.unmatchedDelay); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !fullPipelineAvailable {
|
|
result.PipelineFailed++
|
|
advance("Full refresh unavailable for unmatched items")
|
|
continue
|
|
}
|
|
|
|
if err := e.refreshUnmatchedItem(ctx, libraryID, contentID); err != nil {
|
|
result.PipelineFailed++
|
|
} else {
|
|
result.PipelineOK++
|
|
}
|
|
advance("Refreshing unmatched items")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *LibraryRefreshExecutor) refreshUnmatchedItem(ctx context.Context, libraryID int, contentID string) error {
|
|
req, err := e.resolver.ResolveForLibrary(ctx, contentID, libraryID)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve scope: %w", err)
|
|
}
|
|
if req.ScanFolderID != libraryID {
|
|
return fmt.Errorf("resolved scan folder %d does not match requested library %d", req.ScanFolderID, libraryID)
|
|
}
|
|
|
|
folder, err := e.folderRepo.GetByID(ctx, req.ScanFolderID)
|
|
if err != nil {
|
|
return fmt.Errorf("load folder %d: %w", req.ScanFolderID, err)
|
|
}
|
|
if !folder.Enabled {
|
|
return fmt.Errorf("load folder %d: library is disabled", req.ScanFolderID)
|
|
}
|
|
if _, err := e.ingester.IngestSubtree(ctx, folder, req.ScanPath); err != nil {
|
|
return fmt.Errorf("ingest subtree: %w", err)
|
|
}
|
|
if err := e.refresher.RefreshItemForLibrary(ctx, req.RefreshContentID, req.ScanFolderID); err != nil {
|
|
return fmt.Errorf("refresh metadata: %w", err)
|
|
}
|
|
e.publishCatalogItemChanged(ctx, libraryID, req.RefreshContentID)
|
|
return nil
|
|
}
|
|
|
|
func (e *LibraryRefreshExecutor) publish(eventType, payload string) {
|
|
if e.eventBus == nil {
|
|
return
|
|
}
|
|
_ = e.eventBus.Publish(context.Background(), cache.ChannelCatalog, cache.Event{
|
|
Type: eventType,
|
|
Payload: payload,
|
|
})
|
|
}
|
|
|
|
func (e *LibraryRefreshExecutor) publishCatalogItemChanged(ctx context.Context, libraryID int, contentID string) {
|
|
if e == nil || e.realtimeHub == nil || contentID == "" {
|
|
return
|
|
}
|
|
_ = e.realtimeHub.PublishCatalogItemChanged(ctx, notifications.MetadataUpdateEvent{
|
|
LibraryID: libraryID,
|
|
ContentID: contentID,
|
|
Change: "metadata_updated",
|
|
})
|
|
}
|
|
|
|
func decodeLibraryRefreshRequest(data json.RawMessage) (LibraryRefreshRequest, error) {
|
|
var req LibraryRefreshRequest
|
|
if len(data) == 0 {
|
|
return req, fmt.Errorf("missing library refresh payload")
|
|
}
|
|
if err := json.Unmarshal(data, &req); err != nil {
|
|
return req, fmt.Errorf("invalid library refresh payload: %w", err)
|
|
}
|
|
req.Mode = normalizeLibraryRefreshMode(req.Mode)
|
|
return req, nil
|
|
}
|
|
|
|
func normalizeLibraryRefreshMode(mode LibraryRefreshMode) LibraryRefreshMode {
|
|
if mode == LibraryRefreshModeFull {
|
|
return LibraryRefreshModeFull
|
|
}
|
|
return LibraryRefreshModeQuick
|
|
}
|
|
|
|
func waitWithContext(ctx context.Context, delay time.Duration) error {
|
|
timer := time.NewTimer(delay)
|
|
defer timer.Stop()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (e *LibraryRefreshExecutor) ensureLibraryEnabled(ctx context.Context, libraryID int) error {
|
|
if e == nil || e.folderRepo == nil {
|
|
return nil
|
|
}
|
|
folder, err := e.folderRepo.GetByID(ctx, libraryID)
|
|
if err != nil {
|
|
return fmt.Errorf("load library %d: %w", libraryID, err)
|
|
}
|
|
if !folder.Enabled {
|
|
return fmt.Errorf("library %d is disabled", libraryID)
|
|
}
|
|
return nil
|
|
}
|