diff --git a/internal/features/playback/handler.go b/internal/features/playback/handler.go index 1fbae17..5714636 100644 --- a/internal/features/playback/handler.go +++ b/internal/features/playback/handler.go @@ -256,7 +256,9 @@ func (h *Handler) HandleProxyPreviewMap(w http.ResponseWriter, r *http.Request) Duration: duration, }) if previewErr != nil { - log.Printf("preview map error mal_id=%d ep=%s mode=%s: %v", malID, episode, mode, previewErr) + if previewErr.Error() != "preview temporarily disabled" { + log.Printf("preview map error mal_id=%d ep=%s mode=%s: %v", malID, episode, mode, previewErr) + } http.Error(w, "failed to generate preview map", http.StatusBadGateway) return } diff --git a/internal/features/playback/preview_service.go b/internal/features/playback/preview_service.go index aaa7502..fd5b126 100644 --- a/internal/features/playback/preview_service.go +++ b/internal/features/playback/preview_service.go @@ -5,6 +5,7 @@ import ( "crypto/sha1" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "math" @@ -26,6 +27,7 @@ const ( previewFrameInterval = 10 previewFrameLimit = previewGridColumns * previewGridRows previewGenerationLimit = 120 * time.Second + previewFailureTTL = 2 * time.Minute previewManifestName = "map.json" spriteFileName = "sprite.jpg" ) @@ -67,6 +69,9 @@ func (s *Service) EnsurePreviewMap(ctx context.Context, req PreviewRequest) (Pre previewHash := hashPreviewIdentity(req.MalID, normalizedEpisode, normalizedMode, req.Source, req.Referer) previewKey := fmt.Sprintf("%d-%s", req.MalID, previewHash) + if s.previewFailureActive(previewKey) { + return PreviewMap{}, "", fmt.Errorf("preview temporarily disabled") + } previewDir := filepath.Join(s.previewRoot, previewKey) manifestPath := filepath.Join(previewDir, previewManifestName) spritePath := filepath.Join(previewDir, spriteFileName) @@ -120,8 +125,12 @@ func (s *Service) EnsurePreviewMap(ctx context.Context, req PreviewRequest) (Pre interval := selectPreviewInterval(duration) if err := generatePreviewSprite(ctxWithTimeout, ffmpegPath, req.Source, req.Referer, spritePath, interval); err != nil { + if shouldBackoffPreview(err) { + s.markPreviewFailure(previewKey) + } return PreviewMap{}, "", err } + s.clearPreviewFailure(previewKey) mapData := buildPreviewMap(duration, interval) if err := writePreviewManifest(manifestPath, mapData); err != nil { @@ -159,6 +168,37 @@ func (s *Service) previewLock(key string) *sync.Mutex { return newLock } +func (s *Service) previewFailureActive(key string) bool { + s.previewFailMu.Lock() + defer s.previewFailMu.Unlock() + + expiresAt, ok := s.previewFailTTL[key] + if !ok { + return false + } + + if time.Now().After(expiresAt) { + delete(s.previewFailTTL, key) + return false + } + + return true +} + +func (s *Service) markPreviewFailure(key string) { + s.previewFailMu.Lock() + defer s.previewFailMu.Unlock() + + s.previewFailTTL[key] = time.Now().Add(previewFailureTTL) +} + +func (s *Service) clearPreviewFailure(key string) { + s.previewFailMu.Lock() + defer s.previewFailMu.Unlock() + + delete(s.previewFailTTL, key) +} + func hashPreviewIdentity(malID int, episode string, mode string, source string, referer string) string { payload := fmt.Sprintf("%d|%s|%s|%s|%s", malID, episode, mode, source, referer) sum := sha1.Sum([]byte(payload)) @@ -398,3 +438,26 @@ func isFinitePositive(value float64) bool { return true } + +func shouldBackoffPreview(err error) bool { + if err == nil { + return false + } + + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return true + } + + errText := strings.ToLower(err.Error()) + if strings.Contains(errText, "signal: killed") { + return true + } + if strings.Contains(errText, "context canceled") { + return true + } + if strings.Contains(errText, "cannot allocate memory") { + return true + } + + return false +} diff --git a/internal/features/playback/service.go b/internal/features/playback/service.go index b9d8f07..12d23e8 100644 --- a/internal/features/playback/service.go +++ b/internal/features/playback/service.go @@ -28,6 +28,8 @@ type Service struct { previewRoot string previewMu sync.Mutex previewLocks map[string]*sync.Mutex + previewFailMu sync.Mutex + previewFailTTL map[string]time.Time } type sourceScore struct { @@ -47,6 +49,7 @@ func NewService(jikanClient *jikan.Client, db database.Querier) *Service { db: db, previewRoot: newPreviewRootDir(), previewLocks: make(map[string]*sync.Mutex), + previewFailTTL: make(map[string]time.Time), } }