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:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user