feat(metadata): add resilient match queue diagnostics (#463)

* feat(metadata): add resilient match queue diagnostics

* fix(metadata): harden match queue lifecycle

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
This commit is contained in:
Suspense
2026-07-24 12:18:49 -04:00
committed by GitHub
co-authored by Quick104
parent 383973ec22
commit 22fec4ed2d
24 changed files with 3101 additions and 397 deletions
+29
View File
@@ -1228,6 +1228,35 @@ func main() {
deps.MovieMatchQueueRepo = movieQueueRepo
deps.SeriesRootMatchQueueRepo = seriesQueueRepo
matchQueueCoordinator = metadata.NewMatchQueueCoordinator(movieQueueRepo, seriesQueueRepo)
backgroundInit = append(backgroundInit, func(ctx context.Context) {
if err := matchQueueCoordinator.WakeForChangedInputs(ctx); err != nil {
slog.WarnContext(ctx, "refresh metadata match queue inputs at startup failed", "component", "app", "error", err)
}
})
if pluginService != nil {
matchInputChanged := make(chan struct{}, 1)
go func() {
for {
select {
case <-appCtx.Done():
return
case <-matchInputChanged:
if err := matchQueueCoordinator.WakeForChangedInputs(appCtx); err != nil {
slog.WarnContext(appCtx, "wake metadata matches after plugin lifecycle change failed", "component", "app", "error", err)
}
}
}
}()
pluginService.AddLifecycleHook(func(context.Context) {
// Queue fingerprint reconciliation may touch thousands of parked
// rows. Coalesce lifecycle bursts and keep plugin admin requests
// independent of that background database work.
select {
case matchInputChanged <- struct{}{}:
default:
}
})
}
rootClaimRepo = catalog.NewRootClaimRepository(deps.DB)
groupClaimRepo = catalog.NewGroupClaimRepository(deps.DB)
pluginResolver := metadata.NewPluginResolverAdapter(pluginService)
+163 -62
View File
@@ -101,19 +101,24 @@ type libraryScanQueuer interface {
type libraryMovieMatchQueue interface {
SyncForFolder(ctx context.Context, folderID int) error
DeleteByFolder(ctx context.Context, folderID int) (int, error)
CountByFolder(ctx context.Context, folderID int) (int, error)
CountStatesByFolder(ctx context.Context, folderID int) (pending int, parked int, err error)
CountStatesByFolders(ctx context.Context, folderIDs []int) (map[int]metadata.MatchQueueStateCounts, error)
ListByFolder(ctx context.Context, folderID int, limit int, offset int) ([]models.MovieMatchQueueEntry, int, error)
RetryNowByFolder(ctx context.Context, folderID int) (int, error)
}
type librarySeriesMatchQueue interface {
SyncForFolder(ctx context.Context, folderID int) error
DeleteByFolder(ctx context.Context, folderID int) (int, error)
CountByFolder(ctx context.Context, folderID int) (int, error)
CountStatesByFolder(ctx context.Context, folderID int) (pending int, parked int, err error)
CountStatesByFolders(ctx context.Context, folderIDs []int) (map[int]metadata.MatchQueueStateCounts, error)
ListByFolder(ctx context.Context, folderID int, limit int, offset int) ([]models.SeriesRootMatchQueueEntry, int, error)
RetryNowByFolder(ctx context.Context, folderID int) (int, error)
}
type libraryRawMatchBacklog interface {
CountUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode) (int, error)
CountUnmatchedMatchBacklogByFolders(ctx context.Context, folderIDs []int, mode scanner.RawMatchBacklogMode) (map[int]int, error)
ListUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode, limit int, offset int) ([]*models.MediaFile, int, error)
SuppressUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode) (int, error)
RetryUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode) (int, error)
@@ -713,7 +718,11 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ
// existing items adopt the new language instead of keeping the one
// stamped at first match. Quick mode suffices: the refresh item lister
// includes complete-but-language-mismatched items.
if h.JobRepo != nil && !strings.EqualFold(strings.TrimSpace(oldFolder.MetadataLanguage), strings.TrimSpace(folder.MetadataLanguage)) {
languageChanged := !strings.EqualFold(strings.TrimSpace(oldFolder.MetadataLanguage), strings.TrimSpace(folder.MetadataLanguage))
if languageChanged {
h.wakeMetadataMatcher(r.Context(), folder.ID)
}
if h.JobRepo != nil && languageChanged {
job, jobErr := h.JobRepo.CreateLibraryRefresh(r.Context(), currentAdminUserID(r), adminjob.LibraryRefreshRequest{
LibraryID: folder.ID,
LibraryName: folder.Name,
@@ -1370,6 +1379,8 @@ type libraryMetadataMatchQueueStatusResponse struct {
SeriesCount int `json:"series_count"`
RawFileCount int `json:"raw_file_count"`
TotalCount int `json:"total_count"`
PendingCount int `json:"pending_count"`
ParkedCount int `json:"parked_count"`
}
type libraryMetadataMatchQueueActionResponse struct {
@@ -1385,32 +1396,46 @@ type libraryMetadataMatchQueueActionResponse struct {
type libraryMetadataMatchQueueDetailResponse struct {
libraryMetadataMatchQueueStatusResponse
Limit int `json:"limit"`
Offset int `json:"offset"`
Movies []libraryMovieMatchQueueEntryResponse `json:"movies"`
Series []librarySeriesMatchQueueEntryResponse `json:"series"`
RawFiles []libraryRawMatchBacklogEntryResponse `json:"raw_files"`
}
type libraryMovieMatchQueueEntryResponse struct {
MediaFileID int `json:"media_file_id"`
MediaFolderID int `json:"media_folder_id"`
FilePath string `json:"file_path"`
FirstQueuedAt time.Time `json:"first_queued_at"`
AvailableAt time.Time `json:"available_at"`
LastAttemptedAt *time.Time `json:"last_attempted_at,omitempty"`
AttemptCount int `json:"attempt_count"`
LastError string `json:"last_error,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
MediaFileID int `json:"media_file_id"`
MediaFolderID int `json:"media_folder_id"`
FilePath string `json:"file_path"`
FirstQueuedAt time.Time `json:"first_queued_at"`
AvailableAt time.Time `json:"available_at"`
LastAttemptedAt *time.Time `json:"last_attempted_at,omitempty"`
AttemptCount int `json:"attempt_count"`
LastError string `json:"last_error,omitempty"`
State string `json:"state"`
FailureKind string `json:"failure_kind,omitempty"`
FailureDetail json.RawMessage `json:"failure_detail,omitempty"`
DeterministicAttemptCount int `json:"deterministic_attempt_count"`
MatcherRevision int `json:"matcher_revision"`
ParkedAt *time.Time `json:"parked_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type librarySeriesMatchQueueEntryResponse struct {
MediaFolderID int `json:"media_folder_id"`
ObservedRootPath string `json:"observed_root_path"`
FirstQueuedAt time.Time `json:"first_queued_at"`
AvailableAt time.Time `json:"available_at"`
LastAttemptedAt *time.Time `json:"last_attempted_at,omitempty"`
AttemptCount int `json:"attempt_count"`
LastError string `json:"last_error,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
MediaFolderID int `json:"media_folder_id"`
ObservedRootPath string `json:"observed_root_path"`
FirstQueuedAt time.Time `json:"first_queued_at"`
AvailableAt time.Time `json:"available_at"`
LastAttemptedAt *time.Time `json:"last_attempted_at,omitempty"`
AttemptCount int `json:"attempt_count"`
LastError string `json:"last_error,omitempty"`
State string `json:"state"`
FailureKind string `json:"failure_kind,omitempty"`
FailureDetail json.RawMessage `json:"failure_detail,omitempty"`
DeterministicAttemptCount int `json:"deterministic_attempt_count"`
MatcherRevision int `json:"matcher_revision"`
ParkedAt *time.Time `json:"parked_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type libraryRawMatchBacklogEntryResponse struct {
@@ -1438,18 +1463,21 @@ func (h *LibraryHandler) HandleListMetadataMatchQueues(w http.ResponseWriter, r
return
}
resp := make([]libraryMetadataMatchQueueStatusResponse, 0, len(folders))
folderIDs := make([]int, 0, len(folders))
for _, folder := range folders {
if folder == nil {
continue
if folder != nil {
folderIDs = append(folderIDs, folder.ID)
}
status, err := h.metadataMatchQueueStatus(r.Context(), folder.ID)
if err != nil {
slog.ErrorContext(r.Context(), "metadata queue: failed to load queue status", "component", "api", "library_id", folder.ID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue")
return
}
resp = append(resp, status)
}
statuses, err := h.metadataMatchQueueStatuses(r.Context(), folderIDs)
if err != nil {
slog.ErrorContext(r.Context(), "metadata queue: failed to load queue statuses", "component", "api", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queues")
return
}
resp := make([]libraryMetadataMatchQueueStatusResponse, 0, len(folderIDs))
for _, folderID := range folderIDs {
resp = append(resp, statuses[folderID])
}
writeJSON(w, http.StatusOK, resp)
@@ -1481,6 +1509,12 @@ func (h *LibraryHandler) HandleGetMetadataMatchQueue(w http.ResponseWriter, r *h
limit = parsed
}
}
offset := 0
if value := strings.TrimSpace(r.URL.Query().Get("offset")); value != "" {
if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed >= 0 {
offset = parsed
}
}
status, err := h.metadataMatchQueueStatus(r.Context(), id)
if err != nil {
@@ -1491,12 +1525,14 @@ func (h *LibraryHandler) HandleGetMetadataMatchQueue(w http.ResponseWriter, r *h
resp := libraryMetadataMatchQueueDetailResponse{
libraryMetadataMatchQueueStatusResponse: status,
Limit: limit,
Offset: offset,
Movies: []libraryMovieMatchQueueEntryResponse{},
Series: []librarySeriesMatchQueueEntryResponse{},
RawFiles: []libraryRawMatchBacklogEntryResponse{},
}
if h.MovieMatchQueueRepo != nil {
movies, _, err := h.MovieMatchQueueRepo.ListByFolder(r.Context(), id, limit, 0)
movies, _, err := h.MovieMatchQueueRepo.ListByFolder(r.Context(), id, limit, offset)
if err != nil {
slog.ErrorContext(r.Context(), "metadata queue: failed to list movie queue", "component", "api", "library_id", id, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queue")
@@ -1504,20 +1540,26 @@ func (h *LibraryHandler) HandleGetMetadataMatchQueue(w http.ResponseWriter, r *h
}
for _, entry := range movies {
resp.Movies = append(resp.Movies, libraryMovieMatchQueueEntryResponse{
MediaFileID: entry.MediaFileID,
MediaFolderID: entry.MediaFolderID,
FilePath: entry.FilePath,
FirstQueuedAt: entry.FirstQueuedAt,
AvailableAt: entry.AvailableAt,
LastAttemptedAt: entry.LastAttemptedAt,
AttemptCount: entry.AttemptCount,
LastError: entry.LastError,
UpdatedAt: entry.UpdatedAt,
MediaFileID: entry.MediaFileID,
MediaFolderID: entry.MediaFolderID,
FilePath: entry.FilePath,
FirstQueuedAt: entry.FirstQueuedAt,
AvailableAt: entry.AvailableAt,
LastAttemptedAt: entry.LastAttemptedAt,
AttemptCount: entry.AttemptCount,
LastError: entry.LastError,
State: entry.State,
FailureKind: entry.FailureKind,
FailureDetail: entry.FailureDetail,
DeterministicAttemptCount: entry.DeterministicAttemptCount,
MatcherRevision: entry.MatcherRevision,
ParkedAt: entry.ParkedAt,
UpdatedAt: entry.UpdatedAt,
})
}
}
if h.SeriesMatchQueueRepo != nil {
series, _, err := h.SeriesMatchQueueRepo.ListByFolder(r.Context(), id, limit, 0)
series, _, err := h.SeriesMatchQueueRepo.ListByFolder(r.Context(), id, limit, offset)
if err != nil {
slog.ErrorContext(r.Context(), "metadata queue: failed to list series queue", "component", "api", "library_id", id, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queue")
@@ -1525,19 +1567,25 @@ func (h *LibraryHandler) HandleGetMetadataMatchQueue(w http.ResponseWriter, r *h
}
for _, entry := range series {
resp.Series = append(resp.Series, librarySeriesMatchQueueEntryResponse{
MediaFolderID: entry.MediaFolderID,
ObservedRootPath: entry.ObservedRootPath,
FirstQueuedAt: entry.FirstQueuedAt,
AvailableAt: entry.AvailableAt,
LastAttemptedAt: entry.LastAttemptedAt,
AttemptCount: entry.AttemptCount,
LastError: entry.LastError,
UpdatedAt: entry.UpdatedAt,
MediaFolderID: entry.MediaFolderID,
ObservedRootPath: entry.ObservedRootPath,
FirstQueuedAt: entry.FirstQueuedAt,
AvailableAt: entry.AvailableAt,
LastAttemptedAt: entry.LastAttemptedAt,
AttemptCount: entry.AttemptCount,
LastError: entry.LastError,
State: entry.State,
FailureKind: entry.FailureKind,
FailureDetail: entry.FailureDetail,
DeterministicAttemptCount: entry.DeterministicAttemptCount,
MatcherRevision: entry.MatcherRevision,
ParkedAt: entry.ParkedAt,
UpdatedAt: entry.UpdatedAt,
})
}
}
if h.RawMatchBacklogRepo != nil {
rawFiles, _, err := h.RawMatchBacklogRepo.ListUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode(), limit, 0)
rawFiles, _, err := h.RawMatchBacklogRepo.ListUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode(), limit, offset)
if err != nil {
slog.ErrorContext(r.Context(), "metadata queue: failed to list raw backlog", "component", "api", "library_id", id, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher backlog")
@@ -1590,6 +1638,10 @@ func (h *LibraryHandler) HandleRetryMetadataMatchQueue(w http.ResponseWriter, r
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher")
return
}
if _, err := h.SeriesMatchQueueRepo.RetryNowByFolder(r.Context(), id); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher")
return
}
}
if h.MovieMatchQueueRepo != nil {
if err := h.MovieMatchQueueRepo.SyncForFolder(r.Context(), id); err != nil {
@@ -1597,6 +1649,10 @@ func (h *LibraryHandler) HandleRetryMetadataMatchQueue(w http.ResponseWriter, r
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher")
return
}
if _, err := h.MovieMatchQueueRepo.RetryNowByFolder(r.Context(), id); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher")
return
}
}
rawFileRetried := 0
if h.RawMatchBacklogRepo != nil {
@@ -1693,30 +1749,61 @@ func (h *LibraryHandler) metadataMatchBacklogConfigured() bool {
}
func (h *LibraryHandler) metadataMatchQueueStatus(ctx context.Context, libraryID int) (libraryMetadataMatchQueueStatusResponse, error) {
resp := libraryMetadataMatchQueueStatusResponse{LibraryID: libraryID}
statuses, err := h.metadataMatchQueueStatuses(ctx, []int{libraryID})
if err != nil {
return libraryMetadataMatchQueueStatusResponse{LibraryID: libraryID}, err
}
return statuses[libraryID], nil
}
func (h *LibraryHandler) metadataMatchQueueStatuses(ctx context.Context, libraryIDs []int) (map[int]libraryMetadataMatchQueueStatusResponse, error) {
statuses := make(map[int]libraryMetadataMatchQueueStatusResponse, len(libraryIDs))
for _, libraryID := range libraryIDs {
statuses[libraryID] = libraryMetadataMatchQueueStatusResponse{LibraryID: libraryID}
}
if h.MovieMatchQueueRepo != nil {
count, err := h.MovieMatchQueueRepo.CountByFolder(ctx, libraryID)
counts, err := h.MovieMatchQueueRepo.CountStatesByFolders(ctx, libraryIDs)
if err != nil {
return resp, err
return nil, err
}
for libraryID, count := range counts {
status := statuses[libraryID]
status.MovieCount = count.Pending + count.Parked
status.PendingCount += count.Pending
status.ParkedCount += count.Parked
statuses[libraryID] = status
}
resp.MovieCount = count
}
if h.SeriesMatchQueueRepo != nil {
count, err := h.SeriesMatchQueueRepo.CountByFolder(ctx, libraryID)
counts, err := h.SeriesMatchQueueRepo.CountStatesByFolders(ctx, libraryIDs)
if err != nil {
return resp, err
return nil, err
}
for libraryID, count := range counts {
status := statuses[libraryID]
status.SeriesCount = count.Pending + count.Parked
status.PendingCount += count.Pending
status.ParkedCount += count.Parked
statuses[libraryID] = status
}
resp.SeriesCount = count
}
if h.RawMatchBacklogRepo != nil {
count, err := h.RawMatchBacklogRepo.CountUnmatchedMatchBacklogByFolder(ctx, libraryID, h.rawMatchBacklogMode())
counts, err := h.RawMatchBacklogRepo.CountUnmatchedMatchBacklogByFolders(ctx, libraryIDs, h.rawMatchBacklogMode())
if err != nil {
return resp, err
return nil, err
}
for libraryID, count := range counts {
status := statuses[libraryID]
status.RawFileCount = count
status.PendingCount += count
statuses[libraryID] = status
}
resp.RawFileCount = count
}
resp.TotalCount = resp.MovieCount + resp.SeriesCount + resp.RawFileCount
return resp, nil
for libraryID, status := range statuses {
status.TotalCount = status.MovieCount + status.SeriesCount + status.RawFileCount
statuses[libraryID] = status
}
return statuses, nil
}
func (h *LibraryHandler) rawMatchBacklogMode() scanner.RawMatchBacklogMode {
@@ -2107,10 +2194,24 @@ func (h *LibraryHandler) HandleSetLibraryProviders(w http.ResponseWriter, r *htt
if h.chainCacheInvalidator != nil {
h.chainCacheInvalidator.InvalidateChainCache()
}
h.wakeMetadataMatcher(r.Context(), id)
w.WriteHeader(http.StatusNoContent)
}
func (h *LibraryHandler) wakeMetadataMatcher(ctx context.Context, libraryID int) {
if h.MovieMatchQueueRepo != nil {
if _, err := h.MovieMatchQueueRepo.RetryNowByFolder(ctx, libraryID); err != nil {
slog.WarnContext(ctx, "wake movie metadata matcher", "component", "api", "library_id", libraryID, "error", err)
}
}
if h.SeriesMatchQueueRepo != nil {
if _, err := h.SeriesMatchQueueRepo.RetryNowByFolder(ctx, libraryID); err != nil {
slog.WarnContext(ctx, "wake series metadata matcher", "component", "api", "library_id", libraryID, "error", err)
}
}
}
// seedDefaultChain builds a default provider chain from plugin manifest defaults
// for the given library type. Returns entries for all applicable content levels.
func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType string) []metadata.ChainEntry {
+215 -2
View File
@@ -6,10 +6,13 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/metadata"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanner"
)
// TestLibraryMatchUnmatchedItems_NilPool verifies that the unmatched-items
@@ -234,14 +237,224 @@ func (noopMovieMatchQueue) DeleteByFolder(context.Context, int) (int, error) {
return 0, nil
}
func (noopMovieMatchQueue) CountByFolder(context.Context, int) (int, error) {
return 0, nil
func (noopMovieMatchQueue) CountStatesByFolder(context.Context, int) (int, int, error) {
return 0, 0, nil
}
func (noopMovieMatchQueue) CountStatesByFolders(context.Context, []int) (map[int]metadata.MatchQueueStateCounts, error) {
return map[int]metadata.MatchQueueStateCounts{}, nil
}
func (noopMovieMatchQueue) ListByFolder(context.Context, int, int, int) ([]models.MovieMatchQueueEntry, int, error) {
return nil, 0, nil
}
func (noopMovieMatchQueue) RetryNowByFolder(context.Context, int) (int, error) {
return 0, nil
}
type fakeMovieMatchQueue struct {
pending int
parked int
entries []models.MovieMatchQueueEntry
retryFolders []int
bulkCalls int
}
func (f *fakeMovieMatchQueue) SyncForFolder(context.Context, int) error { return nil }
func (f *fakeMovieMatchQueue) DeleteByFolder(context.Context, int) (int, error) {
return 0, nil
}
func (f *fakeMovieMatchQueue) CountStatesByFolder(context.Context, int) (int, int, error) {
return f.pending, f.parked, nil
}
func (f *fakeMovieMatchQueue) CountStatesByFolders(_ context.Context, folderIDs []int) (map[int]metadata.MatchQueueStateCounts, error) {
f.bulkCalls++
counts := make(map[int]metadata.MatchQueueStateCounts, len(folderIDs))
for _, folderID := range folderIDs {
counts[folderID] = metadata.MatchQueueStateCounts{Pending: f.pending, Parked: f.parked}
}
return counts, nil
}
func (f *fakeMovieMatchQueue) ListByFolder(context.Context, int, int, int) ([]models.MovieMatchQueueEntry, int, error) {
return f.entries, len(f.entries), nil
}
func (f *fakeMovieMatchQueue) RetryNowByFolder(_ context.Context, folderID int) (int, error) {
f.retryFolders = append(f.retryFolders, folderID)
return len(f.retryFolders), nil
}
type fakeSeriesMatchQueue struct {
pending int
parked int
entries []models.SeriesRootMatchQueueEntry
retryFolders []int
bulkCalls int
}
func (f *fakeSeriesMatchQueue) SyncForFolder(context.Context, int) error { return nil }
func (f *fakeSeriesMatchQueue) DeleteByFolder(context.Context, int) (int, error) {
return 0, nil
}
func (f *fakeSeriesMatchQueue) CountStatesByFolder(context.Context, int) (int, int, error) {
return f.pending, f.parked, nil
}
func (f *fakeSeriesMatchQueue) CountStatesByFolders(_ context.Context, folderIDs []int) (map[int]metadata.MatchQueueStateCounts, error) {
f.bulkCalls++
counts := make(map[int]metadata.MatchQueueStateCounts, len(folderIDs))
for _, folderID := range folderIDs {
counts[folderID] = metadata.MatchQueueStateCounts{Pending: f.pending, Parked: f.parked}
}
return counts, nil
}
func (f *fakeSeriesMatchQueue) ListByFolder(context.Context, int, int, int) ([]models.SeriesRootMatchQueueEntry, int, error) {
return f.entries, len(f.entries), nil
}
func (f *fakeSeriesMatchQueue) RetryNowByFolder(_ context.Context, folderID int) (int, error) {
f.retryFolders = append(f.retryFolders, folderID)
return len(f.retryFolders), nil
}
type fakeRawMatchBacklog struct {
count int
bulkCalls int
}
func (f *fakeRawMatchBacklog) CountUnmatchedMatchBacklogByFolder(context.Context, int, scanner.RawMatchBacklogMode) (int, error) {
return f.count, nil
}
func (f *fakeRawMatchBacklog) CountUnmatchedMatchBacklogByFolders(_ context.Context, folderIDs []int, _ scanner.RawMatchBacklogMode) (map[int]int, error) {
f.bulkCalls++
counts := make(map[int]int, len(folderIDs))
for _, folderID := range folderIDs {
counts[folderID] = f.count
}
return counts, nil
}
func (f *fakeRawMatchBacklog) ListUnmatchedMatchBacklogByFolder(context.Context, int, scanner.RawMatchBacklogMode, int, int) ([]*models.MediaFile, int, error) {
return nil, 0, nil
}
func (f *fakeRawMatchBacklog) SuppressUnmatchedMatchBacklogByFolder(context.Context, int, scanner.RawMatchBacklogMode) (int, error) {
return 0, nil
}
func (f *fakeRawMatchBacklog) RetryUnmatchedMatchBacklogByFolder(context.Context, int, scanner.RawMatchBacklogMode) (int, error) {
return 0, nil
}
// TestMetadataMatchQueueStatus_AggregatesStates verifies the pending/parked
// aggregation rules the admin UI depends on: parked rows stay out of
// pending_count, raw-backlog files count as pending, and the per-queue totals
// still equal pending + parked so movie_count/series_count semantics are
// unchanged from the pre-state API.
func TestMetadataMatchQueueStatus_AggregatesStates(t *testing.T) {
h := &LibraryHandler{
MovieMatchQueueRepo: &fakeMovieMatchQueue{pending: 2, parked: 1},
SeriesMatchQueueRepo: &fakeSeriesMatchQueue{pending: 3, parked: 2},
RawMatchBacklogRepo: &fakeRawMatchBacklog{count: 4},
}
status, err := h.metadataMatchQueueStatus(context.Background(), 7)
if err != nil {
t.Fatalf("metadataMatchQueueStatus() error = %v", err)
}
if status.LibraryID != 7 {
t.Errorf("LibraryID = %d, want 7", status.LibraryID)
}
if status.MovieCount != 3 || status.SeriesCount != 5 || status.RawFileCount != 4 {
t.Errorf("per-queue counts = %d/%d/%d, want 3/5/4", status.MovieCount, status.SeriesCount, status.RawFileCount)
}
if status.PendingCount != 9 {
t.Errorf("PendingCount = %d, want 9 (2+3 pending + 4 raw)", status.PendingCount)
}
if status.ParkedCount != 3 {
t.Errorf("ParkedCount = %d, want 3", status.ParkedCount)
}
if status.TotalCount != 12 {
t.Errorf("TotalCount = %d, want 12", status.TotalCount)
}
}
func TestMetadataMatchQueueStatusesUsesOneAggregateCallPerQueue(t *testing.T) {
movie := &fakeMovieMatchQueue{pending: 1}
series := &fakeSeriesMatchQueue{parked: 1}
raw := &fakeRawMatchBacklog{count: 1}
h := &LibraryHandler{
MovieMatchQueueRepo: movie,
SeriesMatchQueueRepo: series,
RawMatchBacklogRepo: raw,
}
statuses, err := h.metadataMatchQueueStatuses(context.Background(), []int{3, 7, 11})
if err != nil {
t.Fatalf("metadataMatchQueueStatuses() error = %v", err)
}
if len(statuses) != 3 {
t.Fatalf("status count = %d, want 3", len(statuses))
}
if movie.bulkCalls != 1 || series.bulkCalls != 1 || raw.bulkCalls != 1 {
t.Fatalf("bulk calls = movie:%d series:%d raw:%d, want one each", movie.bulkCalls, series.bulkCalls, raw.bulkCalls)
}
}
// TestWakeMetadataMatcher_RetriesBothQueues verifies the provider-chain-change
// wake path resets parked work in both queue repos for the changed library.
func TestWakeMetadataMatcher_RetriesBothQueues(t *testing.T) {
movie := &fakeMovieMatchQueue{}
series := &fakeSeriesMatchQueue{}
h := &LibraryHandler{MovieMatchQueueRepo: movie, SeriesMatchQueueRepo: series}
h.wakeMetadataMatcher(context.Background(), 42)
if len(movie.retryFolders) != 1 || movie.retryFolders[0] != 42 {
t.Errorf("movie retry folders = %v, want [42]", movie.retryFolders)
}
if len(series.retryFolders) != 1 || series.retryFolders[0] != 42 {
t.Errorf("series retry folders = %v, want [42]", series.retryFolders)
}
}
// TestMovieMatchQueueEntryResponse_SerializesStateFields pins the JSON contract
// for the queue-state fields the admin UI reads. Additive-only API rule: these
// names must never change once shipped.
func TestMovieMatchQueueEntryResponse_SerializesStateFields(t *testing.T) {
parkedAt := time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)
entry := libraryMovieMatchQueueEntryResponse{
MediaFileID: 11,
MediaFolderID: 7,
FilePath: "/movies/a.mkv",
State: "parked",
FailureKind: "candidate_rejected",
FailureDetail: json.RawMessage(`{"message":"below threshold"}`),
DeterministicAttemptCount: 3,
MatcherRevision: 8,
ParkedAt: &parkedAt,
}
data, err := json.Marshal(entry)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for key, want := range map[string]any{
"state": "parked", //nolint:goconst // JSON contract key.
"failure_kind": "candidate_rejected",
"deterministic_attempt_count": float64(3),
"matcher_revision": float64(8),
} {
if m[key] != want {
t.Errorf("JSON %q = %v, want %v", key, m[key], want)
}
}
if _, ok := m["parked_at"]; !ok {
t.Error("expected parked_at in JSON output")
}
if _, ok := m["failure_detail"]; !ok {
t.Error("expected failure_detail in JSON output")
}
}
func keys(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
@@ -42,3 +42,23 @@ func (c *MatchQueueCoordinator) EnqueueSeriesRoot(ctx context.Context, folderID
}
return c.seriesRepo.EnqueueSeriesRoot(ctx, folderID, observedRootPath)
}
// WakeForChangedInputs recalculates queue fingerprints after a provider
// lifecycle event. Only work whose effective provider chain (or another
// fingerprint input) changed is reset, including pending rows in backoff.
func (c *MatchQueueCoordinator) WakeForChangedInputs(ctx context.Context) error {
if c == nil {
return nil
}
if c.movieRepo != nil {
if _, err := c.movieRepo.WakeForChangedInputs(ctx); err != nil {
return err
}
}
if c.seriesRepo != nil {
if _, err := c.seriesRepo.WakeForChangedInputs(ctx); err != nil {
return err
}
}
return nil
}
+178
View File
@@ -0,0 +1,178 @@
package metadata
import (
"fmt"
"regexp"
"sort"
"strings"
"time"
)
var (
matchFailureSecretPattern = regexp.MustCompile(`(?i)("?(?:api[_-]?key|access[_-]?token|token|authorization|password)"?)\s*[:=]\s*"?([^"&,;\s}]+)"?`)
matchFailureBearerPattern = regexp.MustCompile(`(?i)\bbearer\s+[^&,;\s]+`)
matchFailureSecretKey = regexp.MustCompile(`(?i)^(?:api[_-]?key|access[_-]?token|token|authorization|password)$`)
)
const (
matcherRevision = 9
movieQueueRetryDelay = 15 * time.Second
seriesRootQueueQuietWindow = 10 * time.Second
seriesRootQueueRetryDelay = 30 * time.Second
// A claim contains at most one job per available worker. Keep the lease long
// enough that a second server does not reclaim a healthy provider call; a
// crashed process can strand only that worker-sized in-flight window.
// Workers explicitly release unfinished leases on healthy cancellation.
matchQueueClaimLease = 2 * time.Hour
// Transient provider failures use capped exponential backoff. Deterministic
// outcomes use the fixed one-hour/24-hour/park schedule.
matchQueueRetryMaxDelay = 24 * time.Hour
matchQueueBackoffMaxExponent = 16
)
// MatchQueueStateCounts is the pending/parked aggregate for one library.
type MatchQueueStateCounts struct {
Pending int
Parked int
}
// matchQueueBackoffExpr returns the SQL expression both match queues use to
// schedule the next transient retry. attempt_count is incremented at claim.
func matchQueueBackoffExpr(basePlaceholder, maxPlaceholder string) string {
return fmt.Sprintf(
"NOW() + LEAST(%s::interval * power(2::float8, LEAST(attempt_count, %d)), %s::interval)",
basePlaceholder, matchQueueBackoffMaxExponent, maxPlaceholder,
)
}
// matchQueueInputFingerprintSQL returns a deterministic SQL expression for
// inputs that can change a result without changing the queue key. Arguments
// are internal SQL expressions selected by repository code, never user input.
func matchQueueInputFingerprintSQL(pathExpression, typeExpression, folderIDExpression, languageExpression string) string {
return fmt.Sprintf(`md5(%s || '|' || COALESCE(%s, '') || '|' || COALESCE(%s, '') || '|%d|' || COALESCE((
SELECT string_agg(
chain.priority::text || ':' || installation.id::text || ':' || installation.plugin_id || ':' ||
chain.capability_id || ':' || chain.content_level || ':' ||
chain.enabled::text || ':' || installation.enabled::text || ':' || installation.version || ':' ||
COALESCE((
SELECT string_agg(config.config_key || ':' || config.updated_at::text, ',' ORDER BY config.config_key)
FROM plugin_runtime_configs config
WHERE config.plugin_installation_id = installation.id
), ''),
'|' ORDER BY chain.priority, installation.id, chain.capability_id, chain.content_level
)
FROM library_provider_chains chain
JOIN plugin_installations installation ON installation.id = chain.plugin_installation_id
WHERE chain.media_folder_id = %s
AND chain.capability_type = 'metadata_provider.v1'
), ''))`, pathExpression, typeExpression, languageExpression, matcherRevision, folderIDExpression)
}
// seriesMatchQueueInputFingerprintSQL includes the active file-path set because
// episode validation derives both coordinates and episode-title evidence from
// those paths. Adding, removing, or renaming an episode wakes a parked match.
func seriesMatchQueueInputFingerprintSQL(rootExpression, folderIDExpression, languageExpression string) string {
shapeExpression := fmt.Sprintf(`(COALESCE(%s, '') || '|shape:' || COALESCE((
SELECT md5(string_agg(
md5(shape_file.file_path), '' ORDER BY shape_file.file_path
))
FROM media_files shape_file
WHERE shape_file.media_folder_id = %s
AND shape_file.observed_root_path = %s
AND shape_file.missing_since IS NULL
AND shape_file.extra_id IS NULL
), ''))`, rootExpression, folderIDExpression, rootExpression)
return matchQueueInputFingerprintSQL(shapeExpression, "'series'", folderIDExpression, languageExpression)
}
func boundedMatchFailureMessage(message string) string {
message = matchFailureBearerPattern.ReplaceAllString(strings.TrimSpace(message), "Bearer [redacted]")
message = matchFailureSecretPattern.ReplaceAllString(message, "$1=[redacted]")
runes := []rune(message)
if len(runes) > 1000 {
message = string(runes[:1000])
}
return message
}
func truncateMatchFailureField(value string, limit int) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) > limit {
runes = runes[:limit]
}
return string(runes)
}
func normalizeMatchFailureKind(kind MatchOutcome) MatchOutcome {
normalized := MatchOutcome(strings.ToLower(strings.TrimSpace(string(kind))))
switch normalized {
case MatchOutcomeNoCandidates,
MatchOutcomeCandidateRejected,
MatchOutcomeTrustedIDConflict,
MatchOutcomeTrustedIDTypeMismatch,
MatchOutcomeMetadataEmpty,
MatchOutcomeProviderTransient,
MatchOutcomeProviderPermanent:
return normalized
default:
return MatchOutcomeProviderTransient
}
}
// boundedMatchDecision makes the persistence boundary explicit. Provider IDs,
// titles, and reasons must not be able to grow queue rows without limit.
func boundedMatchDecision(decision *MatchDecision) *MatchDecision {
if decision == nil {
return nil
}
bounded := &MatchDecision{
Outcome: MatchOutcome(truncateMatchFailureField(string(decision.Outcome), 64)),
CandidateCount: decision.CandidateCount,
Threshold: decision.Threshold,
}
for _, candidate := range decision.TopCandidates {
if len(bounded.TopCandidates) == 3 {
break
}
copyCandidate := MatchDecisionCandidate{
Title: truncateMatchFailureField(candidate.Title, 256),
MatchedTitle: truncateMatchFailureField(candidate.MatchedTitle, 256),
Year: candidate.Year,
Score: candidate.Score,
}
if len(candidate.ProviderIDs) > 0 {
copyCandidate.ProviderIDs = make(map[string]string, min(len(candidate.ProviderIDs), 8))
keys := make([]string, 0, len(candidate.ProviderIDs))
for key := range candidate.ProviderIDs {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if len(copyCandidate.ProviderIDs) == 8 {
break
}
if matchFailureSecretKey.MatchString(strings.TrimSpace(key)) {
continue
}
value := candidate.ProviderIDs[key]
copyCandidate.ProviderIDs[truncateMatchFailureField(key, 64)] = truncateMatchFailureField(value, 256)
}
}
for _, source := range candidate.Sources {
if len(copyCandidate.Sources) == 8 {
break
}
copyCandidate.Sources = append(copyCandidate.Sources, truncateMatchFailureField(source, 64))
}
for _, reason := range candidate.Reasons {
if len(copyCandidate.Reasons) == 8 {
break
}
copyCandidate.Reasons = append(copyCandidate.Reasons, truncateMatchFailureField(reason, 128))
}
bounded.TopCandidates = append(bounded.TopCandidates, copyCandidate)
}
return bounded
}
@@ -0,0 +1,567 @@
package metadata
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
scannerrepo "github.com/Silo-Server/silo-server/internal/scanner"
)
const queueStatePending = "pending"
func TestBoundedMatchFailureMessageRedactsAndLimits(t *testing.T) {
t.Parallel()
message := "provider failed?api_key=secret&token=also-secret " + strings.Repeat("x", 1200)
got := boundedMatchFailureMessage(message)
if strings.Contains(got, "secret") {
t.Fatalf("failure message leaked a secret: %q", got)
}
if len([]rune(got)) != 1000 {
t.Fatalf("failure message length = %d, want 1000", len([]rune(got)))
}
}
func TestBoundedMatchFailureMessageRedactsHeaderAndBearerForms(t *testing.T) {
t.Parallel()
got := boundedMatchFailureMessage("Authorization: Bearer secret-token; password: hunter2")
for _, secret := range []string{"secret-token", "hunter2"} {
if strings.Contains(got, secret) {
t.Fatalf("failure message leaked %q: %q", secret, got)
}
}
}
func TestBoundedMatchDecisionLimitsProviderControlledFields(t *testing.T) {
t.Parallel()
decision := &MatchDecision{Outcome: MatchOutcome(strings.Repeat("x", 100)), CandidateCount: 99, Threshold: 55}
for i := 0; i < 5; i++ {
candidate := MatchDecisionCandidate{
Title: strings.Repeat("t", 400), MatchedTitle: strings.Repeat("m", 400),
ProviderIDs: make(map[string]string), Score: 42,
Sources: make([]string, 12), Reasons: make([]string, 12),
}
for j := 0; j < 12; j++ {
candidate.ProviderIDs[fmt.Sprintf("provider-%02d", j)] = strings.Repeat("i", 400)
candidate.Sources[j] = strings.Repeat("s", 100)
candidate.Reasons[j] = strings.Repeat("r", 200)
}
candidate.ProviderIDs["api_key"] = "must-not-persist"
decision.TopCandidates = append(decision.TopCandidates, candidate)
}
got := boundedMatchDecision(decision)
if len(got.TopCandidates) != 3 || len(got.TopCandidates[0].ProviderIDs) != 8 || len(got.TopCandidates[0].Sources) != 8 || len(got.TopCandidates[0].Reasons) != 8 {
t.Fatalf("bounded decision sizes = candidates:%d ids:%d sources:%d reasons:%d", len(got.TopCandidates), len(got.TopCandidates[0].ProviderIDs), len(got.TopCandidates[0].Sources), len(got.TopCandidates[0].Reasons))
}
if len([]rune(got.TopCandidates[0].Title)) != 256 || len([]rune(got.TopCandidates[0].MatchedTitle)) != 256 {
t.Fatalf("bounded title lengths = %d/%d", len([]rune(got.TopCandidates[0].Title)), len([]rune(got.TopCandidates[0].MatchedTitle)))
}
if _, exists := got.TopCandidates[0].ProviderIDs["api_key"]; exists {
t.Fatal("bounded decision retained a credential-shaped provider ID")
}
}
func TestNormalizeMatchFailureKindTreatsUnknownAsTransient(t *testing.T) {
t.Parallel()
if got := normalizeMatchFailureKind(MatchOutcomeCandidateRejected); got != MatchOutcomeCandidateRejected {
t.Fatalf("known failure kind = %q", got)
}
if got := normalizeMatchFailureKind(MatchOutcome("unexpected-" + strings.Repeat("x", 500))); got != MatchOutcomeProviderTransient {
t.Fatalf("unknown failure kind = %q, want provider_transient", got)
}
}
func TestMatchQueueFingerprintIncludesMatcherAndProviderConfiguration(t *testing.T) {
t.Parallel()
expression := matchQueueInputFingerprintSQL("mf.file_path", "'movie'", "mf.media_folder_id", "folders.metadata_language")
for _, required := range []string{
"mf.file_path", "'movie'", "folders.metadata_language", "installation.version",
"chain.priority", "chain.capability_id", "plugin_runtime_configs", "config.updated_at::text",
fmt.Sprintf("|%d|", matcherRevision),
} {
if !strings.Contains(expression, required) {
t.Fatalf("fingerprint expression %q does not contain %q", expression, required)
}
}
}
func TestSeriesMatchQueueFingerprintIncludesEpisodePathShape(t *testing.T) {
t.Parallel()
expression := seriesMatchQueueInputFingerprintSQL("q.observed_root_path", "q.media_folder_id", "folders.metadata_language")
for _, required := range []string{"shape_file.file_path", "shape_file.observed_root_path", "shape_file.missing_since", "shape_file.extra_id"} {
if !strings.Contains(expression, required) {
t.Fatalf("series fingerprint expression %q does not contain %q", expression, required)
}
}
}
func TestSeriesMatchQueueDeterministicFailuresParkAndRetryNowResets(t *testing.T) {
pool := chainBuiltinTestPool(t)
ctx := context.Background()
folderID := insertTestFolder(t, pool, "series")
root := fmt.Sprintf("/test/match-queue-%d", time.Now().UnixNano())
if _, err := pool.Exec(ctx, `
INSERT INTO series_root_match_queue (media_folder_id, observed_root_path, available_at)
VALUES ($1, $2, NOW())`, folderID, root); err != nil {
t.Fatalf("seed series match queue: %v", err)
}
repo := NewSeriesRootMatchQueueRepository(pool)
for attempt, wantDelay := range []time.Duration{time.Hour, 24 * time.Hour, 24 * time.Hour} {
leaseToken := fmt.Sprintf("deterministic-lease-%d", attempt)
if _, err := pool.Exec(ctx, `UPDATE series_root_match_queue SET lease_token = $3 WHERE media_folder_id = $1 AND observed_root_path = $2`, folderID, root, leaseToken); err != nil {
t.Fatalf("seed lease token: %v", err)
}
before := time.Now()
if err := repo.UpdateFailure(ctx, folderID, root, leaseToken, MatchFailure{Kind: MatchOutcomeCandidateRejected, Message: "score below threshold"}); err != nil {
t.Fatalf("UpdateFailure(%d): %v", attempt+1, err)
}
var state string
var deterministicCount int
var availableAt time.Time
var parkedAt *time.Time
if err := pool.QueryRow(ctx, `
SELECT state, deterministic_attempt_count, available_at, parked_at
FROM series_root_match_queue WHERE media_folder_id = $1 AND observed_root_path = $2`,
folderID, root).Scan(&state, &deterministicCount, &availableAt, &parkedAt); err != nil {
t.Fatalf("load queue state: %v", err)
}
if deterministicCount != attempt+1 {
t.Fatalf("deterministic count = %d, want %d", deterministicCount, attempt+1)
}
wantState := queueStatePending
if attempt == 2 {
wantState = "parked"
}
if state != wantState {
t.Fatalf("state = %q, want %q", state, wantState)
}
if attempt == 2 && parkedAt == nil {
t.Fatal("parked_at is nil after third deterministic failure")
}
if availableAt.Before(before.Add(wantDelay - time.Minute)) {
t.Fatalf("available_at = %v, want approximately %v later", availableAt, wantDelay)
}
}
if _, err := repo.RetryNowByFolder(ctx, folderID); err != nil {
t.Fatalf("RetryNowByFolder(): %v", err)
}
var state, failureKind string
var deterministicCount int
var availableAt time.Time
var parkedAt *time.Time
if err := pool.QueryRow(ctx, `
SELECT state, failure_kind, deterministic_attempt_count, available_at, parked_at
FROM series_root_match_queue WHERE media_folder_id = $1 AND observed_root_path = $2`,
folderID, root).Scan(&state, &failureKind, &deterministicCount, &availableAt, &parkedAt); err != nil {
t.Fatalf("load retried queue state: %v", err)
}
if state != queueStatePending || failureKind != "" || deterministicCount != 0 || parkedAt != nil {
t.Fatalf("retry state = (%q, %q, %d, %v)", state, failureKind, deterministicCount, parkedAt)
}
if availableAt.After(time.Now().Add(time.Minute)) {
t.Fatalf("RetryNow left available_at in the future: %v", availableAt)
}
}
func TestSeriesMatchQueueTransientFailureDoesNotConsumeDeterministicBudget(t *testing.T) {
pool := chainBuiltinTestPool(t)
ctx := context.Background()
folderID := insertTestFolder(t, pool, "series")
root := fmt.Sprintf("/test/match-queue-transient-%d", time.Now().UnixNano())
if _, err := pool.Exec(ctx, `
INSERT INTO series_root_match_queue (media_folder_id, observed_root_path, available_at)
VALUES ($1, $2, NOW())`, folderID, root); err != nil {
t.Fatalf("seed series match queue: %v", err)
}
repo := NewSeriesRootMatchQueueRepository(pool)
const leaseToken = "transient-lease"
if _, err := pool.Exec(ctx, `UPDATE series_root_match_queue SET lease_token = $3 WHERE media_folder_id = $1 AND observed_root_path = $2`, folderID, root, leaseToken); err != nil {
t.Fatalf("seed lease token: %v", err)
}
if err := repo.UpdateFailure(ctx, folderID, root, leaseToken, MatchFailure{Kind: MatchOutcomeProviderTransient, Message: "HTTP 429"}); err != nil {
t.Fatalf("UpdateFailure(): %v", err)
}
var state string
var deterministicCount int
if err := pool.QueryRow(ctx, `
SELECT state, deterministic_attempt_count FROM series_root_match_queue
WHERE media_folder_id = $1 AND observed_root_path = $2`, folderID, root).Scan(&state, &deterministicCount); err != nil {
t.Fatalf("load transient queue state: %v", err)
}
if state != queueStatePending || deterministicCount != 0 {
t.Fatalf("transient state = (%q, %d), want pending with zero deterministic attempts", state, deterministicCount)
}
}
func TestSeriesMatchQueueWakeForChangedInputsResetsOnlyChangedRows(t *testing.T) {
pool := chainBuiltinTestPool(t)
ctx := context.Background()
folderID := insertTestFolder(t, pool, "series")
root := fmt.Sprintf("/test/match-input-wake-%d", time.Now().UnixNano())
if _, err := pool.Exec(ctx, `
INSERT INTO series_root_match_queue (
media_folder_id, observed_root_path, available_at, state,
failure_kind, failure_detail, deterministic_attempt_count,
input_fingerprint, matcher_revision, parked_at, last_error
) VALUES ($1, $2, NOW() + interval '24 hours', 'parked',
'candidate_rejected', '{"message":"old"}'::jsonb, 3,
'old-fingerprint', 0, NOW(), 'old')
`, folderID, root); err != nil {
t.Fatalf("seed changed-input queue row: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM series_root_match_queue WHERE media_folder_id = $1 AND observed_root_path = $2`, folderID, root)
})
repo := NewSeriesRootMatchQueueRepository(pool)
const staleLeaseToken = "series-stale-lease"
if _, err := pool.Exec(ctx, `
UPDATE series_root_match_queue SET lease_token = $3
WHERE media_folder_id = $1 AND observed_root_path = $2
`, folderID, root, staleLeaseToken); err != nil {
t.Fatalf("seed active lease: %v", err)
}
woken, err := repo.WakeForChangedInputs(ctx)
if err != nil {
t.Fatalf("WakeForChangedInputs(): %v", err)
}
if woken < 1 {
t.Fatalf("woken = %d, want at least seeded row", woken)
}
var state, failureKind, lastError, fingerprint, leaseToken string
var deterministicCount, revision int
var availableAt time.Time
var parkedAt *time.Time
var rerunRequested bool
if err := pool.QueryRow(ctx, `
SELECT state, failure_kind, last_error, deterministic_attempt_count,
input_fingerprint, matcher_revision, available_at, parked_at,
lease_token, rerun_requested
FROM series_root_match_queue
WHERE media_folder_id = $1 AND observed_root_path = $2
`, folderID, root).Scan(&state, &failureKind, &lastError, &deterministicCount, &fingerprint, &revision, &availableAt, &parkedAt, &leaseToken, &rerunRequested); err != nil {
t.Fatalf("load woken row: %v", err)
}
if state != queueStatePending || failureKind != "" || lastError != "" || deterministicCount != 0 || fingerprint == "" || fingerprint == "old-fingerprint" || revision != matcherRevision || parkedAt != nil {
t.Fatalf("woken row = state:%q failure:%q last:%q deterministic:%d fingerprint:%q revision:%d available:%v parked:%v", state, failureKind, lastError, deterministicCount, fingerprint, revision, availableAt, parkedAt)
}
if leaseToken != staleLeaseToken || !rerunRequested || availableAt.Before(time.Now().Add(time.Hour)) {
t.Fatalf("woken lease ownership was not preserved: rerun:%v available:%v", rerunRequested, availableAt)
}
if woken, err := repo.WakeForChangedInputs(ctx); err != nil || woken != 0 {
t.Fatalf("unchanged WakeForChangedInputs() = (%d, %v), want (0, nil)", woken, err)
}
if err := repo.UpdateFailure(ctx, folderID, root, staleLeaseToken, MatchFailure{
Kind: MatchOutcomeCandidateRejected, Message: "stale worker result",
}); err != nil {
t.Fatalf("stale UpdateFailure(): %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT failure_kind FROM series_root_match_queue
WHERE media_folder_id = $1 AND observed_root_path = $2
`, folderID, root).Scan(&failureKind); err != nil {
t.Fatalf("load row after stale series update: %v", err)
}
if failureKind != "" {
t.Fatalf("stale series worker overwrote awakened row with failure %q", failureKind)
}
if err := pool.QueryRow(ctx, `
SELECT lease_token, rerun_requested, available_at
FROM series_root_match_queue
WHERE media_folder_id = $1 AND observed_root_path = $2
`, folderID, root).Scan(&leaseToken, &rerunRequested, &availableAt); err != nil {
t.Fatalf("load released series rerun: %v", err)
}
if leaseToken != "" || !rerunRequested || availableAt.After(time.Now().Add(time.Minute)) {
t.Fatalf("released rerun retained lease ownership: rerun:%v available:%v", rerunRequested, availableAt)
}
if _, err := pool.Exec(ctx, `UPDATE media_folders SET metadata_language = 'da' WHERE id = $1`, folderID); err != nil {
t.Fatalf("change folder language: %v", err)
}
if _, err := pool.Exec(ctx, `
UPDATE series_root_match_queue
SET available_at = NOW() + interval '24 hours', deterministic_attempt_count = 2,
failure_kind = 'candidate_rejected', last_error = 'old'
WHERE media_folder_id = $1 AND observed_root_path = $2
`, folderID, root); err != nil {
t.Fatalf("back off queue row before language change wake: %v", err)
}
if woken, err := repo.WakeForChangedInputs(ctx); err != nil || woken < 1 {
t.Fatalf("language-change WakeForChangedInputs() = (%d, %v), want seeded row", woken, err)
}
installationID := insertTestInstallation(t, pool, "plugin", true)
insertTestCapability(t, pool, installationID, "config-fingerprint", `{}`)
if _, err := pool.Exec(ctx, `
INSERT INTO library_provider_chains (
media_folder_id, plugin_installation_id, capability_id, capability_type,
content_level, priority, enabled
) VALUES ($1, $2, 'config-fingerprint', 'metadata_provider.v1', 'item', 1, true)
`, folderID, installationID); err != nil {
t.Fatalf("seed relevant provider chain: %v", err)
}
if woken, err := repo.WakeForChangedInputs(ctx); err != nil || woken < 1 {
t.Fatalf("provider-chain WakeForChangedInputs() = (%d, %v), want seeded row", woken, err)
}
if woken, err := repo.WakeForChangedInputs(ctx); err != nil || woken != 0 {
t.Fatalf("stable provider chain wake = (%d, %v), want (0, nil)", woken, err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_files (
media_folder_id, file_path, observed_root_path, base_type,
season_number, episode_number, file_size
) VALUES ($1, $2, $3, 'series', 1, 8, 0)
`, folderID, root+"/Season 01/Show S01E08.mkv", root); err != nil {
t.Fatalf("add episode path to series shape: %v", err)
}
if woken, err := repo.WakeForChangedInputs(ctx); err != nil || woken < 1 {
t.Fatalf("episode-shape WakeForChangedInputs() = (%d, %v), want seeded row", woken, err)
}
if woken, err := repo.WakeForChangedInputs(ctx); err != nil || woken != 0 {
t.Fatalf("stable episode shape wake = (%d, %v), want (0, nil)", woken, err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO plugin_runtime_configs (plugin_installation_id, config_key, config_value)
VALUES ($1, 'metadata', '{"api_key":"changed-but-never-persisted-in-the-queue"}'::jsonb)
`, installationID); err != nil {
t.Fatalf("change relevant provider config: %v", err)
}
if woken, err := repo.WakeForChangedInputs(ctx); err != nil || woken < 1 {
t.Fatalf("provider-config WakeForChangedInputs() = (%d, %v), want seeded row", woken, err)
}
if err := pool.QueryRow(ctx, `
SELECT input_fingerprint FROM series_root_match_queue
WHERE media_folder_id = $1 AND observed_root_path = $2
`, folderID, root).Scan(&fingerprint); err != nil {
t.Fatalf("load provider-config fingerprint: %v", err)
}
if strings.Contains(fingerprint, "api_key") || strings.Contains(fingerprint, "changed-but-never") {
t.Fatalf("queue fingerprint leaked provider configuration: %q", fingerprint)
}
}
func TestMovieMatchQueueRetryDuringLeaseQueuesFencedRerun(t *testing.T) {
pool := chainBuiltinTestPool(t)
ctx := context.Background()
folderID := insertTestFolder(t, pool, "movie")
root := fmt.Sprintf("/test/claim-lease-%d", time.Now().UnixNano())
var fileID int
if err := pool.QueryRow(ctx, `
INSERT INTO media_files (media_folder_id, file_path, base_type, file_size)
VALUES ($1, $2, 'movie', 0) RETURNING id
`, folderID, root+"/Movie.mkv").Scan(&fileID); err != nil {
t.Fatalf("seed movie file: %v", err)
}
repo := NewMovieMatchQueueRepository(pool, scannerrepo.NewFileRepository(pool))
if err := repo.EnqueueMovieFile(ctx, fileID); err != nil {
t.Fatalf("EnqueueMovieFile(): %v", err)
}
claimTestMovie := func() ([]models.MovieMatchJob, error) {
return repo.ClaimByFolderAndPathPrefix(ctx, folderID, root, 1, time.Time{})
}
claimed, err := claimTestMovie()
if err != nil || len(claimed) != 1 || claimed[0].File == nil || claimed[0].File.ID != fileID || claimed[0].LeaseToken == "" {
t.Fatalf("first Claim() = (%#v, %v), want file %d", claimed, err, fileID)
}
claimedAgain, err := claimTestMovie()
if err != nil || len(claimedAgain) != 0 {
t.Fatalf("second Claim() during lease = (%#v, %v), want empty", claimedAgain, err)
}
var leasedUntil time.Time
if err := pool.QueryRow(ctx, `SELECT available_at FROM movie_match_queue WHERE media_file_id = $1`, fileID).Scan(&leasedUntil); err != nil {
t.Fatalf("load claim lease: %v", err)
}
if leasedUntil.Before(time.Now().Add(time.Hour)) {
t.Fatalf("claim lease = %v, want comfortably beyond one hour", leasedUntil)
}
if affected, err := repo.RetryNowByFolder(ctx, folderID); err != nil || affected != 1 {
t.Fatalf("RetryNowByFolder() = (%d, %v), want (1, nil)", affected, err)
}
var state, leaseToken string
var availableAt time.Time
var rerunRequested bool
if err := pool.QueryRow(ctx, `
SELECT state, available_at, lease_token, rerun_requested
FROM movie_match_queue
WHERE media_file_id = $1
`, fileID).Scan(&state, &availableAt, &leaseToken, &rerunRequested); err != nil {
t.Fatalf("load retried movie row: %v", err)
}
if state != queueStatePending || availableAt.Before(time.Now().Add(time.Hour)) {
t.Fatalf("retried movie row = state %q available %v, want active lease preserved", state, availableAt)
}
if leaseToken != claimed[0].LeaseToken || !rerunRequested {
t.Fatalf("retried movie ownership was not preserved: rerun %v", rerunRequested)
}
if reclaimed, err := claimTestMovie(); err != nil || len(reclaimed) != 0 {
t.Fatalf("Claim() while original worker runs = (%#v, %v), want empty", reclaimed, err)
}
if err := repo.Delete(ctx, fileID, claimed[0].LeaseToken); err != nil {
t.Fatalf("original leased completion: %v", err)
}
var remaining int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM movie_match_queue WHERE media_file_id = $1`, fileID).Scan(&remaining); err != nil {
t.Fatalf("count retried movie row: %v", err)
}
if remaining != 1 {
t.Fatalf("original completion deleted requested rerun; remaining = %d", remaining)
}
if err := repo.UpdateFailure(ctx, fileID, claimed[0].LeaseToken, MatchFailure{
Kind: MatchOutcomeCandidateRejected, Message: "stale worker result",
}); err != nil {
t.Fatalf("stale leased failure: %v", err)
}
var failureKind string
if err := pool.QueryRow(ctx, `SELECT failure_kind FROM movie_match_queue WHERE media_file_id = $1`, fileID).Scan(&failureKind); err != nil {
t.Fatalf("load retried movie failure kind: %v", err)
}
if failureKind != "" {
t.Fatalf("stale lease overwrote newly awakened row with failure %q", failureKind)
}
reclaimed, err := claimTestMovie()
if err != nil || len(reclaimed) != 1 {
t.Fatalf("Claim() after original completion = (%#v, %v), want one row", reclaimed, err)
}
if !reclaimed[0].RerunRequested {
t.Fatal("reclaimed job did not carry the forced-rerun marker")
}
if _, err := pool.Exec(ctx, `
UPDATE movie_match_queue SET available_at = NOW()
WHERE media_file_id = $1
`, fileID); err != nil {
t.Fatalf("expire forced rerun lease: %v", err)
}
expiredReplacement, err := claimTestMovie()
if err != nil || len(expiredReplacement) != 1 {
t.Fatalf("Claim() after forced lease expiry = (%#v, %v), want one row", expiredReplacement, err)
}
if !expiredReplacement[0].RerunRequested {
t.Fatal("expired forced rerun lost its durable intent")
}
if expiredReplacement[0].LeaseToken == reclaimed[0].LeaseToken {
t.Fatal("expired forced rerun was not assigned fresh ownership")
}
if affected, err := repo.ReleaseLease(ctx, expiredReplacement[0].LeaseToken); err != nil || affected != 1 {
t.Fatalf("ReleaseLease() = (%d, %v), want (1, nil)", affected, err)
}
reclaimedAgain, err := claimTestMovie()
if err != nil || len(reclaimedAgain) != 1 {
t.Fatalf("Claim() after ReleaseLease = (%#v, %v), want one immediately claimable row", reclaimedAgain, err)
}
if reclaimedAgain[0].LeaseToken == expiredReplacement[0].LeaseToken {
t.Fatal("released claim was not assigned a fresh ownership token")
}
if !reclaimedAgain[0].RerunRequested {
t.Fatal("released forced rerun lost its durable intent")
}
if err := repo.UpdateFailure(ctx, fileID, reclaimedAgain[0].LeaseToken, MatchFailure{
Kind: "provider_transient", Message: "temporary provider outage",
}); err != nil {
t.Fatalf("forced rerun failure: %v", err)
}
var rerunAfterFailure, leaseRerunAfterFailure bool
if err := pool.QueryRow(ctx, `
SELECT rerun_requested, lease_forced_rerun
FROM movie_match_queue
WHERE media_file_id = $1
`, fileID).Scan(&rerunAfterFailure, &leaseRerunAfterFailure); err != nil {
t.Fatalf("load failed forced rerun: %v", err)
}
if !rerunAfterFailure || leaseRerunAfterFailure {
t.Fatalf("failed forced rerun state = requested:%v leased:%v", rerunAfterFailure, leaseRerunAfterFailure)
}
}
func TestMatchQueueSyncDeletesIneligibleReruns(t *testing.T) {
pool := chainBuiltinTestPool(t)
ctx := context.Background()
t.Run("movie", func(t *testing.T) {
folderID := insertTestFolder(t, pool, "movie")
var fileID int
if err := pool.QueryRow(ctx, `
INSERT INTO media_files (media_folder_id, file_path, base_type, file_size)
VALUES ($1, $2, 'movie', 0) RETURNING id
`, folderID, fmt.Sprintf("/test/ineligible-rerun-%d/Movie.mkv", time.Now().UnixNano())).Scan(&fileID); err != nil {
t.Fatalf("seed movie file: %v", err)
}
repo := NewMovieMatchQueueRepository(pool, scannerrepo.NewFileRepository(pool))
if err := repo.EnqueueMovieFile(ctx, fileID); err != nil {
t.Fatalf("EnqueueMovieFile(): %v", err)
}
if _, err := pool.Exec(ctx, `
UPDATE movie_match_queue
SET rerun_requested = true, lease_token = $2, available_at = NOW() + interval '24 hours'
WHERE media_file_id = $1
`, fileID, "cleanup-owner"); err != nil {
t.Fatalf("seed movie rerun: %v", err)
}
if _, err := pool.Exec(ctx, `UPDATE media_files SET missing_since = NOW() WHERE id = $1`, fileID); err != nil {
t.Fatalf("mark movie missing: %v", err)
}
if err := repo.SyncForFolder(ctx, folderID); err != nil {
t.Fatalf("SyncForFolder(): %v", err)
}
var remaining int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM movie_match_queue WHERE media_file_id = $1`, fileID).Scan(&remaining); err != nil {
t.Fatalf("count movie rerun: %v", err)
}
if remaining != 0 {
t.Fatalf("ineligible movie reruns remaining = %d, want 0", remaining)
}
})
t.Run("series", func(t *testing.T) {
folderID := insertTestFolder(t, pool, "series")
root := fmt.Sprintf("/test/ineligible-series-rerun-%d", time.Now().UnixNano())
var fileID int
if err := pool.QueryRow(ctx, `
INSERT INTO media_files (
media_folder_id, file_path, observed_root_path, base_type,
season_number, episode_number, file_size
) VALUES ($1, $2, $3, 'series', 1, 1, 0)
RETURNING id
`, folderID, root+"/Season 01/Show S01E01.mkv", root).Scan(&fileID); err != nil {
t.Fatalf("seed series file: %v", err)
}
repo := NewSeriesRootMatchQueueRepository(pool)
if err := repo.EnqueueSeriesRoot(ctx, folderID, root); err != nil {
t.Fatalf("EnqueueSeriesRoot(): %v", err)
}
if _, err := pool.Exec(ctx, `
UPDATE series_root_match_queue
SET rerun_requested = true, lease_token = $3, available_at = NOW() + interval '24 hours'
WHERE media_folder_id = $1 AND observed_root_path = $2
`, folderID, root, "cleanup-owner"); err != nil {
t.Fatalf("seed series rerun: %v", err)
}
if _, err := pool.Exec(ctx, `UPDATE media_files SET missing_since = NOW() WHERE id = $1`, fileID); err != nil {
t.Fatalf("mark series file missing: %v", err)
}
if err := repo.SyncForFolder(ctx, folderID); err != nil {
t.Fatalf("SyncForFolder(): %v", err)
}
var remaining int
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM series_root_match_queue
WHERE media_folder_id = $1 AND observed_root_path = $2
`, folderID, root).Scan(&remaining); err != nil {
t.Fatalf("count series rerun: %v", err)
}
if remaining != 0 {
t.Fatalf("ineligible series reruns remaining = %d, want 0", remaining)
}
})
}
+398 -74
View File
@@ -2,6 +2,7 @@ package metadata
import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
@@ -10,40 +11,11 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
scannerrepo "github.com/Silo-Server/silo-server/internal/scanner"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
movieQueueRetryDelay = 15 * time.Second
seriesRootQueueQuietWindow = 10 * time.Second
seriesRootQueueRetryDelay = 30 * time.Second
// matchQueueRetryMaxDelay caps the exponential retry backoff shared by both
// match queues. Terminal outcomes like "no metadata found from any
// provider" stay in the queue but decay to at most one attempt per day
// instead of hot-looping at the base delay forever. A row self-heals by
// being deleted on the first successful attempt.
matchQueueRetryMaxDelay = 24 * time.Hour
// matchQueueBackoffMaxExponent clamps the 2^attempt_count factor so the
// float math stays finite for rows that accumulated very large attempt
// counts before backoff existed.
matchQueueBackoffMaxExponent = 16
)
// matchQueueBackoffExpr returns the SQL expression both match queues use to
// schedule the next retry after a failure: base_delay * 2^attempt_count,
// capped at matchQueueRetryMaxDelay. attempt_count is incremented when a row
// is claimed, so the first failure backs off to twice the base delay.
// basePlaceholder and maxPlaceholder name the interval bind parameters (e.g.
// "$3", "$4") in the caller's statement.
func matchQueueBackoffExpr(basePlaceholder, maxPlaceholder string) string {
return fmt.Sprintf(
"NOW() + LEAST(%s::interval * power(2::float8, LEAST(attempt_count, %d)), %s::interval)",
basePlaceholder, matchQueueBackoffMaxExponent, maxPlaceholder,
)
}
type MovieMatchQueueRepository struct {
pool *pgxpool.Pool
fileRepo *scannerrepo.FileRepository
@@ -74,16 +46,12 @@ func requirePositiveMovieQueueID(label string, value int) error {
// Files beneath a root skipped as misplaced series are excluded durably:
// their content_id is never set, so without the exclusion every library sync
// would re-enqueue them only for the worker to skip them again.
const movieQueueFileEligibleCond = `folders.enabled = true
const movieQueueFileBaseEligibleCond = `folders.enabled = true
AND (
lower(trim(folders.type)) IN ('movie', 'movies') OR
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'movie')
)
AND mf.missing_since IS NULL AND mf.extra_id IS NULL
AND (
mf.content_id IS NULL OR mf.content_id = '' OR
lower(trim(COALESCE(mi.status, ''))) IN ('pending', 'unmatched', 'ambiguous')
)
AND NOT EXISTS (
SELECT 1
FROM skipped_media_roots sr
@@ -92,6 +60,17 @@ const movieQueueFileEligibleCond = `folders.enabled = true
AND strpos(mf.file_path, sr.root_path || '/') = 1
)`
const movieQueueFileNeedsMatchCond = `(
mf.content_id IS NULL OR mf.content_id = '' OR
lower(trim(COALESCE(mi.status, ''))) IN ('pending', 'unmatched', 'ambiguous')
)`
const movieQueueFileEligibleCond = movieQueueFileBaseEligibleCond + `
AND ` + movieQueueFileNeedsMatchCond
const movieQueueClaimEligibleCond = movieQueueFileBaseEligibleCond + `
AND (q.rerun_requested OR q.lease_forced_rerun OR ` + movieQueueFileNeedsMatchCond + `)`
func (r *MovieMatchQueueRepository) EnqueueMovieFile(ctx context.Context, fileID int) error {
if err := r.requireConfigured(); err != nil {
return err
@@ -110,12 +89,16 @@ func (r *MovieMatchQueueRepository) EnqueueMovieFile(ctx context.Context, fileID
INSERT INTO movie_match_queue (
media_file_id,
media_folder_id,
input_fingerprint,
matcher_revision,
available_at,
updated_at
)
SELECT
mf.id,
mf.media_folder_id,
`+matchQueueInputFingerprintSQL("mf.file_path", "'movie'", "mf.media_folder_id", "folders.metadata_language")+`,
`+fmt.Sprintf("%d", matcherRevision)+`,
NOW(),
NOW()
FROM media_files mf
@@ -125,7 +108,20 @@ func (r *MovieMatchQueueRepository) EnqueueMovieFile(ctx context.Context, fileID
AND `+movieQueueFileEligibleCond+`
ON CONFLICT (media_file_id) DO UPDATE
SET media_folder_id = EXCLUDED.media_folder_id,
available_at = GREATEST(movie_match_queue.available_at, EXCLUDED.available_at),
available_at = CASE
WHEN (movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision) AND movie_match_queue.lease_token = '' THEN LEAST(movie_match_queue.available_at, EXCLUDED.available_at)
WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN movie_match_queue.available_at
ELSE GREATEST(movie_match_queue.available_at, EXCLUDED.available_at)
END,
state = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN 'pending' ELSE movie_match_queue.state END,
deterministic_attempt_count = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN 0 ELSE movie_match_queue.deterministic_attempt_count END,
failure_kind = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN '' ELSE movie_match_queue.failure_kind END,
failure_detail = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN '{}'::jsonb ELSE movie_match_queue.failure_detail END,
last_error = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN '' ELSE movie_match_queue.last_error END,
parked_at = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN NULL ELSE movie_match_queue.parked_at END,
rerun_requested = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN true ELSE movie_match_queue.rerun_requested END,
input_fingerprint = EXCLUDED.input_fingerprint,
matcher_revision = EXCLUDED.matcher_revision,
updated_at = NOW()
`, fileID); err != nil {
return fmt.Errorf("upserting movie queue row: %w", err)
@@ -140,7 +136,7 @@ func (r *MovieMatchQueueRepository) EnqueueMovieFile(ctx context.Context, fileID
JOIN media_folders folders ON folders.id = mf.media_folder_id
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.id = q.media_file_id
AND `+movieQueueFileEligibleCond+`
AND `+movieQueueClaimEligibleCond+`
)
`, fileID); err != nil {
return fmt.Errorf("deleting stale movie queue row: %w", err)
@@ -170,12 +166,16 @@ func (r *MovieMatchQueueRepository) SyncForFolder(ctx context.Context, folderID
INSERT INTO movie_match_queue (
media_file_id,
media_folder_id,
input_fingerprint,
matcher_revision,
available_at,
updated_at
)
SELECT
mf.id,
mf.media_folder_id,
`+matchQueueInputFingerprintSQL("mf.file_path", "'movie'", "mf.media_folder_id", "folders.metadata_language")+`,
`+fmt.Sprintf("%d", matcherRevision)+`,
NOW(),
NOW()
FROM media_files mf
@@ -185,7 +185,20 @@ func (r *MovieMatchQueueRepository) SyncForFolder(ctx context.Context, folderID
AND `+movieQueueFileEligibleCond+`
ON CONFLICT (media_file_id) DO UPDATE
SET media_folder_id = EXCLUDED.media_folder_id,
available_at = GREATEST(movie_match_queue.available_at, EXCLUDED.available_at),
available_at = CASE
WHEN (movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision) AND movie_match_queue.lease_token = '' THEN LEAST(movie_match_queue.available_at, EXCLUDED.available_at)
WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN movie_match_queue.available_at
ELSE GREATEST(movie_match_queue.available_at, EXCLUDED.available_at)
END,
state = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN 'pending' ELSE movie_match_queue.state END,
deterministic_attempt_count = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN 0 ELSE movie_match_queue.deterministic_attempt_count END,
failure_kind = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN '' ELSE movie_match_queue.failure_kind END,
failure_detail = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN '{}'::jsonb ELSE movie_match_queue.failure_detail END,
last_error = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN '' ELSE movie_match_queue.last_error END,
parked_at = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN NULL ELSE movie_match_queue.parked_at END,
rerun_requested = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN true ELSE movie_match_queue.rerun_requested END,
input_fingerprint = EXCLUDED.input_fingerprint,
matcher_revision = EXCLUDED.matcher_revision,
updated_at = NOW()
`, folderID); err != nil {
return fmt.Errorf("upserting movie queue rows for folder: %w", err)
@@ -200,7 +213,7 @@ func (r *MovieMatchQueueRepository) SyncForFolder(ctx context.Context, folderID
JOIN media_folders folders ON folders.id = mf.media_folder_id
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.id = q.media_file_id
AND `+movieQueueFileEligibleCond+`
AND `+movieQueueClaimEligibleCond+`
)
`, folderID); err != nil {
return fmt.Errorf("deleting stale movie queue rows for folder: %w", err)
@@ -235,12 +248,16 @@ func (r *MovieMatchQueueRepository) SyncInScope(ctx context.Context, folderID in
INSERT INTO movie_match_queue (
media_file_id,
media_folder_id,
input_fingerprint,
matcher_revision,
available_at,
updated_at
)
SELECT
mf.id,
mf.media_folder_id,
`+matchQueueInputFingerprintSQL("mf.file_path", "'movie'", "mf.media_folder_id", "folders.metadata_language")+`,
`+fmt.Sprintf("%d", matcherRevision)+`,
NOW(),
NOW()
FROM media_files mf
@@ -254,7 +271,20 @@ func (r *MovieMatchQueueRepository) SyncInScope(ctx context.Context, folderID in
)
ON CONFLICT (media_file_id) DO UPDATE
SET media_folder_id = EXCLUDED.media_folder_id,
available_at = GREATEST(movie_match_queue.available_at, EXCLUDED.available_at),
available_at = CASE
WHEN (movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision) AND movie_match_queue.lease_token = '' THEN LEAST(movie_match_queue.available_at, EXCLUDED.available_at)
WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN movie_match_queue.available_at
ELSE GREATEST(movie_match_queue.available_at, EXCLUDED.available_at)
END,
state = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN 'pending' ELSE movie_match_queue.state END,
deterministic_attempt_count = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN 0 ELSE movie_match_queue.deterministic_attempt_count END,
failure_kind = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN '' ELSE movie_match_queue.failure_kind END,
failure_detail = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN '{}'::jsonb ELSE movie_match_queue.failure_detail END,
last_error = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN '' ELSE movie_match_queue.last_error END,
parked_at = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN NULL ELSE movie_match_queue.parked_at END,
rerun_requested = CASE WHEN movie_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR movie_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN true ELSE movie_match_queue.rerun_requested END,
input_fingerprint = EXCLUDED.input_fingerprint,
matcher_revision = EXCLUDED.matcher_revision,
updated_at = NOW()
`, folderID, scopePath, scopeLike); err != nil {
return fmt.Errorf("upserting movie queue rows in scope: %w", err)
@@ -285,7 +315,7 @@ func (r *MovieMatchQueueRepository) SyncInScope(ctx context.Context, folderID in
JOIN media_folders folders ON folders.id = mf.media_folder_id
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.id = q.media_file_id
AND `+movieQueueFileEligibleCond+`
AND `+movieQueueClaimEligibleCond+`
)
`, folderID, scopePath, scopeLike); err != nil {
return fmt.Errorf("deleting stale movie queue rows in scope: %w", err)
@@ -297,23 +327,26 @@ func (r *MovieMatchQueueRepository) SyncInScope(ctx context.Context, folderID in
return nil
}
func (r *MovieMatchQueueRepository) Claim(ctx context.Context, limit int) ([]*models.MediaFile, error) {
func (r *MovieMatchQueueRepository) Claim(ctx context.Context, limit int) ([]models.MovieMatchJob, error) {
if err := r.requireConfigured(); err != nil {
return nil, err
}
if limit <= 0 {
limit = 500
}
leaseToken := uuid.NewString()
rows, err := r.pool.Query(ctx, `
WITH candidates AS (
SELECT q.media_file_id, q.available_at, q.last_attempted_at
SELECT q.media_file_id, q.available_at, q.last_attempted_at,
(q.rerun_requested OR q.lease_forced_rerun) AS rerun_requested
FROM movie_match_queue q
JOIN media_files mf ON mf.id = q.media_file_id
JOIN media_folders folders ON folders.id = mf.media_folder_id
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE q.available_at <= NOW()
AND `+movieQueueFileEligibleCond+`
WHERE q.state = 'pending'
AND q.available_at <= NOW()
AND `+movieQueueClaimEligibleCond+`
ORDER BY q.available_at ASC, q.last_attempted_at ASC NULLS FIRST, q.media_file_id ASC
LIMIT $1
FOR UPDATE OF q SKIP LOCKED
@@ -322,26 +355,36 @@ func (r *MovieMatchQueueRepository) Claim(ctx context.Context, limit int) ([]*mo
UPDATE movie_match_queue q
SET last_attempted_at = NOW(),
attempt_count = q.attempt_count + 1,
available_at = NOW() + $2::interval,
lease_token = $3,
lease_forced_rerun = c.rerun_requested,
rerun_requested = false,
updated_at = NOW()
FROM candidates c
WHERE q.media_file_id = c.media_file_id
RETURNING q.media_file_id
)
SELECT c.media_file_id
SELECT c.media_file_id, c.rerun_requested
FROM candidates c
JOIN updated u ON u.media_file_id = c.media_file_id
ORDER BY c.available_at ASC, c.last_attempted_at ASC NULLS FIRST, c.media_file_id ASC
`, limit)
`, limit, intervalLiteral(matchQueueClaimLease), leaseToken)
if err != nil {
return nil, fmt.Errorf("claiming movie queue rows: %w", err)
}
defer rows.Close()
ids, err := scanClaimedMovieIDs(rows)
claimed, err := scanClaimedMovies(rows)
if err != nil {
return nil, err
rows.Close()
return nil, r.releaseClaimAfterError(ctx, leaseToken, err)
}
return r.loadFilesByIDs(ctx, ids)
ids := claimedMovieIDs(claimed)
files, err := r.loadFilesByIDs(ctx, ids, leaseToken)
if err != nil {
return nil, r.releaseClaimAfterError(ctx, leaseToken, err)
}
return movieMatchJobs(files, leaseToken, claimed), nil
}
func (r *MovieMatchQueueRepository) ClaimByFolderAndPathPrefix(
@@ -350,7 +393,7 @@ func (r *MovieMatchQueueRepository) ClaimByFolderAndPathPrefix(
pathPrefix string,
limit int,
attemptBefore time.Time,
) ([]*models.MediaFile, error) {
) ([]models.MovieMatchJob, error) {
if err := r.requireConfigured(); err != nil {
return nil, err
}
@@ -363,19 +406,22 @@ func (r *MovieMatchQueueRepository) ClaimByFolderAndPathPrefix(
if limit <= 0 {
limit = 500
}
leaseToken := uuid.NewString()
pathPrefix = filepath.Clean(pathPrefix)
scopeLike := pathPrefixLike(pathPrefix)
rows, err := r.pool.Query(ctx, `
WITH candidates AS (
SELECT q.media_file_id, q.available_at, q.last_attempted_at
SELECT q.media_file_id, q.available_at, q.last_attempted_at,
(q.rerun_requested OR q.lease_forced_rerun) AS rerun_requested
FROM movie_match_queue q
JOIN media_files mf ON mf.id = q.media_file_id
JOIN media_folders folders ON folders.id = mf.media_folder_id
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE q.media_folder_id = $1
AND q.state = 'pending'
AND q.available_at <= NOW()
AND `+movieQueueFileEligibleCond+`
AND `+movieQueueClaimEligibleCond+`
AND (
mf.file_path = $2 OR
mf.file_path LIKE $3 ESCAPE '\'
@@ -389,39 +435,65 @@ func (r *MovieMatchQueueRepository) ClaimByFolderAndPathPrefix(
UPDATE movie_match_queue q
SET last_attempted_at = NOW(),
attempt_count = q.attempt_count + 1,
available_at = NOW() + $6::interval,
lease_token = $7,
lease_forced_rerun = c.rerun_requested,
rerun_requested = false,
updated_at = NOW()
FROM candidates c
WHERE q.media_file_id = c.media_file_id
RETURNING q.media_file_id
)
SELECT c.media_file_id
SELECT c.media_file_id, c.rerun_requested
FROM candidates c
JOIN updated u ON u.media_file_id = c.media_file_id
ORDER BY c.available_at ASC, c.last_attempted_at ASC NULLS FIRST, c.media_file_id ASC
`, folderID, pathPrefix, scopeLike, nullTime(attemptBefore), limit)
`, folderID, pathPrefix, scopeLike, nullTime(attemptBefore), limit, intervalLiteral(matchQueueClaimLease), leaseToken)
if err != nil {
return nil, fmt.Errorf("claiming movie queue rows by scope: %w", err)
}
defer rows.Close()
ids, err := scanClaimedMovieIDs(rows)
claimed, err := scanClaimedMovies(rows)
if err != nil {
return nil, err
rows.Close()
return nil, r.releaseClaimAfterError(ctx, leaseToken, err)
}
return r.loadFilesByIDs(ctx, ids)
ids := claimedMovieIDs(claimed)
files, err := r.loadFilesByIDs(ctx, ids, leaseToken)
if err != nil {
return nil, r.releaseClaimAfterError(ctx, leaseToken, err)
}
return movieMatchJobs(files, leaseToken, claimed), nil
}
func (r *MovieMatchQueueRepository) Delete(ctx context.Context, mediaFileID int) error {
func (r *MovieMatchQueueRepository) Delete(ctx context.Context, mediaFileID int, leaseToken string) error {
if err := r.requireConfigured(); err != nil {
return err
}
if err := requirePositiveMovieQueueID("media file id", mediaFileID); err != nil {
return err
}
if strings.TrimSpace(leaseToken) == "" {
return errors.New("lease token is required")
}
if _, err := r.pool.Exec(ctx, `
WITH rerun AS (
UPDATE movie_match_queue
SET available_at = NOW(),
lease_token = '',
lease_forced_rerun = false,
updated_at = NOW()
WHERE media_file_id = $1
AND lease_token = $2
AND rerun_requested
RETURNING media_file_id
)
DELETE FROM movie_match_queue
WHERE media_file_id = $1
`, mediaFileID); err != nil {
AND lease_token = $2
AND NOT rerun_requested
`, mediaFileID, leaseToken); err != nil {
return fmt.Errorf("deleting movie queue row: %w", err)
}
return nil
@@ -444,25 +516,195 @@ func (r *MovieMatchQueueRepository) DeleteByFolder(ctx context.Context, folderID
return int(tag.RowsAffected()), nil
}
func (r *MovieMatchQueueRepository) UpdateError(ctx context.Context, mediaFileID int, errText string) error {
func (r *MovieMatchQueueRepository) UpdateError(ctx context.Context, mediaFileID int, leaseToken, errText string) error {
// Generic processing failures (database errors, interrupted work, provider
// transport errors) are operationally retryable. Deterministic matcher
// outcomes use UpdateFailure with an explicit kind and are the only failures
// allowed to consume the parking budget.
return r.UpdateFailure(ctx, mediaFileID, leaseToken, MatchFailure{Kind: MatchOutcomeProviderTransient, Message: errText})
}
func (r *MovieMatchQueueRepository) UpdateFailure(ctx context.Context, mediaFileID int, leaseToken string, failure MatchFailure) error {
if err := r.requireConfigured(); err != nil {
return err
}
if err := requirePositiveMovieQueueID("media file id", mediaFileID); err != nil {
return err
}
if strings.TrimSpace(leaseToken) == "" {
return errors.New("lease token is required")
}
kind := normalizeMatchFailureKind(failure.Kind)
message := boundedMatchFailureMessage(failure.Message)
detail, err := json.Marshal(map[string]any{"message": message, "decision": boundedMatchDecision(failure.Decision)})
if err != nil {
return fmt.Errorf("encoding movie queue failure: %w", err)
}
if _, err := r.pool.Exec(ctx, `
UPDATE movie_match_queue
SET last_error = $2,
available_at = `+matchQueueBackoffExpr("$3", "$4")+`,
SET last_error = CASE WHEN rerun_requested THEN last_error ELSE left($2, 2000) END,
failure_kind = CASE WHEN rerun_requested THEN failure_kind ELSE $3 END,
failure_detail = CASE WHEN rerun_requested THEN failure_detail ELSE $4::jsonb END,
deterministic_attempt_count = CASE
WHEN rerun_requested THEN deterministic_attempt_count
ELSE deterministic_attempt_count + CASE WHEN $3 = 'provider_transient' THEN 0 ELSE 1 END
END,
state = CASE
WHEN rerun_requested THEN 'pending'
WHEN $3 <> 'provider_transient' AND deterministic_attempt_count + 1 >= 3 THEN 'parked'
ELSE 'pending'
END,
parked_at = CASE
WHEN rerun_requested THEN NULL
WHEN $3 <> 'provider_transient' AND deterministic_attempt_count + 1 >= 3 THEN NOW()
ELSE NULL
END,
available_at = CASE
WHEN rerun_requested THEN NOW()
WHEN $3 = 'provider_transient' THEN `+matchQueueBackoffExpr("$5", "$6")+`
WHEN deterministic_attempt_count + 1 = 1 THEN NOW() + interval '1 hour'
ELSE NOW() + interval '24 hours'
END,
lease_token = '',
rerun_requested = rerun_requested OR lease_forced_rerun,
lease_forced_rerun = false,
updated_at = NOW()
WHERE media_file_id = $1
`, mediaFileID, errText, intervalLiteral(movieQueueRetryDelay), intervalLiteral(matchQueueRetryMaxDelay)); err != nil {
WHERE media_file_id = $1 AND lease_token = $7
`, mediaFileID, message, kind, detail, intervalLiteral(movieQueueRetryDelay), intervalLiteral(matchQueueRetryMaxDelay), leaseToken); err != nil {
return fmt.Errorf("updating movie queue error: %w", err)
}
return nil
}
// RetryNowByFolder explicitly wakes every queued row, including parked and
// future-backed-off work. Historical attempt_count remains available for audit.
func (r *MovieMatchQueueRepository) RetryNowByFolder(ctx context.Context, folderID int) (int, error) {
if err := r.requireConfigured(); err != nil {
return 0, err
}
if err := requirePositiveMovieQueueID("folder id", folderID); err != nil {
return 0, err
}
tag, err := r.pool.Exec(ctx, `
WITH current_inputs AS (
SELECT q.media_file_id, mf.media_folder_id,
`+matchQueueInputFingerprintSQL("mf.file_path", "'movie'", "mf.media_folder_id", "folders.metadata_language")+` AS input_fingerprint
FROM movie_match_queue q
JOIN media_files mf ON mf.id = q.media_file_id
JOIN media_folders folders ON folders.id = mf.media_folder_id
WHERE q.media_folder_id = $1
)
UPDATE movie_match_queue
SET state = 'pending',
available_at = CASE WHEN movie_match_queue.lease_token = '' THEN NOW() ELSE movie_match_queue.available_at END,
deterministic_attempt_count = 0,
failure_kind = '', failure_detail = '{}'::jsonb, last_error = '', parked_at = NULL,
rerun_requested = true,
media_folder_id = current_inputs.media_folder_id,
input_fingerprint = current_inputs.input_fingerprint, matcher_revision = `+fmt.Sprintf("%d", matcherRevision)+`, updated_at = NOW()
FROM current_inputs
WHERE movie_match_queue.media_file_id = current_inputs.media_file_id
`, folderID)
if err != nil {
return 0, fmt.Errorf("retrying movie queue now: %w", err)
}
return int(tag.RowsAffected()), nil
}
func (r *MovieMatchQueueRepository) WakeForChangedInputs(ctx context.Context) (int, error) {
if err := r.requireConfigured(); err != nil {
return 0, err
}
tag, err := r.pool.Exec(ctx, `
WITH changed AS (
SELECT q.media_file_id, mf.media_folder_id,
`+matchQueueInputFingerprintSQL("mf.file_path", "'movie'", "mf.media_folder_id", "folders.metadata_language")+` AS input_fingerprint
FROM movie_match_queue q
JOIN media_files mf ON mf.id = q.media_file_id
JOIN media_folders folders ON folders.id = mf.media_folder_id
)
UPDATE movie_match_queue q
SET state = 'pending',
available_at = CASE WHEN q.lease_token = '' THEN NOW() ELSE q.available_at END,
deterministic_attempt_count = 0,
failure_kind = '', failure_detail = '{}'::jsonb, last_error = '', parked_at = NULL,
rerun_requested = true,
media_folder_id = changed.media_folder_id,
input_fingerprint = changed.input_fingerprint, matcher_revision = `+fmt.Sprintf("%d", matcherRevision)+`, updated_at = NOW()
FROM changed
WHERE changed.media_file_id = q.media_file_id
AND (q.input_fingerprint <> changed.input_fingerprint OR q.matcher_revision <> `+fmt.Sprintf("%d", matcherRevision)+` OR q.media_folder_id <> changed.media_folder_id)
`)
if err != nil {
return 0, fmt.Errorf("waking movie matches with changed inputs: %w", err)
}
return int(tag.RowsAffected()), nil
}
// ReleaseLease makes any unfinished rows from one batch immediately
// claimable. Rows completed, failed, expired, or explicitly retried no longer
// carry this token and are therefore untouched.
func (r *MovieMatchQueueRepository) ReleaseLease(ctx context.Context, leaseToken string) (int, error) {
if err := r.requireConfigured(); err != nil {
return 0, err
}
if strings.TrimSpace(leaseToken) == "" {
return 0, errors.New("lease token is required")
}
tag, err := r.pool.Exec(ctx, `
UPDATE movie_match_queue
SET available_at = NOW(), lease_token = '',
rerun_requested = rerun_requested OR lease_forced_rerun,
lease_forced_rerun = false,
attempt_count = GREATEST(attempt_count - 1, 0), updated_at = NOW()
WHERE lease_token = $1 AND state = 'pending'
`, leaseToken)
if err != nil {
return 0, fmt.Errorf("releasing movie match lease: %w", err)
}
return int(tag.RowsAffected()), nil
}
func (r *MovieMatchQueueRepository) releaseClaimAfterError(ctx context.Context, leaseToken string, claimErr error) error {
releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
if _, releaseErr := r.ReleaseLease(releaseCtx, leaseToken); releaseErr != nil {
return errors.Join(claimErr, releaseErr)
}
return claimErr
}
type claimedMovie struct {
mediaFileID int
rerunRequested bool
}
func claimedMovieIDs(claimed []claimedMovie) []int {
ids := make([]int, 0, len(claimed))
for _, row := range claimed {
ids = append(ids, row.mediaFileID)
}
return ids
}
func movieMatchJobs(files []*models.MediaFile, leaseToken string, claimed []claimedMovie) []models.MovieMatchJob {
rerunByID := make(map[int]bool, len(claimed))
for _, row := range claimed {
rerunByID[row.mediaFileID] = row.rerunRequested
}
jobs := make([]models.MovieMatchJob, 0, len(files))
for _, file := range files {
if file != nil {
jobs = append(jobs, models.MovieMatchJob{
File: file,
LeaseToken: leaseToken,
RerunRequested: rerunByID[file.ID],
})
}
}
return jobs
}
func (r *MovieMatchQueueRepository) ListByFolder(ctx context.Context, folderID int, limit int, offset int) ([]models.MovieMatchQueueEntry, int, error) {
if err := r.requireConfigured(); err != nil {
return nil, 0, err
@@ -488,11 +730,18 @@ func (r *MovieMatchQueueRepository) ListByFolder(ctx context.Context, folderID i
q.last_attempted_at,
q.attempt_count,
q.last_error,
q.state,
q.failure_kind,
q.failure_detail,
q.deterministic_attempt_count,
q.input_fingerprint,
q.matcher_revision,
q.parked_at,
q.updated_at
FROM movie_match_queue q
LEFT JOIN media_files mf ON mf.id = q.media_file_id
WHERE q.media_folder_id = $1
ORDER BY q.available_at ASC, q.last_attempted_at ASC NULLS FIRST, q.media_file_id ASC
ORDER BY (q.state = 'parked') DESC, q.available_at ASC, q.last_attempted_at ASC NULLS FIRST, q.media_file_id ASC
LIMIT $2 OFFSET $3
`, folderID, limit, offset)
if err != nil {
@@ -512,6 +761,13 @@ func (r *MovieMatchQueueRepository) ListByFolder(ctx context.Context, folderID i
&entry.LastAttemptedAt,
&entry.AttemptCount,
&entry.LastError,
&entry.State,
&entry.FailureKind,
&entry.FailureDetail,
&entry.DeterministicAttemptCount,
&entry.InputFingerprint,
&entry.MatcherRevision,
&entry.ParkedAt,
&entry.UpdatedAt,
); err != nil {
return nil, 0, fmt.Errorf("scanning movie queue row: %w", err)
@@ -540,7 +796,75 @@ func (r *MovieMatchQueueRepository) CountByFolder(ctx context.Context, folderID
return total, nil
}
func (r *MovieMatchQueueRepository) loadFilesByIDs(ctx context.Context, ids []int) ([]*models.MediaFile, error) {
func (r *MovieMatchQueueRepository) CountStatesByFolder(ctx context.Context, folderID int) (int, int, error) {
if err := r.requireConfigured(); err != nil {
return 0, 0, err
}
if err := requirePositiveMovieQueueID("folder id", folderID); err != nil {
return 0, 0, err
}
var pending, parked int
if err := r.pool.QueryRow(ctx, `
SELECT
COUNT(*) FILTER (WHERE state = 'pending'),
COUNT(*) FILTER (WHERE state = 'parked')
FROM movie_match_queue
WHERE media_folder_id = $1
`, folderID).Scan(&pending, &parked); err != nil {
return 0, 0, fmt.Errorf("counting movie queue states: %w", err)
}
return pending, parked, nil
}
// CountStatesByFolders returns queue aggregates for every requested library in
// one query. Libraries without rows are omitted from the result map.
func (r *MovieMatchQueueRepository) CountStatesByFolders(ctx context.Context, folderIDs []int) (map[int]MatchQueueStateCounts, error) {
if err := r.requireConfigured(); err != nil {
return nil, err
}
counts := make(map[int]MatchQueueStateCounts, len(folderIDs))
if len(folderIDs) == 0 {
return counts, nil
}
rows, err := r.pool.Query(ctx, `
SELECT
media_folder_id,
COUNT(*) FILTER (WHERE state = 'pending'),
COUNT(*) FILTER (WHERE state = 'parked')
FROM movie_match_queue
WHERE media_folder_id = ANY($1)
GROUP BY media_folder_id
`, folderIDs)
if err != nil {
return nil, fmt.Errorf("counting movie queue states by folders: %w", err)
}
defer rows.Close()
for rows.Next() {
var folderID int
var count MatchQueueStateCounts
if err := rows.Scan(&folderID, &count.Pending, &count.Parked); err != nil {
return nil, fmt.Errorf("scanning movie queue state counts: %w", err)
}
counts[folderID] = count
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating movie queue state counts: %w", err)
}
return counts, nil
}
func (r *MovieMatchQueueRepository) CountByFolderAndState(ctx context.Context, folderID int, state string) (int, error) {
if err := r.requireConfigured(); err != nil {
return 0, err
}
var total int
if err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM movie_match_queue WHERE media_folder_id = $1 AND state = $2`, folderID, state).Scan(&total); err != nil {
return 0, fmt.Errorf("counting movie queue state: %w", err)
}
return total, nil
}
func (r *MovieMatchQueueRepository) loadFilesByIDs(ctx context.Context, ids []int, leaseToken string) ([]*models.MediaFile, error) {
if len(ids) == 0 {
return nil, nil
}
@@ -561,7 +885,7 @@ func (r *MovieMatchQueueRepository) loadFilesByIDs(ctx context.Context, ids []in
for _, id := range ids {
file := filesByID[id]
if file == nil {
if err := r.Delete(ctx, id); err != nil {
if err := r.Delete(ctx, id, leaseToken); err != nil {
return nil, fmt.Errorf("deleting stale movie queue row %d: %w", id, err)
}
continue
@@ -571,19 +895,19 @@ func (r *MovieMatchQueueRepository) loadFilesByIDs(ctx context.Context, ids []in
return files, nil
}
func scanClaimedMovieIDs(rows pgx.Rows) ([]int, error) {
ids := make([]int, 0)
func scanClaimedMovies(rows pgx.Rows) ([]claimedMovie, error) {
claimed := make([]claimedMovie, 0)
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
var row claimedMovie
if err := rows.Scan(&row.mediaFileID, &row.rerunRequested); err != nil {
return nil, fmt.Errorf("scanning claimed movie queue row: %w", err)
}
ids = append(ids, id)
claimed = append(claimed, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating claimed movie queue rows: %w", err)
}
return ids, nil
return claimed, nil
}
func intervalLiteral(d time.Duration) string {
+405 -71
View File
@@ -2,6 +2,7 @@ package metadata
import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
@@ -10,6 +11,7 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/pathscope"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -71,33 +73,60 @@ func (r *SeriesRootMatchQueueRepository) EnqueueSeriesRoot(ctx context.Context,
defer tx.Rollback(ctx) //nolint:errcheck
if _, err := tx.Exec(ctx, `
WITH eligible_roots AS (
SELECT DISTINCT
mf.media_folder_id,
mf.observed_root_path,
COALESCE(folders.metadata_language, '') AS metadata_language
FROM media_files mf
JOIN media_folders folders ON folders.id = mf.media_folder_id
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.media_folder_id = $1
AND mf.observed_root_path = $2
AND folders.enabled = true
AND (
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series')
)
AND mf.missing_since IS NULL AND mf.extra_id IS NULL
AND mf.observed_root_path <> ''
AND (
mf.content_id IS NULL OR mf.content_id = '' OR
lower(trim(COALESCE(mi.status, ''))) IN ('pending', 'unmatched', 'ambiguous')
)
)
INSERT INTO series_root_match_queue (
media_folder_id,
observed_root_path,
input_fingerprint,
matcher_revision,
available_at,
updated_at
)
SELECT DISTINCT
mf.media_folder_id,
mf.observed_root_path,
SELECT
roots.media_folder_id,
roots.observed_root_path,
`+seriesMatchQueueInputFingerprintSQL("roots.observed_root_path", "roots.media_folder_id", "roots.metadata_language")+`,
`+fmt.Sprintf("%d", matcherRevision)+`,
NOW() + $3::interval,
NOW()
FROM media_files mf
JOIN media_folders folders ON folders.id = mf.media_folder_id
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.media_folder_id = $1
AND mf.observed_root_path = $2
AND folders.enabled = true
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows')
AND mf.missing_since IS NULL AND mf.extra_id IS NULL
AND mf.observed_root_path <> ''
AND (
mf.content_id IS NULL OR mf.content_id = '' OR
lower(trim(COALESCE(mi.status, ''))) IN ('pending', 'unmatched', 'ambiguous')
)
FROM eligible_roots roots
ON CONFLICT (media_folder_id, observed_root_path)
DO UPDATE SET
available_at = GREATEST(series_root_match_queue.available_at, EXCLUDED.available_at),
available_at = CASE
WHEN (series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision) AND series_root_match_queue.lease_token = '' THEN LEAST(series_root_match_queue.available_at, EXCLUDED.available_at)
WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN series_root_match_queue.available_at
ELSE GREATEST(series_root_match_queue.available_at, EXCLUDED.available_at)
END,
state = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN 'pending' ELSE series_root_match_queue.state END,
deterministic_attempt_count = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN 0 ELSE series_root_match_queue.deterministic_attempt_count END,
failure_kind = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN '' ELSE series_root_match_queue.failure_kind END,
failure_detail = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN '{}'::jsonb ELSE series_root_match_queue.failure_detail END,
last_error = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN '' ELSE series_root_match_queue.last_error END,
parked_at = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN NULL ELSE series_root_match_queue.parked_at END,
rerun_requested = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN true ELSE series_root_match_queue.rerun_requested END,
input_fingerprint = EXCLUDED.input_fingerprint,
matcher_revision = EXCLUDED.matcher_revision,
updated_at = NOW()
`, folderID, observedRootPath, intervalLiteral(seriesRootQueueQuietWindow)); err != nil {
return fmt.Errorf("upserting series root queue row: %w", err)
@@ -121,10 +150,10 @@ func (r *SeriesRootMatchQueueRepository) EnqueueSeriesRoot(ctx context.Context,
)
AND mf.missing_since IS NULL AND mf.extra_id IS NULL
AND mf.observed_root_path <> ''
AND (
AND (q.rerun_requested OR q.lease_forced_rerun OR (
mf.content_id IS NULL OR mf.content_id = '' OR
lower(trim(COALESCE(mi.status, ''))) IN ('pending', 'unmatched', 'ambiguous')
)
))
)
`, folderID, observedRootPath); err != nil {
return fmt.Errorf("deleting stale series root queue row: %w", err)
@@ -151,36 +180,60 @@ func (r *SeriesRootMatchQueueRepository) SyncForFolder(ctx context.Context, fold
defer tx.Rollback(ctx) //nolint:errcheck
if _, err := tx.Exec(ctx, `
WITH eligible_roots AS (
SELECT DISTINCT
mf.media_folder_id,
mf.observed_root_path,
COALESCE(folders.metadata_language, '') AS metadata_language
FROM media_files mf
JOIN media_folders folders ON folders.id = mf.media_folder_id
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.media_folder_id = $1
AND folders.enabled = true
AND (
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series')
)
AND mf.missing_since IS NULL AND mf.extra_id IS NULL
AND mf.observed_root_path IS NOT NULL
AND mf.observed_root_path <> ''
AND (
mf.content_id IS NULL OR mf.content_id = '' OR
lower(trim(COALESCE(mi.status, ''))) IN ('pending', 'unmatched', 'ambiguous')
)
)
INSERT INTO series_root_match_queue (
media_folder_id,
observed_root_path,
input_fingerprint,
matcher_revision,
available_at,
updated_at
)
SELECT DISTINCT
mf.media_folder_id,
mf.observed_root_path,
SELECT
roots.media_folder_id,
roots.observed_root_path,
`+seriesMatchQueueInputFingerprintSQL("roots.observed_root_path", "roots.media_folder_id", "roots.metadata_language")+`,
`+fmt.Sprintf("%d", matcherRevision)+`,
NOW() + $2::interval,
NOW()
FROM media_files mf
JOIN media_folders folders ON folders.id = mf.media_folder_id
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.media_folder_id = $1
AND folders.enabled = true
AND (
lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows') OR
(lower(trim(folders.type)) = 'mixed' AND lower(trim(mf.base_type)) = 'series')
)
AND mf.missing_since IS NULL AND mf.extra_id IS NULL
AND mf.observed_root_path IS NOT NULL
AND mf.observed_root_path <> ''
AND (
mf.content_id IS NULL OR mf.content_id = '' OR
lower(trim(COALESCE(mi.status, ''))) IN ('pending', 'unmatched', 'ambiguous')
)
FROM eligible_roots roots
ON CONFLICT (media_folder_id, observed_root_path)
DO UPDATE SET
available_at = GREATEST(series_root_match_queue.available_at, EXCLUDED.available_at),
available_at = CASE
WHEN (series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision) AND series_root_match_queue.lease_token = '' THEN LEAST(series_root_match_queue.available_at, EXCLUDED.available_at)
WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN series_root_match_queue.available_at
ELSE GREATEST(series_root_match_queue.available_at, EXCLUDED.available_at)
END,
state = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN 'pending' ELSE series_root_match_queue.state END,
deterministic_attempt_count = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN 0 ELSE series_root_match_queue.deterministic_attempt_count END,
failure_kind = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN '' ELSE series_root_match_queue.failure_kind END,
failure_detail = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN '{}'::jsonb ELSE series_root_match_queue.failure_detail END,
last_error = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN '' ELSE series_root_match_queue.last_error END,
parked_at = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN NULL ELSE series_root_match_queue.parked_at END,
rerun_requested = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN true ELSE series_root_match_queue.rerun_requested END,
input_fingerprint = EXCLUDED.input_fingerprint,
matcher_revision = EXCLUDED.matcher_revision,
updated_at = NOW()
`, folderID, intervalLiteral(seriesRootQueueQuietWindow)); err != nil {
return fmt.Errorf("upserting series root queue rows for folder: %w", err)
@@ -203,10 +256,10 @@ func (r *SeriesRootMatchQueueRepository) SyncForFolder(ctx context.Context, fold
)
AND mf.missing_since IS NULL AND mf.extra_id IS NULL
AND mf.observed_root_path <> ''
AND (
AND (q.rerun_requested OR q.lease_forced_rerun OR (
mf.content_id IS NULL OR mf.content_id = '' OR
lower(trim(COALESCE(mi.status, ''))) IN ('pending', 'unmatched', 'ambiguous')
)
))
)
`, folderID); err != nil {
return fmt.Errorf("deleting stale series root queue rows for folder: %w", err)
@@ -241,7 +294,8 @@ func (r *SeriesRootMatchQueueRepository) SyncInScope(ctx context.Context, folder
WITH in_scope_roots AS (
SELECT DISTINCT
mf.media_folder_id,
mf.observed_root_path
mf.observed_root_path,
COALESCE(folders.metadata_language, '') AS metadata_language
FROM media_files mf
JOIN media_folders folders ON folders.id = mf.media_folder_id
LEFT JOIN media_items mi ON mi.content_id = mf.content_id
@@ -266,14 +320,31 @@ func (r *SeriesRootMatchQueueRepository) SyncInScope(ctx context.Context, folder
INSERT INTO series_root_match_queue (
media_folder_id,
observed_root_path,
input_fingerprint,
matcher_revision,
available_at,
updated_at
)
SELECT media_folder_id, observed_root_path, NOW() + $4::interval, NOW()
FROM in_scope_roots
SELECT roots.media_folder_id, roots.observed_root_path,
`+seriesMatchQueueInputFingerprintSQL("roots.observed_root_path", "roots.media_folder_id", "roots.metadata_language")+`,
`+fmt.Sprintf("%d", matcherRevision)+`, NOW() + $4::interval, NOW()
FROM in_scope_roots roots
ON CONFLICT (media_folder_id, observed_root_path)
DO UPDATE SET
available_at = GREATEST(series_root_match_queue.available_at, EXCLUDED.available_at),
available_at = CASE
WHEN (series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision) AND series_root_match_queue.lease_token = '' THEN LEAST(series_root_match_queue.available_at, EXCLUDED.available_at)
WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN series_root_match_queue.available_at
ELSE GREATEST(series_root_match_queue.available_at, EXCLUDED.available_at)
END,
state = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN 'pending' ELSE series_root_match_queue.state END,
deterministic_attempt_count = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN 0 ELSE series_root_match_queue.deterministic_attempt_count END,
failure_kind = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN '' ELSE series_root_match_queue.failure_kind END,
failure_detail = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN '{}'::jsonb ELSE series_root_match_queue.failure_detail END,
last_error = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN '' ELSE series_root_match_queue.last_error END,
parked_at = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN NULL ELSE series_root_match_queue.parked_at END,
rerun_requested = CASE WHEN series_root_match_queue.input_fingerprint <> EXCLUDED.input_fingerprint OR series_root_match_queue.matcher_revision <> EXCLUDED.matcher_revision THEN true ELSE series_root_match_queue.rerun_requested END,
input_fingerprint = EXCLUDED.input_fingerprint,
matcher_revision = EXCLUDED.matcher_revision,
updated_at = NOW()
`, folderID, scopePath, scopeLike, intervalLiteral(seriesRootQueueQuietWindow)); err != nil {
return fmt.Errorf("upserting series root queue rows in scope: %w", err)
@@ -314,10 +385,10 @@ func (r *SeriesRootMatchQueueRepository) SyncInScope(ctx context.Context, folder
)
AND mf.missing_since IS NULL AND mf.extra_id IS NULL
AND mf.observed_root_path <> ''
AND (
AND (q.rerun_requested OR q.lease_forced_rerun OR (
mf.content_id IS NULL OR mf.content_id = '' OR
lower(trim(COALESCE(mi.status, ''))) IN ('pending', 'unmatched', 'ambiguous')
)
))
)
`, folderID, scopePath, scopeLike); err != nil {
return fmt.Errorf("deleting stale series root queue rows in scope: %w", err)
@@ -336,13 +407,16 @@ func (r *SeriesRootMatchQueueRepository) Claim(ctx context.Context, limit int) (
if limit <= 0 {
limit = 500
}
leaseToken := uuid.NewString()
rows, err := r.pool.Query(ctx, `
WITH candidates AS (
SELECT q.media_folder_id, q.observed_root_path
SELECT q.media_folder_id, q.observed_root_path,
(q.rerun_requested OR q.lease_forced_rerun) AS rerun_requested
FROM series_root_match_queue q
JOIN media_folders folders ON folders.id = q.media_folder_id
WHERE q.available_at <= NOW()
WHERE q.state = 'pending'
AND q.available_at <= NOW()
AND folders.enabled = true
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows', 'mixed')
AND EXISTS (
@@ -357,10 +431,10 @@ func (r *SeriesRootMatchQueueRepository) Claim(ctx context.Context, limit int) (
)
AND mf.missing_since IS NULL AND mf.extra_id IS NULL
AND mf.observed_root_path <> ''
AND (
AND (q.rerun_requested OR q.lease_forced_rerun OR (
mf.content_id IS NULL OR mf.content_id = '' OR
lower(trim(COALESCE(mi.status, ''))) IN ('pending', 'unmatched', 'ambiguous')
)
))
)
ORDER BY q.available_at ASC, q.last_attempted_at ASC NULLS FIRST, q.media_folder_id ASC, q.observed_root_path ASC
LIMIT $1
@@ -370,6 +444,10 @@ func (r *SeriesRootMatchQueueRepository) Claim(ctx context.Context, limit int) (
UPDATE series_root_match_queue q
SET last_attempted_at = NOW(),
attempt_count = q.attempt_count + 1,
available_at = NOW() + $2::interval,
lease_token = $3,
lease_forced_rerun = c.rerun_requested,
rerun_requested = false,
updated_at = NOW()
FROM candidates c
WHERE q.media_folder_id = c.media_folder_id
@@ -386,19 +464,31 @@ func (r *SeriesRootMatchQueueRepository) Claim(ctx context.Context, limit int) (
WHERE mf.media_folder_id = u.media_folder_id
AND mf.observed_root_path = u.observed_root_path
AND mf.missing_since IS NULL AND mf.extra_id IS NULL
)) AS observed_file_count
)) AS observed_file_count,
c.rerun_requested
FROM updated u
JOIN candidates c
ON c.media_folder_id = u.media_folder_id
AND c.observed_root_path = u.observed_root_path
LEFT JOIN observed_media_locations loc
ON loc.media_folder_id = u.media_folder_id
AND loc.observed_root_path = u.observed_root_path
ORDER BY u.media_folder_id ASC, u.observed_root_path ASC
`, limit)
`, limit, intervalLiteral(matchQueueClaimLease), leaseToken)
if err != nil {
return nil, fmt.Errorf("claiming series root queue rows: %w", err)
}
defer rows.Close()
return scanSeriesRootJobs(rows)
jobs, err := scanSeriesRootJobs(rows)
if err != nil {
rows.Close()
return nil, r.releaseClaimAfterError(ctx, leaseToken, err)
}
for i := range jobs {
jobs[i].LeaseToken = leaseToken
}
return jobs, nil
}
func (r *SeriesRootMatchQueueRepository) ClaimByFolderAndPathPrefix(
@@ -420,15 +510,18 @@ func (r *SeriesRootMatchQueueRepository) ClaimByFolderAndPathPrefix(
if limit <= 0 {
limit = 500
}
leaseToken := uuid.NewString()
pathPrefix = filepath.Clean(pathPrefix)
scopeLike := pathPrefixLike(pathPrefix)
rows, err := r.pool.Query(ctx, `
WITH candidates AS (
SELECT q.media_folder_id, q.observed_root_path
SELECT q.media_folder_id, q.observed_root_path,
(q.rerun_requested OR q.lease_forced_rerun) AS rerun_requested
FROM series_root_match_queue q
JOIN media_folders folders ON folders.id = q.media_folder_id
WHERE q.media_folder_id = $1
AND q.state = 'pending'
AND q.available_at <= NOW()
AND folders.enabled = true
AND lower(trim(folders.type)) IN ('series', 'tv', 'show', 'tvshows', 'mixed')
@@ -455,10 +548,10 @@ func (r *SeriesRootMatchQueueRepository) ClaimByFolderAndPathPrefix(
)
AND mf.missing_since IS NULL AND mf.extra_id IS NULL
AND mf.observed_root_path <> ''
AND (
AND (q.rerun_requested OR q.lease_forced_rerun OR (
mf.content_id IS NULL OR mf.content_id = '' OR
lower(trim(COALESCE(mi.status, ''))) IN ('pending', 'unmatched', 'ambiguous')
)
))
)
AND ($4::timestamptz IS NULL OR q.last_attempted_at IS NULL OR q.last_attempted_at < $4)
ORDER BY q.available_at ASC, q.last_attempted_at ASC NULLS FIRST, q.observed_root_path ASC
@@ -469,6 +562,10 @@ func (r *SeriesRootMatchQueueRepository) ClaimByFolderAndPathPrefix(
UPDATE series_root_match_queue q
SET last_attempted_at = NOW(),
attempt_count = q.attempt_count + 1,
available_at = NOW() + $6::interval,
lease_token = $7,
lease_forced_rerun = c.rerun_requested,
rerun_requested = false,
updated_at = NOW()
FROM candidates c
WHERE q.media_folder_id = c.media_folder_id
@@ -485,22 +582,34 @@ func (r *SeriesRootMatchQueueRepository) ClaimByFolderAndPathPrefix(
WHERE mf.media_folder_id = u.media_folder_id
AND mf.observed_root_path = u.observed_root_path
AND mf.missing_since IS NULL AND mf.extra_id IS NULL
)) AS observed_file_count
)) AS observed_file_count,
c.rerun_requested
FROM updated u
JOIN candidates c
ON c.media_folder_id = u.media_folder_id
AND c.observed_root_path = u.observed_root_path
LEFT JOIN observed_media_locations loc
ON loc.media_folder_id = u.media_folder_id
AND loc.observed_root_path = u.observed_root_path
ORDER BY u.observed_root_path ASC
`, folderID, pathPrefix, scopeLike, nullTime(attemptBefore), limit)
`, folderID, pathPrefix, scopeLike, nullTime(attemptBefore), limit, intervalLiteral(matchQueueClaimLease), leaseToken)
if err != nil {
return nil, fmt.Errorf("claiming series root queue rows by scope: %w", err)
}
defer rows.Close()
return scanSeriesRootJobs(rows)
jobs, err := scanSeriesRootJobs(rows)
if err != nil {
rows.Close()
return nil, r.releaseClaimAfterError(ctx, leaseToken, err)
}
for i := range jobs {
jobs[i].LeaseToken = leaseToken
}
return jobs, nil
}
func (r *SeriesRootMatchQueueRepository) Delete(ctx context.Context, folderID int, observedRootPath string) error {
func (r *SeriesRootMatchQueueRepository) Delete(ctx context.Context, folderID int, observedRootPath, leaseToken string) error {
if err := r.requireConfigured(); err != nil {
return err
}
@@ -510,10 +619,28 @@ func (r *SeriesRootMatchQueueRepository) Delete(ctx context.Context, folderID in
if strings.TrimSpace(observedRootPath) == "" {
return errors.New("observed root path is required")
}
if strings.TrimSpace(leaseToken) == "" {
return errors.New("lease token is required")
}
_, err := r.pool.Exec(ctx, `
WITH rerun AS (
UPDATE series_root_match_queue
SET available_at = NOW(),
lease_token = '',
lease_forced_rerun = false,
updated_at = NOW()
WHERE media_folder_id = $1
AND observed_root_path = $2
AND lease_token = $3
AND rerun_requested
RETURNING media_folder_id
)
DELETE FROM series_root_match_queue
WHERE media_folder_id = $1 AND observed_root_path = $2
`, folderID, filepath.Clean(observedRootPath))
WHERE media_folder_id = $1
AND observed_root_path = $2
AND lease_token = $3
AND NOT rerun_requested
`, folderID, filepath.Clean(observedRootPath), leaseToken)
if err != nil {
return fmt.Errorf("deleting series root queue row: %w", err)
}
@@ -537,7 +664,11 @@ func (r *SeriesRootMatchQueueRepository) DeleteByFolder(ctx context.Context, fol
return int(tag.RowsAffected()), nil
}
func (r *SeriesRootMatchQueueRepository) UpdateError(ctx context.Context, folderID int, observedRootPath string, errText string) error {
func (r *SeriesRootMatchQueueRepository) UpdateError(ctx context.Context, folderID int, observedRootPath, leaseToken, errText string) error {
return r.UpdateFailure(ctx, folderID, observedRootPath, leaseToken, MatchFailure{Kind: MatchOutcomeProviderTransient, Message: errText})
}
func (r *SeriesRootMatchQueueRepository) UpdateFailure(ctx context.Context, folderID int, observedRootPath, leaseToken string, failure MatchFailure) error {
if err := r.requireConfigured(); err != nil {
return err
}
@@ -547,19 +678,145 @@ func (r *SeriesRootMatchQueueRepository) UpdateError(ctx context.Context, folder
if strings.TrimSpace(observedRootPath) == "" {
return errors.New("observed root path is required")
}
_, err := r.pool.Exec(ctx, `
if strings.TrimSpace(leaseToken) == "" {
return errors.New("lease token is required")
}
kind := normalizeMatchFailureKind(failure.Kind)
message := boundedMatchFailureMessage(failure.Message)
detail, err := json.Marshal(map[string]any{"message": message, "decision": boundedMatchDecision(failure.Decision)})
if err != nil {
return fmt.Errorf("encoding series root queue failure: %w", err)
}
_, err = r.pool.Exec(ctx, `
UPDATE series_root_match_queue
SET last_error = $3,
available_at = `+matchQueueBackoffExpr("$4", "$5")+`,
SET last_error = CASE WHEN rerun_requested THEN last_error ELSE left($3, 2000) END,
failure_kind = CASE WHEN rerun_requested THEN failure_kind ELSE $4 END,
failure_detail = CASE WHEN rerun_requested THEN failure_detail ELSE $5::jsonb END,
deterministic_attempt_count = CASE
WHEN rerun_requested THEN deterministic_attempt_count
ELSE deterministic_attempt_count + CASE WHEN $4 = 'provider_transient' THEN 0 ELSE 1 END
END,
state = CASE
WHEN rerun_requested THEN 'pending'
WHEN $4 <> 'provider_transient' AND deterministic_attempt_count + 1 >= 3 THEN 'parked'
ELSE 'pending'
END,
parked_at = CASE
WHEN rerun_requested THEN NULL
WHEN $4 <> 'provider_transient' AND deterministic_attempt_count + 1 >= 3 THEN NOW()
ELSE NULL
END,
available_at = CASE
WHEN rerun_requested THEN NOW()
WHEN $4 = 'provider_transient' THEN `+matchQueueBackoffExpr("$6", "$7")+`
WHEN deterministic_attempt_count + 1 = 1 THEN NOW() + interval '1 hour'
ELSE NOW() + interval '24 hours'
END,
lease_token = '',
rerun_requested = rerun_requested OR lease_forced_rerun,
lease_forced_rerun = false,
updated_at = NOW()
WHERE media_folder_id = $1 AND observed_root_path = $2
`, folderID, filepath.Clean(observedRootPath), errText, intervalLiteral(seriesRootQueueRetryDelay), intervalLiteral(matchQueueRetryMaxDelay))
WHERE media_folder_id = $1 AND observed_root_path = $2 AND lease_token = $8
`, folderID, filepath.Clean(observedRootPath), message, kind, detail, intervalLiteral(seriesRootQueueRetryDelay), intervalLiteral(matchQueueRetryMaxDelay), leaseToken)
if err != nil {
return fmt.Errorf("updating series root queue error: %w", err)
}
return nil
}
func (r *SeriesRootMatchQueueRepository) RetryNowByFolder(ctx context.Context, folderID int) (int, error) {
if err := r.requireConfigured(); err != nil {
return 0, err
}
if err := requirePositiveSeriesQueueID("folder id", folderID); err != nil {
return 0, err
}
tag, err := r.pool.Exec(ctx, `
WITH current_inputs AS (
SELECT q.media_folder_id, q.observed_root_path,
`+seriesMatchQueueInputFingerprintSQL("q.observed_root_path", "q.media_folder_id", "folders.metadata_language")+` AS input_fingerprint
FROM series_root_match_queue q
JOIN media_folders folders ON folders.id = q.media_folder_id
WHERE q.media_folder_id = $1
)
UPDATE series_root_match_queue
SET state = 'pending',
available_at = CASE WHEN series_root_match_queue.lease_token = '' THEN NOW() ELSE series_root_match_queue.available_at END,
deterministic_attempt_count = 0,
failure_kind = '', failure_detail = '{}'::jsonb, last_error = '', parked_at = NULL,
rerun_requested = true,
input_fingerprint = current_inputs.input_fingerprint, matcher_revision = `+fmt.Sprintf("%d", matcherRevision)+`, updated_at = NOW()
FROM current_inputs
WHERE series_root_match_queue.media_folder_id = current_inputs.media_folder_id
AND series_root_match_queue.observed_root_path = current_inputs.observed_root_path
`, folderID)
if err != nil {
return 0, fmt.Errorf("retrying series root queue now: %w", err)
}
return int(tag.RowsAffected()), nil
}
func (r *SeriesRootMatchQueueRepository) WakeForChangedInputs(ctx context.Context) (int, error) {
if err := r.requireConfigured(); err != nil {
return 0, err
}
tag, err := r.pool.Exec(ctx, `
WITH changed AS (
SELECT q.media_folder_id, q.observed_root_path,
`+seriesMatchQueueInputFingerprintSQL("q.observed_root_path", "q.media_folder_id", "folders.metadata_language")+` AS input_fingerprint
FROM series_root_match_queue q
JOIN media_folders folders ON folders.id = q.media_folder_id
)
UPDATE series_root_match_queue q
SET state = 'pending',
available_at = CASE WHEN q.lease_token = '' THEN NOW() ELSE q.available_at END,
deterministic_attempt_count = 0,
failure_kind = '', failure_detail = '{}'::jsonb, last_error = '', parked_at = NULL,
rerun_requested = true,
input_fingerprint = changed.input_fingerprint, matcher_revision = `+fmt.Sprintf("%d", matcherRevision)+`, updated_at = NOW()
FROM changed
WHERE changed.media_folder_id = q.media_folder_id
AND changed.observed_root_path = q.observed_root_path
AND (q.input_fingerprint <> changed.input_fingerprint OR q.matcher_revision <> `+fmt.Sprintf("%d", matcherRevision)+`)
`)
if err != nil {
return 0, fmt.Errorf("waking series matches with changed inputs: %w", err)
}
return int(tag.RowsAffected()), nil
}
// ReleaseLease makes unfinished rows from a stopped batch immediately
// claimable without affecting rows whose ownership was revoked or replaced.
func (r *SeriesRootMatchQueueRepository) ReleaseLease(ctx context.Context, leaseToken string) (int, error) {
if err := r.requireConfigured(); err != nil {
return 0, err
}
if strings.TrimSpace(leaseToken) == "" {
return 0, errors.New("lease token is required")
}
tag, err := r.pool.Exec(ctx, `
UPDATE series_root_match_queue
SET available_at = NOW(), lease_token = '',
rerun_requested = rerun_requested OR lease_forced_rerun,
lease_forced_rerun = false,
attempt_count = GREATEST(attempt_count - 1, 0), updated_at = NOW()
WHERE lease_token = $1 AND state = 'pending'
`, leaseToken)
if err != nil {
return 0, fmt.Errorf("releasing series root match lease: %w", err)
}
return int(tag.RowsAffected()), nil
}
func (r *SeriesRootMatchQueueRepository) releaseClaimAfterError(ctx context.Context, leaseToken string, claimErr error) error {
releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
if _, releaseErr := r.ReleaseLease(releaseCtx, leaseToken); releaseErr != nil {
return errors.Join(claimErr, releaseErr)
}
return claimErr
}
func (r *SeriesRootMatchQueueRepository) ListByFolder(ctx context.Context, folderID int, limit int, offset int) ([]models.SeriesRootMatchQueueEntry, int, error) {
if err := r.requireConfigured(); err != nil {
return nil, 0, err
@@ -576,10 +833,11 @@ func (r *SeriesRootMatchQueueRepository) ListByFolder(ctx context.Context, folde
}
rows, err := r.pool.Query(ctx, `
SELECT media_folder_id, observed_root_path, first_queued_at, available_at, last_attempted_at, attempt_count, last_error, updated_at
SELECT media_folder_id, observed_root_path, first_queued_at, available_at, last_attempted_at, attempt_count, last_error,
state, failure_kind, failure_detail, deterministic_attempt_count, input_fingerprint, matcher_revision, parked_at, updated_at
FROM series_root_match_queue
WHERE media_folder_id = $1
ORDER BY available_at ASC, last_attempted_at ASC NULLS FIRST, observed_root_path ASC
ORDER BY (state = 'parked') DESC, available_at ASC, last_attempted_at ASC NULLS FIRST, observed_root_path ASC
LIMIT $2 OFFSET $3
`, folderID, limit, offset)
if err != nil {
@@ -598,6 +856,13 @@ func (r *SeriesRootMatchQueueRepository) ListByFolder(ctx context.Context, folde
&entry.LastAttemptedAt,
&entry.AttemptCount,
&entry.LastError,
&entry.State,
&entry.FailureKind,
&entry.FailureDetail,
&entry.DeterministicAttemptCount,
&entry.InputFingerprint,
&entry.MatcherRevision,
&entry.ParkedAt,
&entry.UpdatedAt,
); err != nil {
return nil, 0, fmt.Errorf("scanning series root queue row: %w", err)
@@ -626,6 +891,74 @@ func (r *SeriesRootMatchQueueRepository) CountByFolder(ctx context.Context, fold
return total, nil
}
func (r *SeriesRootMatchQueueRepository) CountStatesByFolder(ctx context.Context, folderID int) (int, int, error) {
if err := r.requireConfigured(); err != nil {
return 0, 0, err
}
if err := requirePositiveSeriesQueueID("folder id", folderID); err != nil {
return 0, 0, err
}
var pending, parked int
if err := r.pool.QueryRow(ctx, `
SELECT
COUNT(*) FILTER (WHERE state = 'pending'),
COUNT(*) FILTER (WHERE state = 'parked')
FROM series_root_match_queue
WHERE media_folder_id = $1
`, folderID).Scan(&pending, &parked); err != nil {
return 0, 0, fmt.Errorf("counting series queue states: %w", err)
}
return pending, parked, nil
}
// CountStatesByFolders returns queue aggregates for every requested library in
// one query. Libraries without rows are omitted from the result map.
func (r *SeriesRootMatchQueueRepository) CountStatesByFolders(ctx context.Context, folderIDs []int) (map[int]MatchQueueStateCounts, error) {
if err := r.requireConfigured(); err != nil {
return nil, err
}
counts := make(map[int]MatchQueueStateCounts, len(folderIDs))
if len(folderIDs) == 0 {
return counts, nil
}
rows, err := r.pool.Query(ctx, `
SELECT
media_folder_id,
COUNT(*) FILTER (WHERE state = 'pending'),
COUNT(*) FILTER (WHERE state = 'parked')
FROM series_root_match_queue
WHERE media_folder_id = ANY($1)
GROUP BY media_folder_id
`, folderIDs)
if err != nil {
return nil, fmt.Errorf("counting series queue states by folders: %w", err)
}
defer rows.Close()
for rows.Next() {
var folderID int
var count MatchQueueStateCounts
if err := rows.Scan(&folderID, &count.Pending, &count.Parked); err != nil {
return nil, fmt.Errorf("scanning series queue state counts: %w", err)
}
counts[folderID] = count
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating series queue state counts: %w", err)
}
return counts, nil
}
func (r *SeriesRootMatchQueueRepository) CountByFolderAndState(ctx context.Context, folderID int, state string) (int, error) {
if err := r.requireConfigured(); err != nil {
return 0, err
}
var total int
if err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM series_root_match_queue WHERE media_folder_id = $1 AND state = $2`, folderID, state).Scan(&total); err != nil {
return 0, fmt.Errorf("counting series root queue state: %w", err)
}
return total, nil
}
func scanSeriesRootJobs(rows pgx.Rows) ([]models.SeriesRootMatchJob, error) {
jobs := make([]models.SeriesRootMatchJob, 0)
for rows.Next() {
@@ -635,6 +968,7 @@ func scanSeriesRootJobs(rows pgx.Rows) ([]models.SeriesRootMatchJob, error) {
&job.ObservedRootPath,
&job.SampleFilePath,
&job.ObservedFileCount,
&job.RerunRequested,
); err != nil {
return nil, fmt.Errorf("scanning series root job: %w", err)
}
+257 -87
View File
@@ -53,6 +53,9 @@ func (w *MatchWorker) SetConcurrency(workers, batchSize int) {
func (w *MatchWorker) workerCount() int { return int(w.workers.Load()) }
func (w *MatchWorker) claimBatchSize() int { return int(w.batchSize.Load()) }
func (w *MatchWorker) queueClaimSize() int {
return min(w.workerCount(), w.claimBatchSize())
}
type NonSeriesFileClaimer interface {
ClaimUnmatchedNonSeries(ctx context.Context, limit int) ([]*models.MediaFile, error)
@@ -69,17 +72,21 @@ type MatchSuppressionChecker interface {
}
type MovieFileClaimer interface {
Claim(ctx context.Context, limit int) ([]*models.MediaFile, error)
ClaimByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]*models.MediaFile, error)
Delete(ctx context.Context, mediaFileID int) error
UpdateError(ctx context.Context, mediaFileID int, errText string) error
Claim(ctx context.Context, limit int) ([]models.MovieMatchJob, error)
ClaimByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]models.MovieMatchJob, error)
Delete(ctx context.Context, mediaFileID int, leaseToken string) error
UpdateError(ctx context.Context, mediaFileID int, leaseToken, errText string) error
UpdateFailure(ctx context.Context, mediaFileID int, leaseToken string, failure MatchFailure) error
ReleaseLease(ctx context.Context, leaseToken string) (int, error)
}
type SeriesRootClaimer interface {
Claim(ctx context.Context, limit int) ([]models.SeriesRootMatchJob, error)
ClaimByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]models.SeriesRootMatchJob, error)
Delete(ctx context.Context, folderID int, observedRootPath string) error
UpdateError(ctx context.Context, folderID int, observedRootPath string, errText string) error
Delete(ctx context.Context, folderID int, observedRootPath, leaseToken string) error
UpdateError(ctx context.Context, folderID int, observedRootPath, leaseToken, errText string) error
UpdateFailure(ctx context.Context, folderID int, observedRootPath, leaseToken string, failure MatchFailure) error
ReleaseLease(ctx context.Context, leaseToken string) (int, error)
ListByFolder(ctx context.Context, folderID int, limit int, offset int) ([]models.SeriesRootMatchQueueEntry, int, error)
CountByFolder(ctx context.Context, folderID int) (int, error)
}
@@ -152,25 +159,11 @@ func (w *MatchWorker) Run(ctx context.Context) {
// processUnmatched fetches a batch of unmatched files and processes them.
func (w *MatchWorker) processUnmatched(ctx context.Context) {
if w.enableTVSeriesRootQueue && w.seriesClaimer != nil {
jobs, err := w.seriesClaimer.Claim(ctx, w.claimBatchSize())
if err != nil {
slog.ErrorContext(ctx, "metadata: failed to claim unmatched series roots", "component", "metadata", "error", err)
} else if len(jobs) > 0 {
slog.InfoContext(ctx, "metadata: processing unmatched series roots", "component", "metadata", "count", len(jobs))
if _, err := w.processSeriesRoots(ctx, jobs); err != nil {
slog.ErrorContext(ctx, "metadata: failed to process unmatched series roots", "component", "metadata", "error", err)
}
}
w.processBackgroundSeriesQueue(ctx)
}
if w.movieClaimer != nil {
files, err := w.movieClaimer.Claim(ctx, w.claimBatchSize())
if err != nil {
slog.ErrorContext(ctx, "metadata: failed to claim queued movie files", "component", "metadata", "error", err)
} else if len(files) > 0 {
slog.InfoContext(ctx, "metadata: processing queued movie files", "component", "metadata", "count", len(files))
w.processQueuedMovieFiles(ctx, files)
}
w.processBackgroundMovieQueue(ctx)
}
files, err := w.claimBackgroundFiles(ctx)
@@ -186,6 +179,51 @@ func (w *MatchWorker) processUnmatched(ctx context.Context) {
w.processFiles(ctx, files)
}
func (w *MatchWorker) processBackgroundSeriesQueue(ctx context.Context) {
remaining := w.claimBatchSize()
for remaining > 0 && ctx.Err() == nil {
claimLimit := min(w.queueClaimSize(), remaining)
jobs, err := w.seriesClaimer.Claim(ctx, claimLimit)
if err != nil {
slog.ErrorContext(ctx, "metadata: failed to claim unmatched series roots", "component", "metadata", "error", err)
return
}
if len(jobs) == 0 {
return
}
slog.InfoContext(ctx, "metadata: processing unmatched series roots", "component", "metadata", "count", len(jobs))
if _, err := w.processSeriesRoots(ctx, jobs); err != nil {
slog.ErrorContext(ctx, "metadata: failed to process unmatched series roots", "component", "metadata", "error", err)
return
}
remaining -= len(jobs)
if len(jobs) < claimLimit {
return
}
}
}
func (w *MatchWorker) processBackgroundMovieQueue(ctx context.Context) {
remaining := w.claimBatchSize()
for remaining > 0 && ctx.Err() == nil {
claimLimit := min(w.queueClaimSize(), remaining)
jobs, err := w.movieClaimer.Claim(ctx, claimLimit)
if err != nil {
slog.ErrorContext(ctx, "metadata: failed to claim queued movie files", "component", "metadata", "error", err)
return
}
if len(jobs) == 0 {
return
}
slog.InfoContext(ctx, "metadata: processing queued movie files", "component", "metadata", "count", len(jobs))
w.processQueuedMovieFiles(ctx, jobs)
remaining -= len(jobs)
if len(jobs) < claimLimit {
return
}
}
}
func (w *MatchWorker) processFile(ctx context.Context, file *models.MediaFile) {
if w.fileLister != nil {
defer func() {
@@ -429,21 +467,49 @@ func (w *MatchWorker) ProcessFile(ctx context.Context, file *models.MediaFile) {
// number of files processed.
func (w *MatchWorker) ProcessBatch(ctx context.Context) (processed int, err error) {
if w.enableTVSeriesRootQueue && w.seriesClaimer != nil {
jobs, err := w.seriesClaimer.Claim(ctx, w.claimBatchSize())
if err != nil {
return 0, err
claimed := 0
for claimed < w.claimBatchSize() {
claimLimit := min(w.queueClaimSize(), w.claimBatchSize()-claimed)
jobs, err := w.seriesClaimer.Claim(ctx, claimLimit)
if err != nil {
return processed, err
}
if len(jobs) == 0 {
break
}
claimed += len(jobs)
batchProcessed, err := w.processSeriesRoots(ctx, jobs)
processed += batchProcessed
if err != nil {
return processed, err
}
if len(jobs) < claimLimit {
break
}
}
if len(jobs) > 0 {
return w.processSeriesRoots(ctx, jobs)
if claimed > 0 {
return processed, nil
}
}
if w.movieClaimer != nil {
files, err := w.movieClaimer.Claim(ctx, w.claimBatchSize())
if err != nil {
return 0, err
claimed := 0
for claimed < w.claimBatchSize() {
claimLimit := min(w.queueClaimSize(), w.claimBatchSize()-claimed)
jobs, err := w.movieClaimer.Claim(ctx, claimLimit)
if err != nil {
return processed, err
}
if len(jobs) == 0 {
break
}
claimed += len(jobs)
processed += w.processQueuedMovieFiles(ctx, jobs)
if len(jobs) < claimLimit {
break
}
}
if len(files) > 0 {
return w.processQueuedMovieFiles(ctx, files), nil
if claimed > 0 {
return processed, nil
}
}
@@ -466,28 +532,51 @@ func (w *MatchWorker) ProcessBatchByFolderAndPathPrefix(ctx context.Context, fol
return 0, err
}
if useSeriesQueue {
jobs, err := w.seriesClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.claimBatchSize(), attemptBefore)
if err != nil {
return 0, err
claimed := 0
for claimed < w.claimBatchSize() {
claimLimit := min(w.queueClaimSize(), w.claimBatchSize()-claimed)
jobs, err := w.seriesClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, claimLimit, attemptBefore)
if err != nil {
return processed, err
}
if len(jobs) == 0 {
break
}
claimed += len(jobs)
batchProcessed, err := w.processSeriesRoots(ctx, jobs)
processed += batchProcessed
if err != nil {
return processed, err
}
if len(jobs) < claimLimit {
break
}
}
processed, err := w.processSeriesRoots(ctx, jobs)
if err != nil || processed > 0 {
return processed, err
if processed > 0 {
return processed, nil
}
}
if useSeriesQueue && !useMovieQueue {
return processed, nil
}
if useMovieQueue {
files, err := w.movieClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.claimBatchSize(), attemptBefore)
if err != nil {
return 0, err
claimed := 0
for claimed < w.claimBatchSize() {
claimLimit := min(w.queueClaimSize(), w.claimBatchSize()-claimed)
jobs, err := w.movieClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, claimLimit, attemptBefore)
if err != nil {
return processed, err
}
if len(jobs) == 0 {
break
}
claimed += len(jobs)
processed += w.processQueuedMovieFiles(ctx, jobs)
if len(jobs) < claimLimit {
break
}
}
processed := w.processQueuedMovieFiles(ctx, files)
if processed > 0 {
return processed, nil
}
if !useSeriesQueue {
if processed > 0 || !useSeriesQueue {
return processed, nil
}
}
@@ -521,7 +610,7 @@ func (w *MatchWorker) ProcessAllByFolderAndPathPrefix(ctx context.Context, folde
}
if useSeriesQueue {
jobs, err := w.seriesClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.claimBatchSize(), attemptBefore)
jobs, err := w.seriesClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.queueClaimSize(), attemptBefore)
if err != nil {
return processed, err
}
@@ -535,12 +624,12 @@ func (w *MatchWorker) ProcessAllByFolderAndPathPrefix(ctx context.Context, folde
}
}
if useMovieQueue {
files, err := w.movieClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.claimBatchSize(), attemptBefore)
jobs, err := w.movieClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.queueClaimSize(), attemptBefore)
if err != nil {
return processed, err
}
if len(files) > 0 {
batchProcessed := w.processQueuedMovieFiles(ctx, files)
if len(jobs) > 0 {
batchProcessed := w.processQueuedMovieFiles(ctx, jobs)
processed += batchProcessed
continue
}
@@ -641,16 +730,17 @@ func (w *MatchWorker) isMatchSuppressed(ctx context.Context, file *models.MediaF
return suppressed
}
func (w *MatchWorker) processQueuedMovieFiles(ctx context.Context, files []*models.MediaFile) int {
if len(files) == 0 {
func (w *MatchWorker) processQueuedMovieFiles(ctx context.Context, jobs []models.MovieMatchJob) int {
if len(jobs) == 0 {
return 0
}
defer w.releaseMovieLeases(jobs)
fileChan := make(chan *models.MediaFile, len(files))
for _, f := range files {
fileChan <- f
jobChan := make(chan models.MovieMatchJob, len(jobs))
for _, job := range jobs {
jobChan <- job
}
close(fileChan)
close(jobChan)
var (
wg sync.WaitGroup
@@ -659,11 +749,11 @@ func (w *MatchWorker) processQueuedMovieFiles(ctx context.Context, files []*mode
)
for i := 0; i < w.workerCount(); i++ {
wg.Go(func() {
for file := range fileChan {
for job := range jobChan {
if ctx.Err() != nil {
return
}
if w.processQueuedMovieFile(ctx, file, &folders) {
if w.processQueuedMovieFile(ctx, job, &folders) {
processed.Add(1)
}
}
@@ -674,7 +764,28 @@ func (w *MatchWorker) processQueuedMovieFiles(ctx context.Context, files []*mode
return int(processed.Load())
}
func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.MediaFile, folderEnabledCache *sync.Map) bool {
func (w *MatchWorker) releaseMovieLeases(jobs []models.MovieMatchJob) {
if w == nil || w.movieClaimer == nil {
return
}
tokens := make(map[string]struct{})
for _, job := range jobs {
if token := strings.TrimSpace(job.LeaseToken); token != "" {
tokens[token] = struct{}{}
}
}
for token := range tokens {
releaseCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_, err := w.movieClaimer.ReleaseLease(releaseCtx, token)
cancel()
if err != nil {
slog.Warn("metadata: failed to release unfinished movie match lease", "component", "metadata", "error", err)
}
}
}
func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, job models.MovieMatchJob, folderEnabledCache *sync.Map) bool {
file := job.File
if file == nil || w == nil || w.service == nil || w.movieClaimer == nil {
return false
}
@@ -682,10 +793,10 @@ func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.M
return false
}
skeleton, reusedLinkedItem, err := w.queuedMovieSkeleton(ctx, file)
skeleton, reusedLinkedItem, err := w.queuedMovieSkeleton(ctx, file, job.RerunRequested)
if err != nil {
queueErr := truncateSeriesQueueError(err.Error())
if updateErr := w.movieClaimer.UpdateError(ctx, file.ID, queueErr); updateErr != nil {
if updateErr := w.movieClaimer.UpdateError(ctx, file.ID, job.LeaseToken, queueErr); updateErr != nil {
slog.WarnContext(ctx, "metadata: failed to update movie queue error", "component", "metadata",
"file_id", file.ID,
"path", file.FilePath,
@@ -705,7 +816,7 @@ func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.M
// Dequeue immediately; the recorded skipped root keeps the file out of
// future enqueues (see movieQueueFileEligibleCond), so this drains rows
// claimed before the skipped root was recorded.
if err := w.movieClaimer.Delete(ctx, file.ID); err != nil {
if err := w.movieClaimer.Delete(ctx, file.ID, job.LeaseToken); err != nil {
slog.WarnContext(ctx, "metadata: failed to delete skipped movie queue row", "component", "metadata",
"file_id", file.ID,
"path", file.FilePath,
@@ -716,7 +827,7 @@ func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.M
return true
}
if skeleton == nil || strings.TrimSpace(skeleton.ContentID) == "" {
if updateErr := w.movieClaimer.UpdateError(ctx, file.ID, truncateSeriesQueueError("movie queue claimed without a content id")); updateErr != nil {
if updateErr := w.movieClaimer.UpdateError(ctx, file.ID, job.LeaseToken, truncateSeriesQueueError("movie queue claimed without a content id")); updateErr != nil {
slog.WarnContext(ctx, "metadata: failed to update movie queue error", "component", "metadata",
"file_id", file.ID,
"path", file.FilePath,
@@ -726,7 +837,7 @@ func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.M
return false
}
if skeleton.ItemStatus == "ambiguous" {
if err := w.movieClaimer.Delete(ctx, file.ID); err != nil {
if err := w.movieClaimer.Delete(ctx, file.ID, job.LeaseToken); err != nil {
slog.WarnContext(ctx, "metadata: failed to delete ambiguous movie queue row", "component", "metadata",
"file_id", file.ID,
"path", file.FilePath,
@@ -742,7 +853,7 @@ func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.M
result, processErr := w.service.Process(ctx, req)
if processErr != nil {
queueErr := truncateSeriesQueueError(processErr.Error())
if updateErr := w.movieClaimer.UpdateError(ctx, file.ID, queueErr); updateErr != nil {
if updateErr := w.movieClaimer.UpdateError(ctx, file.ID, job.LeaseToken, queueErr); updateErr != nil {
slog.WarnContext(ctx, "metadata: failed to update movie queue error", "component", "metadata",
"file_id", file.ID,
"path", file.FilePath,
@@ -757,7 +868,7 @@ func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.M
w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched", "content_id", skeleton.ContentID, "file_id", file.ID, "path", file.FilePath)
return false
} else if result != nil && !result.Updated {
if updateErr := w.movieClaimer.UpdateError(ctx, file.ID, truncateSeriesQueueError(ErrMetadataNotFound.Error())); updateErr != nil {
if updateErr := w.updateMovieFailure(ctx, file.ID, job.LeaseToken, result.Decision); updateErr != nil {
slog.WarnContext(ctx, "metadata: failed to update movie queue error", "component", "metadata",
"file_id", file.ID,
"path", file.FilePath,
@@ -770,7 +881,7 @@ func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.M
w.publishCatalogItemChanged(ctx, file.MediaFolderID, resultContentID(result, skeleton.ContentID), "metadata_updated")
}
if err := w.movieClaimer.Delete(ctx, file.ID); err != nil {
if err := w.movieClaimer.Delete(ctx, file.ID, job.LeaseToken); err != nil {
slog.WarnContext(ctx, "metadata: failed to delete movie queue row", "component", "metadata",
"file_id", file.ID,
"path", file.FilePath,
@@ -781,8 +892,8 @@ func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.M
return true
}
func (w *MatchWorker) queuedMovieSkeleton(ctx context.Context, file *models.MediaFile) (*skeletonResult, bool, error) {
if skeleton, ok := w.reusableQueuedMovieSkeleton(ctx, file); ok {
func (w *MatchWorker) queuedMovieSkeleton(ctx context.Context, file *models.MediaFile, allowMatched bool) (*skeletonResult, bool, error) {
if skeleton, ok := w.reusableQueuedMovieSkeleton(ctx, file, allowMatched); ok {
return skeleton, true, nil
}
@@ -794,7 +905,7 @@ func (w *MatchWorker) queuedMovieSkeleton(ctx context.Context, file *models.Medi
return skeleton, false, nil
}
func (w *MatchWorker) reusableQueuedMovieSkeleton(ctx context.Context, file *models.MediaFile) (*skeletonResult, bool) {
func (w *MatchWorker) reusableQueuedMovieSkeleton(ctx context.Context, file *models.MediaFile, allowMatched bool) (*skeletonResult, bool) {
if w == nil || w.service == nil || w.service.itemRepo == nil || file == nil {
return nil, false
}
@@ -808,7 +919,10 @@ func (w *MatchWorker) reusableQueuedMovieSkeleton(ctx context.Context, file *mod
return nil, false
}
status := strings.ToLower(strings.TrimSpace(item.Status))
if !isSkeletonLikeStatus(status) && status != "ambiguous" {
reusableStatus := isSkeletonLikeStatus(status) ||
status == "ambiguous" ||
(allowMatched && status == string(MatchOutcomeMatched))
if !reusableStatus {
return nil, false
}
@@ -922,6 +1036,7 @@ func (w *MatchWorker) processSeriesRoots(ctx context.Context, jobs []models.Seri
if len(jobs) == 0 {
return 0, nil
}
defer w.releaseSeriesLeases(jobs)
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
@@ -964,6 +1079,26 @@ func (w *MatchWorker) processSeriesRoots(ctx context.Context, jobs []models.Seri
return int(processed.Load()), firstErr
}
func (w *MatchWorker) releaseSeriesLeases(jobs []models.SeriesRootMatchJob) {
if w == nil || w.seriesClaimer == nil {
return
}
tokens := make(map[string]struct{})
for _, job := range jobs {
if token := strings.TrimSpace(job.LeaseToken); token != "" {
tokens[token] = struct{}{}
}
}
for token := range tokens {
releaseCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_, err := w.seriesClaimer.ReleaseLease(releaseCtx, token)
cancel()
if err != nil {
slog.Warn("metadata: failed to release unfinished series match lease", "component", "metadata", "error", err)
}
}
}
func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRootMatchJob, folderEnabledCache *sync.Map) (int, error) {
if !w.folderEnabled(ctx, job.MediaFolderID, folderEnabledCache) {
return 0, nil
@@ -977,7 +1112,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
return 0, fmt.Errorf("loading files for series root %d/%s: %w", job.MediaFolderID, job.ObservedRootPath, err)
}
if len(groupFiles) == 0 {
if err := w.seriesClaimer.Delete(ctx, job.MediaFolderID, job.ObservedRootPath); err != nil {
if err := w.seriesClaimer.Delete(ctx, job.MediaFolderID, job.ObservedRootPath, job.LeaseToken); err != nil {
return 0, err
}
slog.InfoContext(ctx, "metadata: series root dropped because files disappeared", "component", "metadata",
@@ -989,19 +1124,19 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
representative := selectRepresentativeGroupFile(groupFiles)
if representative == nil {
if err := w.seriesClaimer.Delete(ctx, job.MediaFolderID, job.ObservedRootPath); err != nil {
if err := w.seriesClaimer.Delete(ctx, job.MediaFolderID, job.ObservedRootPath, job.LeaseToken); err != nil {
return 0, err
}
return 0, nil
}
if !hasUnlinkedGroupFile(groupFiles) {
if strings.TrimSpace(representative.ContentID) != "" {
if skeleton, ok := w.reusableQueuedMovieSkeleton(ctx, representative); ok && skeleton.ItemStatus != "ambiguous" {
if skeleton, ok := w.reusableQueuedMovieSkeleton(ctx, representative, job.RerunRequested); ok && skeleton.ItemStatus != "ambiguous" {
req := w.buildProcessRequestForGroup(ctx, representative, skeleton, groupFiles)
result, processErr := w.service.Process(ctx, req)
if processErr != nil {
queueErr := truncateSeriesQueueError(processErr.Error())
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, queueErr); updateErr != nil {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, job.LeaseToken, queueErr); updateErr != nil {
return 0, updateErr
}
slog.WarnContext(ctx, "metadata: enrichment failed", "component", "metadata",
@@ -1019,7 +1154,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
return 0, nil
}
if result != nil && !result.Updated {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(ErrMetadataNotFound.Error())); updateErr != nil {
if updateErr := w.updateSeriesFailure(ctx, job.MediaFolderID, job.ObservedRootPath, job.LeaseToken, result.Decision); updateErr != nil {
return 0, updateErr
}
w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched",
@@ -1045,14 +1180,14 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
"folder_id", job.MediaFolderID,
"observed_root_path", job.ObservedRootPath)
} else {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, job.LeaseToken, truncateSeriesQueueError(err.Error())); updateErr != nil {
return 0, updateErr
}
return 0, fmt.Errorf("ensuring series episode links for %s: %w", representative.ContentID, err)
}
}
}
if err := w.seriesClaimer.Delete(ctx, job.MediaFolderID, job.ObservedRootPath); err != nil {
if err := w.seriesClaimer.Delete(ctx, job.MediaFolderID, job.ObservedRootPath, job.LeaseToken); err != nil {
return 0, err
}
return len(groupFiles), nil
@@ -1062,7 +1197,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
skeleton, err := w.service.createOrFindSkeleton(ctx, currentRepresentative, job.MediaFolderID)
if err != nil {
queueErr := truncateSeriesQueueError(err.Error())
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, queueErr); updateErr != nil {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, job.LeaseToken, queueErr); updateErr != nil {
return 0, updateErr
}
slog.WarnContext(ctx, "metadata: series root skeleton creation failed", "component", "metadata",
@@ -1078,18 +1213,18 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
}
if _, err := w.service.fileRepo.UpdateContentIDByObservedRootPath(ctx, job.MediaFolderID, job.ObservedRootPath, skeleton.ContentID); err != nil {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, job.LeaseToken, truncateSeriesQueueError(err.Error())); updateErr != nil {
return 0, updateErr
}
return 0, fmt.Errorf("relinking series root %d/%s: %w", job.MediaFolderID, job.ObservedRootPath, err)
}
needsInitialMatch := skeleton.IsNew
needsInitialMatch := skeleton.IsNew || job.RerunRequested
if !needsInitialMatch && strings.TrimSpace(skeleton.ContentID) != "" && w.service.itemRepo != nil {
item, loadErr := w.service.itemRepo.GetByID(ctx, skeleton.ContentID)
if loadErr != nil {
queueErr := truncateSeriesQueueError(loadErr.Error())
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, queueErr); updateErr != nil {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, job.LeaseToken, queueErr); updateErr != nil {
return 0, updateErr
}
return 0, fmt.Errorf("loading linked series skeleton %s: %w", skeleton.ContentID, loadErr)
@@ -1104,7 +1239,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
result, processErr := w.service.Process(ctx, req)
if processErr != nil {
queueErr := truncateSeriesQueueError(processErr.Error())
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, queueErr); updateErr != nil {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, job.LeaseToken, queueErr); updateErr != nil {
return 0, updateErr
}
slog.WarnContext(ctx, "metadata: enrichment failed", "component", "metadata",
@@ -1126,7 +1261,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
}
return 0, nil
} else if result != nil && !result.Updated {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(ErrMetadataNotFound.Error())); updateErr != nil {
if updateErr := w.updateSeriesFailure(ctx, job.MediaFolderID, job.ObservedRootPath, job.LeaseToken, result.Decision); updateErr != nil {
return 0, updateErr
}
w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched",
@@ -1149,7 +1284,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
finalContentID, err := w.service.fileRepo.FindContentIDByObservedRootPath(ctx, job.MediaFolderID, job.ObservedRootPath, "series")
if err != nil {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, job.LeaseToken, truncateSeriesQueueError(err.Error())); updateErr != nil {
return 0, updateErr
}
return 0, fmt.Errorf("resolving final content for series root %d/%s: %w", job.MediaFolderID, job.ObservedRootPath, err)
@@ -1165,7 +1300,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
"folder_id", job.MediaFolderID,
"observed_root_path", job.ObservedRootPath)
} else {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil {
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, job.LeaseToken, truncateSeriesQueueError(err.Error())); updateErr != nil {
return 0, updateErr
}
return 0, fmt.Errorf("ensuring series episode links for %s: %w", finalContentID, err)
@@ -1176,7 +1311,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
}
}
if err := w.seriesClaimer.Delete(ctx, job.MediaFolderID, job.ObservedRootPath); err != nil {
if err := w.seriesClaimer.Delete(ctx, job.MediaFolderID, job.ObservedRootPath, job.LeaseToken); err != nil {
return 0, err
}
@@ -1189,6 +1324,41 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo
return len(groupFiles), nil
}
func matchFailureFromDecision(decision *MatchDecision) MatchFailure {
kind := MatchOutcomeMetadataEmpty
if decision != nil && strings.TrimSpace(string(decision.Outcome)) != "" {
kind = decision.Outcome
}
message := ErrMetadataNotFound.Error()
if decision != nil {
switch decision.Outcome {
case MatchOutcomeNoCandidates:
message = "no provider candidates returned"
case MatchOutcomeCandidateRejected:
message = "provider candidates did not meet the automatic match threshold"
case MatchOutcomeTrustedIDConflict:
message = "provider candidates conflicted with a trusted external ID"
case MatchOutcomeTrustedIDTypeMismatch:
message = "trusted external ID resolves to the opposite library type"
case MatchOutcomeMetadataEmpty:
message = "selected providers returned no usable metadata"
case MatchOutcomeProviderTransient:
message = "metadata provider is temporarily unavailable"
case MatchOutcomeProviderPermanent:
message = "metadata provider rejected the request permanently"
}
}
return MatchFailure{Kind: kind, Message: message, Decision: decision}
}
func (w *MatchWorker) updateMovieFailure(ctx context.Context, mediaFileID int, leaseToken string, decision *MatchDecision) error {
return w.movieClaimer.UpdateFailure(ctx, mediaFileID, leaseToken, matchFailureFromDecision(decision))
}
func (w *MatchWorker) updateSeriesFailure(ctx context.Context, folderID int, observedRootPath, leaseToken string, decision *MatchDecision) error {
return w.seriesClaimer.UpdateFailure(ctx, folderID, observedRootPath, leaseToken, matchFailureFromDecision(decision))
}
func (w *MatchWorker) logStatusUpdateFailure(ctx context.Context, contentID, status string, attrs ...any) {
if w == nil || w.service == nil {
return
+182 -11
View File
@@ -14,6 +14,18 @@ import (
const queuedShowName = "Show Name"
func TestQueueClaimSizeNeverExceedsAvailableWorkers(t *testing.T) {
worker := NewMatchWorker(nil, nil, 8, 500, time.Second)
if got := worker.queueClaimSize(); got != 8 {
t.Fatalf("queue claim size = %d, want 8 workers", got)
}
worker.SetConcurrency(32, 4)
if got := worker.queueClaimSize(); got != 4 {
t.Fatalf("queue claim size = %d, want batch cap 4", got)
}
}
type fakeWorkerFolderRepo struct {
folders map[int]*models.MediaFolder
}
@@ -34,6 +46,7 @@ type fakeSeriesQueueRepo struct {
claimCalls int
scopedClaimCalls int
claimErr error
releasedLeases []string
}
func newFakeSeriesQueueRepo(jobs ...models.SeriesRootMatchJob) *fakeSeriesQueueRepo {
@@ -68,6 +81,7 @@ func (r *fakeSeriesQueueRepo) ClaimByFolderAndPathPrefix(_ context.Context, fold
if claimedAt := r.lastAttemptedAt[key]; !attemptBefore.IsZero() && !claimedAt.IsZero() && !claimedAt.Before(attemptBefore) {
continue
}
job.LeaseToken = "fake-series-lease"
out = append(out, job)
r.lastAttemptedAt[key] = time.Now().UTC()
if limit > 0 && len(out) >= limit {
@@ -82,14 +96,15 @@ func (r *fakeSeriesQueueRepo) claim(limit int, _ time.Time) ([]models.SeriesRoot
limit = len(r.jobs)
}
out := append([]models.SeriesRootMatchJob(nil), r.jobs[:limit]...)
for _, job := range out {
for i, job := range out {
out[i].LeaseToken = "fake-series-lease"
key := fmt.Sprintf("%d:%s", job.MediaFolderID, job.ObservedRootPath)
r.lastAttemptedAt[key] = time.Now().UTC()
}
return out, nil
}
func (r *fakeSeriesQueueRepo) Delete(_ context.Context, folderID int, observedRootPath string) error {
func (r *fakeSeriesQueueRepo) Delete(_ context.Context, folderID int, observedRootPath, _ string) error {
r.deleted[fmt.Sprintf("%d:%s", folderID, observedRootPath)] = struct{}{}
filtered := r.jobs[:0]
for _, job := range r.jobs {
@@ -102,11 +117,20 @@ func (r *fakeSeriesQueueRepo) Delete(_ context.Context, folderID int, observedRo
return nil
}
func (r *fakeSeriesQueueRepo) UpdateError(_ context.Context, folderID int, observedRootPath string, errText string) error {
func (r *fakeSeriesQueueRepo) UpdateError(_ context.Context, folderID int, observedRootPath, _ string, errText string) error {
r.errors[fmt.Sprintf("%d:%s", folderID, observedRootPath)] = errText
return nil
}
func (r *fakeSeriesQueueRepo) UpdateFailure(ctx context.Context, folderID int, observedRootPath, leaseToken string, failure MatchFailure) error {
return r.UpdateError(ctx, folderID, observedRootPath, leaseToken, failure.Message)
}
func (r *fakeSeriesQueueRepo) ReleaseLease(_ context.Context, leaseToken string) (int, error) {
r.releasedLeases = append(r.releasedLeases, leaseToken)
return 1, nil
}
func (r *fakeSeriesQueueRepo) ListByFolder(_ context.Context, folderID int, limit int, offset int) ([]models.SeriesRootMatchQueueEntry, int, error) {
out := make([]models.SeriesRootMatchQueueEntry, 0)
for _, job := range r.jobs {
@@ -147,6 +171,7 @@ type fakeMovieQueueRepo struct {
claimCalls int
scopedClaimCalls int
claimErr error
releasedLeases []string
}
func newFakeMovieQueueRepo(files ...*models.MediaFile) *fakeMovieQueueRepo {
@@ -166,7 +191,7 @@ func newFakeMovieQueueRepo(files ...*models.MediaFile) *fakeMovieQueueRepo {
}
}
func (r *fakeMovieQueueRepo) Claim(_ context.Context, limit int) ([]*models.MediaFile, error) {
func (r *fakeMovieQueueRepo) Claim(_ context.Context, limit int) ([]models.MovieMatchJob, error) {
r.claimCalls++
if r.claimErr != nil {
return nil, r.claimErr
@@ -174,13 +199,13 @@ func (r *fakeMovieQueueRepo) Claim(_ context.Context, limit int) ([]*models.Medi
return r.claim(limit, 0, "", time.Time{})
}
func (r *fakeMovieQueueRepo) ClaimByFolderAndPathPrefix(_ context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]*models.MediaFile, error) {
func (r *fakeMovieQueueRepo) ClaimByFolderAndPathPrefix(_ context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]models.MovieMatchJob, error) {
r.scopedClaimCalls++
return r.claim(limit, folderID, pathPrefix, attemptBefore)
}
func (r *fakeMovieQueueRepo) claim(limit int, folderID int, pathPrefix string, attemptBefore time.Time) ([]*models.MediaFile, error) {
out := make([]*models.MediaFile, 0, len(r.files))
func (r *fakeMovieQueueRepo) claim(limit int, folderID int, pathPrefix string, attemptBefore time.Time) ([]models.MovieMatchJob, error) {
out := make([]models.MovieMatchJob, 0, len(r.files))
for _, file := range r.files {
if file == nil {
continue
@@ -195,7 +220,7 @@ func (r *fakeMovieQueueRepo) claim(limit int, folderID int, pathPrefix string, a
continue
}
fileCopy := *file
out = append(out, &fileCopy)
out = append(out, models.MovieMatchJob{File: &fileCopy, LeaseToken: "fake-movie-lease"})
r.lastAttemptedAt[file.ID] = time.Now().UTC()
if limit > 0 && len(out) >= limit {
break
@@ -204,7 +229,7 @@ func (r *fakeMovieQueueRepo) claim(limit int, folderID int, pathPrefix string, a
return out, nil
}
func (r *fakeMovieQueueRepo) Delete(_ context.Context, mediaFileID int) error {
func (r *fakeMovieQueueRepo) Delete(_ context.Context, mediaFileID int, _ string) error {
r.deleted[mediaFileID] = struct{}{}
filtered := r.files[:0]
for _, file := range r.files {
@@ -217,11 +242,66 @@ func (r *fakeMovieQueueRepo) Delete(_ context.Context, mediaFileID int) error {
return nil
}
func (r *fakeMovieQueueRepo) UpdateError(_ context.Context, mediaFileID int, errText string) error {
func (r *fakeMovieQueueRepo) UpdateError(_ context.Context, mediaFileID int, _ string, errText string) error {
r.errors[mediaFileID] = errText
return nil
}
func (r *fakeMovieQueueRepo) UpdateFailure(ctx context.Context, mediaFileID int, leaseToken string, failure MatchFailure) error {
return r.UpdateError(ctx, mediaFileID, leaseToken, failure.Message)
}
func (r *fakeMovieQueueRepo) ReleaseLease(_ context.Context, leaseToken string) (int, error) {
r.releasedLeases = append(r.releasedLeases, leaseToken)
return 1, nil
}
func TestProcessQueuedMovieFiles_ReleasesUnfinishedBatchOnCancellation(t *testing.T) {
const leaseToken = "movie-batch"
repo := newFakeMovieQueueRepo()
worker := NewMatchWorker(nil, nil, 2, 10, 0)
worker.movieClaimer = repo
ctx, cancel := context.WithCancel(context.Background())
cancel()
processed := worker.processQueuedMovieFiles(ctx, []models.MovieMatchJob{
{File: &models.MediaFile{ID: 1}, LeaseToken: leaseToken},
{File: &models.MediaFile{ID: 2}, LeaseToken: leaseToken},
})
if processed != 0 {
t.Fatalf("processed = %d, want 0", processed)
}
if got := repo.releasedLeases; len(got) != 1 || got[0] != leaseToken {
t.Fatalf("released leases = %v, want [%s]", got, leaseToken)
}
}
func TestProcessSeriesRoots_ReleasesUnfinishedBatchOnCancellation(t *testing.T) {
const leaseToken = "series-batch"
repo := newFakeSeriesQueueRepo()
worker := NewMatchWorker(nil, nil, 2, 10, 0)
worker.seriesClaimer = repo
ctx, cancel := context.WithCancel(context.Background())
cancel()
processed, err := worker.processSeriesRoots(ctx, []models.SeriesRootMatchJob{
{MediaFolderID: 1, ObservedRootPath: "/shows/One", LeaseToken: leaseToken},
{MediaFolderID: 1, ObservedRootPath: "/shows/Two", LeaseToken: leaseToken},
})
if err != nil {
t.Fatalf("process series roots: %v", err)
}
if processed != 0 {
t.Fatalf("processed = %d, want 0", processed)
}
if got := repo.releasedLeases; len(got) != 1 || got[0] != leaseToken {
t.Fatalf("released leases = %v, want [%s]", got, leaseToken)
}
}
// TestWorkerProcessFile_SkeletonCreatedForNoFolderIDs verifies that the worker
// processes files under roots without folder IDs and creates skeleton items
// (no longer skipping them).
@@ -1204,6 +1284,97 @@ func TestWorkerProcessBatchByFolderAndPathPrefix_MovieQueueClaimsOnlyOncePerScan
}
}
func TestWorkerProcessBatchByFolderAndPathPrefix_ZeroResultSeriesClaimFallsThroughToMovie(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "mixed", Enabled: true},
},
}
pathPrefix := "/media/mixed/Example"
seriesQueue := newFakeSeriesQueueRepo(models.SeriesRootMatchJob{
MediaFolderID: 10,
ObservedRootPath: pathPrefix + "/Missing Show",
SampleFilePath: pathPrefix + "/Missing Show/Show S01E01.mkv",
})
movieFile := &models.MediaFile{
ID: 2,
MediaFolderID: 10,
FilePath: pathPrefix + "/Movie (2026)/Movie.mkv",
BaseType: "movie",
}
movieQueue := newFakeMovieQueueRepo(movieFile)
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
return &ProcessResult{Updated: true}, nil
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 1, 0)
worker.SetSeriesRootClaimer(seriesQueue, true)
worker.SetMovieFileClaimer(movieQueue)
_, err := worker.ProcessBatchByFolderAndPathPrefix(ctx, 10, pathPrefix, time.Now().UTC())
if err != nil {
t.Fatalf("ProcessBatchByFolderAndPathPrefix error = %v", err)
}
if movieQueue.scopedClaimCalls == 0 {
t.Fatal("zero-result series claim suppressed the movie queue")
}
if movieQueue.lastAttemptedAt[movieFile.ID].IsZero() {
t.Fatal("movie fallback job was not claimed")
}
}
func TestWorkerProcessBatchByFolderAndPathPrefix_ZeroResultMovieClaimFallsThroughToRaw(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "mixed", Enabled: true},
},
}
pathPrefix := "/media/mixed/Example"
queuedFile := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: pathPrefix + "/Queued Movie (2026)/Queued.mkv",
BaseType: "movie",
}
rawFile := &models.MediaFile{
ID: 2,
MediaFolderID: 10,
FilePath: pathPrefix + "/Raw Movie (2025)/Raw.mkv",
BaseType: "movie",
GroupKeyVersion: 1,
ContentGroupKey: "v1|movie|raw_movie|2025",
}
h.fileRepo.setGroupFiles(10, rawFile.GroupKeyVersion, rawFile.ContentGroupKey, rawFile)
processCalls := 0
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
processCalls++
if processCalls == 1 {
return nil, ErrMetadataNotFound
}
return &ProcessResult{Updated: true}, nil
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 1, 0)
worker.SetSeriesRootClaimer(newFakeSeriesQueueRepo(), true)
worker.SetMovieFileClaimer(newFakeMovieQueueRepo(queuedFile))
processed, err := worker.ProcessBatchByFolderAndPathPrefix(ctx, 10, pathPrefix, time.Now().UTC())
if err != nil {
t.Fatalf("ProcessBatchByFolderAndPathPrefix error = %v", err)
}
if processed != 1 {
t.Fatalf("processed = %d, want raw fallback result", processed)
}
if h.fileRepo.claimMixedCalls == 0 {
t.Fatal("zero-result movie claim suppressed the raw mixed fallback")
}
if processCalls != 2 {
t.Fatalf("process calls = %d, want queued attempt plus raw fallback", processCalls)
}
}
// ---------------------------------------------------------------------------
// Tests for concurrent-merge ErrItemNotFound tolerance (hotfix 2026-05-27)
// ---------------------------------------------------------------------------
@@ -1542,7 +1713,7 @@ func TestReusableQueuedMovieSkeletonPreservesKnownYearWhenCurrentPathHasNone(t *
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 1, 0)
skeleton, ok := worker.reusableQueuedMovieSkeleton(ctx, file)
skeleton, ok := worker.reusableQueuedMovieSkeleton(ctx, file, false)
if !ok || skeleton == nil {
t.Fatal("expected reusable skeleton")
}
+48 -21
View File
@@ -1,6 +1,9 @@
package models
import "time"
import (
"encoding/json"
"time"
)
// ScannedMediaGroup is the persisted group-inference snapshot for one logical
// content group inside a library folder.
@@ -100,16 +103,23 @@ type MediaGroupLocation struct {
LastSeenAt time.Time
}
// SeriesRootMatchQueueEntry represents one pending initial series-root job.
// SeriesRootMatchQueueEntry represents one pending or parked series-root job.
type SeriesRootMatchQueueEntry struct {
MediaFolderID int
ObservedRootPath string
FirstQueuedAt time.Time
AvailableAt time.Time
LastAttemptedAt *time.Time
AttemptCount int
LastError string
UpdatedAt time.Time
MediaFolderID int
ObservedRootPath string
FirstQueuedAt time.Time
AvailableAt time.Time
LastAttemptedAt *time.Time
AttemptCount int
LastError string
State string
FailureKind string
FailureDetail json.RawMessage
DeterministicAttemptCount int
InputFingerprint string
MatcherRevision int
ParkedAt *time.Time
UpdatedAt time.Time
}
// SeriesRootMatchJob is the claimed work payload returned to the matcher.
@@ -118,17 +128,34 @@ type SeriesRootMatchJob struct {
ObservedRootPath string
SampleFilePath string
ObservedFileCount int
LeaseToken string
RerunRequested bool
}
// MovieMatchQueueEntry represents one pending initial movie-file job.
type MovieMatchQueueEntry struct {
MediaFileID int
MediaFolderID int
FilePath string
FirstQueuedAt time.Time
AvailableAt time.Time
LastAttemptedAt *time.Time
AttemptCount int
LastError string
UpdatedAt time.Time
// MovieMatchJob is a claimed movie file plus the ownership token required to
// complete, fail, or release that exact claim.
type MovieMatchJob struct {
File *MediaFile
LeaseToken string
RerunRequested bool
}
// MovieMatchQueueEntry represents one pending or parked movie-file job.
type MovieMatchQueueEntry struct {
MediaFileID int
MediaFolderID int
FilePath string
FirstQueuedAt time.Time
AvailableAt time.Time
LastAttemptedAt *time.Time
AttemptCount int
LastError string
State string
FailureKind string
FailureDetail json.RawMessage
DeterministicAttemptCount int
InputFingerprint string
MatcherRevision int
ParkedAt *time.Time
UpdatedAt time.Time
}
+1 -1
View File
@@ -104,7 +104,7 @@ type Service struct {
func (s *Service) SetEventDispatcher(d *EventDispatcher) { s.dispatcher = d }
// AddLifecycleHook registers a callback invoked after plugin install, enable,
// disable, uninstall, or preload lifecycle changes.
// disable, uninstall, preload, or runtime-configuration changes.
func (s *Service) AddLifecycleHook(hook func(context.Context)) {
if s == nil || hook == nil {
return
+8 -2
View File
@@ -109,12 +109,18 @@ func (s *Service) SetGlobalConfigWithClears(
return fmt.Errorf("persist plugin config: concurrent updates did not settle")
}
var stopErr error
if s.host != nil {
if err := s.host.Stop(installationID); err != nil && !errors.Is(err, pluginhost.ErrClientNotFound) {
return fmt.Errorf("reload plugin after config update: %w", err)
stopErr = fmt.Errorf("reload plugin after config update: %w", err)
}
}
return nil
// Configuration is part of metadata match input identity. Notify hooks
// after the old process has been stopped so resolver reloads observe the new
// runtime. The durable config changed even when stopping failed, so hooks
// still run before returning that error and parked rows are not left asleep.
s.OnLifecycleChange(ctx)
return stopErr
}
func validatedSecretClearSet(
+44
View File
@@ -2198,6 +2198,50 @@ func (r *FileRepository) CountUnmatchedMatchBacklogByFolder(ctx context.Context,
return total, nil
}
// CountUnmatchedMatchBacklogByFolders counts raw matcher work for multiple
// libraries in one query. Libraries without eligible files are omitted.
func (r *FileRepository) CountUnmatchedMatchBacklogByFolders(ctx context.Context, folderIDs []int, mode RawMatchBacklogMode) (map[int]int, error) {
counts := make(map[int]int, len(folderIDs))
if len(folderIDs) == 0 {
return counts, nil
}
rows, err := r.pool.Query(ctx, `
SELECT mf.media_folder_id, COUNT(*)
FROM media_files mf
JOIN media_folders folders ON folders.id = mf.media_folder_id
WHERE mf.media_folder_id = ANY($1)
AND (mf.content_id IS NULL OR mf.content_id = '') AND mf.extra_id IS NULL
AND mf.missing_since IS NULL
AND mf.match_suppressed_at IS NULL
AND folders.enabled = true
AND (
$2 = 'generic'
OR ($2 = 'non_series' AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows'))
OR (
$2 = 'mixed'
AND lower(trim(folders.type)) NOT IN ('series', 'tv', 'show', 'tvshows', 'movie', 'movies')
AND lower(trim(COALESCE(mf.base_type, ''))) NOT IN ('series', 'movie')
)
)
GROUP BY mf.media_folder_id
`, folderIDs, string(normalizeRawMatchBacklogMode(mode)))
if err != nil {
return nil, fmt.Errorf("counting unmatched match backlog by folders: %w", err)
}
defer rows.Close()
for rows.Next() {
var folderID, count int
if err := rows.Scan(&folderID, &count); err != nil {
return nil, fmt.Errorf("scanning unmatched match backlog counts: %w", err)
}
counts[folderID] = count
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating unmatched match backlog counts: %w", err)
}
return counts, nil
}
// ListUnmatchedMatchBacklogByFolder lists raw unmatched files that are still
// eligible for the background matcher.
func (r *FileRepository) ListUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode RawMatchBacklogMode, limit int, offset int) ([]*models.MediaFile, int, error) {
@@ -0,0 +1,55 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE movie_match_queue
ADD COLUMN state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'parked')),
ADD COLUMN failure_kind text NOT NULL DEFAULT '',
ADD COLUMN failure_detail jsonb NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN deterministic_attempt_count integer NOT NULL DEFAULT 0,
ADD COLUMN input_fingerprint text NOT NULL DEFAULT '',
ADD COLUMN matcher_revision integer NOT NULL DEFAULT 0,
ADD COLUMN lease_token text NOT NULL DEFAULT '',
ADD COLUMN parked_at timestamptz;
ALTER TABLE series_root_match_queue
ADD COLUMN state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'parked')),
ADD COLUMN failure_kind text NOT NULL DEFAULT '',
ADD COLUMN failure_detail jsonb NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN deterministic_attempt_count integer NOT NULL DEFAULT 0,
ADD COLUMN input_fingerprint text NOT NULL DEFAULT '',
ADD COLUMN matcher_revision integer NOT NULL DEFAULT 0,
ADD COLUMN lease_token text NOT NULL DEFAULT '',
ADD COLUMN parked_at timestamptz;
CREATE INDEX idx_movie_match_queue_claimable
ON movie_match_queue (available_at, last_attempted_at, media_folder_id, media_file_id)
WHERE state = 'pending';
CREATE INDEX idx_series_root_match_queue_claimable
ON series_root_match_queue (available_at, last_attempted_at, media_folder_id, observed_root_path)
WHERE state = 'pending';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_series_root_match_queue_claimable;
DROP INDEX IF EXISTS idx_movie_match_queue_claimable;
ALTER TABLE series_root_match_queue
DROP COLUMN IF EXISTS parked_at,
DROP COLUMN IF EXISTS lease_token,
DROP COLUMN IF EXISTS matcher_revision,
DROP COLUMN IF EXISTS input_fingerprint,
DROP COLUMN IF EXISTS deterministic_attempt_count,
DROP COLUMN IF EXISTS failure_detail,
DROP COLUMN IF EXISTS failure_kind,
DROP COLUMN IF EXISTS state;
ALTER TABLE movie_match_queue
DROP COLUMN IF EXISTS parked_at,
DROP COLUMN IF EXISTS lease_token,
DROP COLUMN IF EXISTS matcher_revision,
DROP COLUMN IF EXISTS input_fingerprint,
DROP COLUMN IF EXISTS deterministic_attempt_count,
DROP COLUMN IF EXISTS failure_detail,
DROP COLUMN IF EXISTS failure_kind,
DROP COLUMN IF EXISTS state;
-- +goose StatementEnd
@@ -0,0 +1,32 @@
-- +goose NO TRANSACTION
-- +goose Up
-- The series queue fingerprint aggregates active paths under each observed
-- root. Build its supporting index without blocking scanner writes to the
-- potentially large media_files table during deployment.
-- A failed concurrent build can leave an INVALID relation that makes
-- IF NOT EXISTS skip every retry. Remove only that unusable artifact.
-- +goose StatementBegin
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_index i ON i.indexrelid = c.oid
WHERE n.nspname = 'public'
AND c.relname = 'idx_media_files_folder_root_active'
AND NOT i.indisvalid
) THEN
DROP INDEX public.idx_media_files_folder_root_active;
END IF;
END;
$$;
-- +goose StatementEnd
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_media_files_folder_root_active
ON public.media_files (media_folder_id, observed_root_path)
WHERE missing_since IS NULL AND extra_id IS NULL;
-- +goose Down
DROP INDEX CONCURRENTLY IF EXISTS idx_media_files_folder_root_active;
@@ -0,0 +1,17 @@
-- +goose Up
ALTER TABLE movie_match_queue
ADD COLUMN rerun_requested boolean NOT NULL DEFAULT false,
ADD COLUMN lease_forced_rerun boolean NOT NULL DEFAULT false;
ALTER TABLE series_root_match_queue
ADD COLUMN rerun_requested boolean NOT NULL DEFAULT false,
ADD COLUMN lease_forced_rerun boolean NOT NULL DEFAULT false;
-- +goose Down
ALTER TABLE series_root_match_queue
DROP COLUMN lease_forced_rerun,
DROP COLUMN rerun_requested;
ALTER TABLE movie_match_queue
DROP COLUMN lease_forced_rerun,
DROP COLUMN rerun_requested;
+35
View File
@@ -3038,6 +3038,8 @@ export interface LibraryMetadataMatchQueueStatus {
series_count: number;
raw_file_count: number;
total_count: number;
pending_count: number;
parked_count: number;
}
export interface LibraryMovieMatchQueueEntry {
@@ -3049,6 +3051,12 @@ export interface LibraryMovieMatchQueueEntry {
last_attempted_at?: string;
attempt_count: number;
last_error?: string;
state: "pending" | "parked";
failure_kind?: string;
failure_detail?: LibraryMetadataMatchFailureDetail;
deterministic_attempt_count: number;
matcher_revision: number;
parked_at?: string;
updated_at: string;
}
@@ -3060,9 +3068,34 @@ export interface LibrarySeriesMatchQueueEntry {
last_attempted_at?: string;
attempt_count: number;
last_error?: string;
state: "pending" | "parked";
failure_kind?: string;
failure_detail?: LibraryMetadataMatchFailureDetail;
deterministic_attempt_count: number;
matcher_revision: number;
parked_at?: string;
updated_at: string;
}
export interface LibraryMetadataMatchFailureDetail {
message?: string;
decision?: {
outcome: string;
candidate_count: number;
threshold: number;
top_candidates?: Array<{
title: string;
matched_title?: string;
year?: number;
score: number;
provider_ids?: Record<string, string>;
sources?: string[];
reasons?: string[];
}>;
};
[key: string]: unknown;
}
export interface LibraryRawMatchBacklogEntry {
media_file_id: number;
media_folder_id: number;
@@ -3076,6 +3109,8 @@ export interface LibraryRawMatchBacklogEntry {
}
export interface LibraryMetadataMatchQueueDetail extends LibraryMetadataMatchQueueStatus {
limit: number;
offset: number;
movies: LibraryMovieMatchQueueEntry[];
series: LibrarySeriesMatchQueueEntry[];
raw_files: LibraryRawMatchBacklogEntry[];
@@ -0,0 +1,63 @@
import type { ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
export function CollapsibleDiagnosticsSection({
title,
description,
count,
icon,
iconClassName,
open,
onOpenChange,
children,
}: {
title: string;
description: string;
count: number;
icon: ReactNode;
iconClassName?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
children: ReactNode;
}) {
return (
<section className="surface-panel-subtle overflow-hidden rounded-2xl">
<button
type="button"
className="hover:bg-surface-hover/40 flex w-full items-center gap-3 px-5 py-4 text-left transition-colors"
aria-expanded={open}
onClick={() => onOpenChange(!open)}
>
<div
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg",
iconClassName ?? "bg-amber-500/10",
)}
>
{icon}
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<h2 className="text-sm font-semibold tracking-wide">{title}</h2>
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
</div>
{open ? (
<Badge variant="secondary" className="text-[11px] tabular-nums">
{count}
</Badge>
) : (
<div className="text-2xl leading-none font-bold tabular-nums">{count}</div>
)}
<ChevronDown
className={cn(
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
!open && "-rotate-90",
)}
/>
</button>
{open ? <div className="px-3 pb-3">{children}</div> : null}
</section>
);
}
@@ -0,0 +1,105 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { Library } from "@/api/types";
import { MetadataMatcherQueuesSection } from "./MetadataMatcherQueuesSection";
const mocks = vi.hoisted(() => ({
useQueues: vi.fn(),
useDetail: vi.fn(),
useRetry: vi.fn(),
}));
vi.mock("@/hooks/queries/admin/libraries", () => ({
useLibraryMetadataMatchQueues: (...args: unknown[]) => mocks.useQueues(...args),
useLibraryMetadataMatchQueueDetail: (...args: unknown[]) => mocks.useDetail(...args),
useRetryLibraryMetadataMatchQueue: (...args: unknown[]) => mocks.useRetry(...args),
}));
describe("MetadataMatcherQueuesSection", () => {
it("renders the structured failure kind with the parked item detail", async () => {
mocks.useQueues.mockReturnValue({
data: [
{
library_id: 1,
movie_count: 1,
series_count: 0,
raw_file_count: 0,
total_count: 1,
pending_count: 0,
parked_count: 1,
},
],
});
mocks.useDetail.mockReturnValue({
data: {
movies: [
{
media_file_id: 42,
file_path: "/media/movies/Unknown.mkv",
state: "parked",
failure_kind: "candidate_rejected",
failure_detail: { message: "Score below threshold" },
},
],
series: [],
raw_files: [],
},
});
mocks.useRetry.mockReturnValue({ mutate: vi.fn(), isPending: false, variables: undefined });
const libraries = [{ id: 1, name: "Movies" }] as Library[];
render(<MetadataMatcherQueuesSection libraries={libraries} />);
await userEvent.click(screen.getByRole("button", { name: /metadata matcher/i }));
await userEvent.click(screen.getByText("Movies"));
expect(screen.getByText("candidate rejected")).toBeInTheDocument();
expect(screen.getByText("Score below threshold")).toBeInTheDocument();
expect(screen.getByText("parked")).toBeInTheDocument();
});
it("pages through every queue entry instead of stopping at the first ten", async () => {
mocks.useQueues.mockReturnValue({
data: [
{
library_id: 1,
movie_count: 15,
series_count: 0,
raw_file_count: 0,
total_count: 15,
pending_count: 15,
parked_count: 0,
},
],
});
mocks.useDetail.mockImplementation((_libraryID: number | null, offset: number) => ({
data: {
limit: 10,
offset,
movie_count: 15,
series_count: 0,
raw_file_count: 0,
movies: Array.from({ length: offset === 0 ? 10 : 5 }, (_, index) => ({
media_file_id: offset + index + 1,
file_path: `/media/movies/Movie ${offset + index + 1}.mkv`,
state: "pending",
})),
series: [],
raw_files: [],
},
isFetching: false,
}));
mocks.useRetry.mockReturnValue({ mutate: vi.fn(), isPending: false, variables: undefined });
const libraries = [{ id: 1, name: "Movies" }] as Library[];
render(<MetadataMatcherQueuesSection libraries={libraries} />);
await userEvent.click(screen.getByRole("button", { name: /metadata matcher/i }));
await userEvent.click(screen.getByText("Movies"));
await userEvent.click(screen.getByRole("button", { name: "Next" }));
expect(mocks.useDetail).toHaveBeenLastCalledWith(1, 10);
expect(screen.getByText("Page 2")).toBeInTheDocument();
expect(screen.getByText("/media/movies/Movie 15.mkv")).toBeInTheDocument();
});
});
@@ -0,0 +1,260 @@
import { Fragment, useState } from "react";
import { Wrench } from "lucide-react";
import type { Library } from "@/api/types";
import { CollapsibleDiagnosticsSection } from "@/components/admin/CollapsibleDiagnosticsSection";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
useLibraryMetadataMatchQueueDetail,
useLibraryMetadataMatchQueues,
useRetryLibraryMetadataMatchQueue,
} from "@/hooks/queries/admin/libraries";
function formatFailureKind(kind: string): string {
return kind.replace(/_/g, " ");
}
export function MetadataMatcherQueuesSection({ libraries }: { libraries: Library[] }) {
const [open, setOpen] = useState(false);
const [selectedLibraryID, setSelectedLibraryID] = useState<number | null>(null);
const [detailOffset, setDetailOffset] = useState(0);
const { data: queues = [] } = useLibraryMetadataMatchQueues();
// Passing null while collapsed disables the detail query entirely so a
// hidden expansion does not keep polling the per-library endpoint.
const { data: detail, isFetching: detailFetching } = useLibraryMetadataMatchQueueDetail(
open ? selectedLibraryID : null,
detailOffset,
);
const retry = useRetryLibraryMetadataMatchQueue();
const total = queues.reduce((sum, queue) => sum + queue.total_count, 0);
const detailLimit = detail?.limit ?? 10;
const hasPreviousDetailPage = detailOffset > 0;
const hasNextDetailPage = detail
? detailOffset + detail.movies.length < detail.movie_count ||
detailOffset + detail.series.length < detail.series_count ||
detailOffset + detail.raw_files.length < detail.raw_file_count
: false;
const detailEntries = detail
? [
...detail.movies.map((entry) => ({
key: `movie-${entry.media_file_id}`,
path: entry.file_path,
state: entry.state,
failureKind: entry.failure_kind,
message: entry.failure_detail?.message ?? entry.last_error,
decision: entry.failure_detail?.decision,
})),
...detail.series.map((entry) => ({
key: `series-${entry.media_folder_id}-${entry.observed_root_path}`,
path: entry.observed_root_path,
state: entry.state,
failureKind: entry.failure_kind,
message: entry.failure_detail?.message ?? entry.last_error,
decision: entry.failure_detail?.decision,
})),
...detail.raw_files.map((entry) => {
const identity = [
entry.base_title,
entry.base_year ? `(${entry.base_year})` : "",
entry.base_type ? `[${entry.base_type}]` : "",
]
.filter(Boolean)
.join(" ");
return {
key: `raw-${entry.media_file_id}`,
path: entry.file_path,
state: "pending" as const,
failureKind: null,
message: identity ? `Awaiting initial match: ${identity}` : "Awaiting initial match",
decision: undefined,
};
}),
]
: [];
if (total === 0) return null;
return (
<CollapsibleDiagnosticsSection
title="Metadata Matcher"
description="Pending and parked items that still need a provider match."
count={total}
icon={<Wrench className="h-4 w-4 text-amber-500" />}
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) {
setSelectedLibraryID(null);
setDetailOffset(0);
}
}}
>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Library</TableHead>
<TableHead className="text-right">Pending</TableHead>
<TableHead className="text-right">Parked</TableHead>
<TableHead className="w-28" />
</TableRow>
</TableHeader>
<TableBody>
{queues.map((queue) => {
const selected = selectedLibraryID === queue.library_id;
const library = libraries.find((entry) => entry.id === queue.library_id);
return (
<Fragment key={queue.library_id}>
<TableRow
className="cursor-pointer"
onClick={() => {
setSelectedLibraryID(selected ? null : queue.library_id);
setDetailOffset(0);
}}
>
<TableCell className="font-medium">
{library?.name ?? `Library ${queue.library_id}`}
</TableCell>
<TableCell className="text-right tabular-nums">{queue.pending_count}</TableCell>
<TableCell className="text-right tabular-nums">
{queue.parked_count > 0 ? (
<Badge variant="outline" className="border-amber-500/30 text-amber-600">
{queue.parked_count}
</Badge>
) : (
0
)}
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
variant="outline"
disabled={retry.isPending && retry.variables === queue.library_id}
onClick={(event) => {
event.stopPropagation();
retry.mutate(queue.library_id);
}}
>
Retry now
</Button>
</TableCell>
</TableRow>
{selected ? (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={4} className="bg-muted/30 p-3">
<div className="space-y-2">
{detailEntries.map((entry) => {
const { path, failureKind, message, decision } = entry;
return (
<div
key={entry.key}
className="bg-background/70 rounded-lg border p-2 text-xs"
>
<div className="flex items-center justify-between gap-2">
<code className="min-w-0 truncate font-mono">{path}</code>
<div className="flex shrink-0 items-center gap-1.5">
{failureKind ? (
<Badge variant="outline">
{formatFailureKind(failureKind)}
</Badge>
) : null}
<Badge
variant={entry.state === "parked" ? "outline" : "secondary"}
>
{entry.state}
</Badge>
</div>
</div>
{message ? (
<p className="text-muted-foreground mt-1">{message}</p>
) : null}
{decision?.top_candidates?.length ? (
<div className="mt-2 space-y-1 border-t pt-2">
{decision.top_candidates.map((candidate, candidateIndex) => (
<div
key={`${candidate.title}-${candidate.year ?? 0}-${candidate.score}-${candidateIndex}`}
className="text-muted-foreground flex flex-wrap items-baseline gap-x-2"
>
<span className="text-foreground font-medium">
{candidate.title}
{candidate.year ? ` (${candidate.year})` : ""}
</span>
<span className="tabular-nums">
score {candidate.score.toFixed(1)} / {decision.threshold}
</span>
{candidate.matched_title &&
candidate.matched_title !== candidate.title ? (
<span>matched “{candidate.matched_title}”</span>
) : null}
{candidate.reasons?.length ? (
<span>{candidate.reasons.join(", ")}</span>
) : null}
{candidate.sources?.length ? (
<span>via {candidate.sources.join(", ")}</span>
) : null}
</div>
))}
</div>
) : null}
</div>
);
})}
{detail && detailEntries.length === 0 ? (
<p className="text-muted-foreground text-center">
No queued item details.
</p>
) : null}
{hasPreviousDetailPage || hasNextDetailPage ? (
<div className="flex items-center justify-between pt-1">
<span className="text-muted-foreground text-xs">
Page {Math.floor(detailOffset / detailLimit) + 1}
</span>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={!hasPreviousDetailPage || detailFetching}
onClick={() =>
setDetailOffset((current) => Math.max(0, current - detailLimit))
}
>
Previous
</Button>
<Button
type="button"
size="sm"
variant="outline"
disabled={!hasNextDetailPage || detailFetching}
onClick={() =>
setDetailOffset((current) => current + detailLimit)
}
>
Next
</Button>
</div>
</div>
) : null}
</div>
</TableCell>
</TableRow>
) : null}
</Fragment>
);
})}
</TableBody>
</Table>
</div>
</CollapsibleDiagnosticsSection>
);
}
+8 -3
View File
@@ -423,15 +423,20 @@ export function useLibraryMetadataMatchQueues() {
});
}
export function useLibraryMetadataMatchQueueDetail(libraryId: number | null) {
const METADATA_MATCH_QUEUE_PAGE_SIZE = 10;
export function useLibraryMetadataMatchQueueDetail(libraryId: number | null, offset = 0) {
const pageActivity = usePageActivity();
return useQuery({
queryKey: adminKeys.libraryMatchQueueDetail(libraryId ?? 0),
queryKey: [...adminKeys.libraryMatchQueueDetail(libraryId ?? 0), offset],
queryFn: () =>
api<LibraryMetadataMatchQueueDetail>(
`/libraries/${encodeURIComponent(String(libraryId))}/metadata-match-queue?limit=10`,
`/libraries/${encodeURIComponent(String(libraryId))}/metadata-match-queue?limit=${METADATA_MATCH_QUEUE_PAGE_SIZE}&offset=${offset}`,
),
enabled: libraryId !== null,
staleTime: 0,
refetchInterval: pageActivity.canApplyRealtimeUpdates ? 10_000 : false,
});
}
+8 -4
View File
@@ -243,7 +243,7 @@ describe("AdminLibraries", () => {
expect(markup).toContain("Scanner roots that stay visible");
});
it("does not show metadata matcher queue counts in the library status", () => {
it("shows metadata matcher pending and parked counts", () => {
mocks.useLibraryMetadataMatchQueues.mockReturnValue({
data: [
{
@@ -252,6 +252,8 @@ describe("AdminLibraries", () => {
series_count: 2,
raw_file_count: 0,
total_count: 3,
pending_count: 2,
parked_count: 1,
},
],
isLoading: false,
@@ -259,9 +261,11 @@ describe("AdminLibraries", () => {
const markup = renderPage();
expect(markup).toContain("Enabled");
expect(markup).not.toContain("3 matching");
expect(markup).not.toContain("View backlog");
expect(markup).toContain("Metadata Matcher");
expect(markup).toContain("Pending and parked items that still need a provider match.");
// The total renders as element text (">3<"); a bare "3" would also match
// Tailwind class names like p-3 and prove nothing.
expect(markup).toMatch(/>\s*3\s*</);
});
it("shows active scan progress in the library task status row", () => {
+3 -59
View File
@@ -1,5 +1,4 @@
import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from "react";
import type { ReactNode } from "react";
import { useDebounce } from "@/hooks/useDebounce";
import { useEventChannel } from "@/components/realtimeEventsContext";
import type {
@@ -36,6 +35,8 @@ import { useActiveScans } from "@/hooks/queries/admin/scans";
import { buildLibraryReorderEntries } from "./adminLibraryOrder";
import MatchItemDialog from "@/components/MatchItemDialog";
import { LibraryEditorDialog } from "@/components/admin/libraries/LibraryEditorDialog";
import { MetadataMatcherQueuesSection } from "@/components/admin/libraries/MetadataMatcherQueuesSection";
import { CollapsibleDiagnosticsSection } from "@/components/admin/CollapsibleDiagnosticsSection";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -717,6 +718,7 @@ export default function AdminLibraries() {
</DndContext>
<UnmatchedItemsSection />
<MetadataMatcherQueuesSection libraries={libraries} />
<AmbiguousRootsSection libraries={libraries} />
{skippedRoots.length > 0 ? <SkippedRootsSection skippedRoots={skippedRoots} /> : null}
{staleIDs.length > 0 && <StaleIDsSection staleIDs={staleIDs} />}
@@ -1215,64 +1217,6 @@ function usePagination<T>(items: T[], pageSize = PAGE_SIZE) {
};
}
function CollapsibleDiagnosticsSection({
title,
description,
count,
icon,
iconClassName,
open,
onOpenChange,
children,
}: {
title: string;
description: string;
count: number;
icon: ReactNode;
iconClassName?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
children: ReactNode;
}) {
return (
<section className="surface-panel-subtle overflow-hidden rounded-2xl">
<button
type="button"
className="hover:bg-surface-hover/40 flex w-full items-center gap-3 px-5 py-4 text-left transition-colors"
aria-expanded={open}
onClick={() => onOpenChange(!open)}
>
<div
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg",
iconClassName ?? "bg-amber-500/10",
)}
>
{icon}
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<h2 className="text-sm font-semibold tracking-wide">{title}</h2>
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
</div>
{open ? (
<Badge variant="secondary" className="text-[11px] tabular-nums">
{count}
</Badge>
) : (
<div className="text-2xl leading-none font-bold tabular-nums">{count}</div>
)}
<ChevronDown
className={cn(
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
!open && "-rotate-90",
)}
/>
</button>
{open ? <div className="px-3 pb-3">{children}</div> : null}
</section>
);
}
function PaginationBar({
total,
rangeStart,