Files
MeBox/internal/service/scraper_artwork_test.go
truewhile 8eb017fedc 优化
2026-09-09 00:03:14 +08:00

307 lines
9.9 KiB
Go

package service
import (
"bytes"
"errors"
"image"
"image/color"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func TestScrapeDelayUsesSettings(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
if err := repos.DB.AutoMigrate(&model.Setting{}); err != nil {
t.Fatal(err)
}
if got := scraper.scrapeDelay(t.Context()); got < 250*time.Millisecond || got > 500*time.Millisecond {
t.Fatalf("default scrapeDelay = %s, want 250-500ms", got)
}
if err := repos.Setting.Set(t.Context(), "scrape.delay_min_ms", "0"); err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(t.Context(), "scrape.delay_max_ms", "0"); err != nil {
t.Fatal(err)
}
if got := scraper.scrapeDelay(t.Context()); got != 0 {
t.Fatalf("disabled scrapeDelay = %s, want 0", got)
}
if err := repos.Setting.Set(t.Context(), "scrape.delay_min_ms", "800"); err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(t.Context(), "scrape.delay_max_ms", "200"); err != nil {
t.Fatal(err)
}
if got := scraper.scrapeDelay(t.Context()); got != 800*time.Millisecond {
t.Fatalf("normalized scrapeDelay = %s, want 800ms", got)
}
}
func TestApplyProviderMatchInvalidatesMediaCache(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop())
cache.SetJSON(t.Context(), "media:list:stale", map[string]string{"poster": ""}, time.Minute)
cache.SetJSON(t.Context(), "stats:snapshot:base", map[string]int{"media": 1}, time.Minute)
scraper.SetRuntimeCache(cache)
libPath := t.TempDir()
lib := model.Library{Name: "Movies", Path: libPath, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
media := model.Media{LibraryID: lib.ID, Title: "Raw", Path: "/media/movies/raw.mkv", ScrapeStatus: "pending"}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
match := &Match{Provider: "metatube", Title: "Matched", PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg", BackdropURL: "https://image.tmdb.org/t/p/w1280/backdrop.jpg"}
usedProvider := ""
if err := scraper.applyProviderMatchWithOptions(t.Context(), &media, &lib, match, ScrapeOptions{resultProvider: &usedProvider}); err != nil {
t.Fatal(err)
}
if usedProvider != "metatube" {
t.Fatalf("recorded provider = %q, want metatube", usedProvider)
}
var stale map[string]string
if cache.GetJSON(t.Context(), "media:list:stale", &stale) {
t.Fatal("scraper should invalidate media list cache after applying artwork")
}
var stats map[string]int
if cache.GetJSON(t.Context(), "stats:snapshot:base", &stats) {
t.Fatal("scraper should invalidate stats cache after applying artwork")
}
var stored model.Media
if err := repos.DB.First(&stored, "id = ?", media.ID).Error; err != nil {
t.Fatal(err)
}
if stored.PosterURL == "" || stored.BackdropURL == "" || stored.ScrapeStatus != "matched" {
t.Fatalf("match not saved: poster=%q backdrop=%q status=%q", stored.PosterURL, stored.BackdropURL, stored.ScrapeStatus)
}
}
func TestApplyProviderMatchKeepsExistingArtworkWhenNewPrefetchFails(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
images := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
images.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadGateway,
Status: "502 Bad Gateway",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("bad gateway")),
Request: req,
}, nil
})}
scraper.SetImageProxy(images)
libPath := t.TempDir()
lib := model.Library{Name: "Movies", Path: libPath, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
oldPoster := "https://image.tmdb.org/t/p/w500/old-poster.jpg"
oldBackdrop := "https://image.tmdb.org/t/p/w1280/old-backdrop.jpg"
media := model.Media{
LibraryID: lib.ID,
Title: "Raw",
Path: filepath.Join(libPath, "raw.mkv"),
PosterURL: oldPoster,
BackdropURL: oldBackdrop,
ScrapeStatus: "matched",
OriginalName: "Raw",
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
match := &Match{
Title: "Matched",
PosterURL: "https://image.tmdb.org/t/p/w500/new-broken-poster.jpg",
BackdropURL: "",
}
if err := scraper.applyProviderMatch(t.Context(), &media, &lib, match); err != nil {
t.Fatal(err)
}
var stored model.Media
if err := repos.DB.First(&stored, "id = ?", media.ID).Error; err != nil {
t.Fatal(err)
}
if stored.PosterURL != oldPoster {
t.Fatalf("poster should keep existing URL when new prefetch fails: got %q want %q", stored.PosterURL, oldPoster)
}
if stored.BackdropURL != oldBackdrop {
t.Fatalf("blank match backdrop should not clear existing backdrop: got %q want %q", stored.BackdropURL, oldBackdrop)
}
}
func TestApplyProviderMatchFallsBackToLocalFaceCropWhenMetaTubeImageFails(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
wideCover := createTestImage(
900,
600,
color.RGBA{R: 255, A: 255},
color.RGBA{B: 255, A: 255},
)
sourceURL := "https://images.example.com/wide-cover.jpg"
images := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
images.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host == "metatube.example.com" {
return &http.Response{
StatusCode: http.StatusGatewayTimeout,
Status: "504 Gateway Timeout",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("timeout")),
Request: req,
}, nil
}
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: http.Header{"Content-Type": []string{"image/jpeg"}},
Body: io.NopCloser(bytes.NewReader(wideCover)),
Request: req,
}, nil
})}
scraper.SetImageProxy(images)
libPath := t.TempDir()
lib := model.Library{Name: "Adult", Path: libPath, Type: "adult", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "Raw",
Path: filepath.Join(libPath, "IPX-235.mp4"),
PosterURL: filepath.Join(libPath, "old-poster.jpg"),
NSFW: true,
ScrapeStatus: "matched",
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
query := url.Values{
"auto": {"true"},
"pos": {"1"},
"quality": {"90"},
"ratio": {"-1"},
"url": {sourceURL},
}
match := &Match{
Title: "IPX-235 Matched",
MediaType: "adult",
NSFW: true,
PosterURL: "https://metatube.example.com/v1/images/primary/AVE/123?" + query.Encode(),
}
if err := scraper.applyProviderMatch(t.Context(), &media, &lib, match); err != nil {
t.Fatal(err)
}
var stored model.Media
if err := repos.DB.First(&stored, "id = ?", media.ID).Error; err != nil {
t.Fatal(err)
}
if filepath.Base(stored.PosterURL) != "IPX-235-poster.jpg" {
t.Fatalf("poster = %q, want local face-cropped sidecar", stored.PosterURL)
}
data, err := os.ReadFile(stored.PosterURL)
if err != nil {
t.Fatal(err)
}
cropped, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
ratio := float64(cropped.Bounds().Dx()) / float64(cropped.Bounds().Dy())
if ratio < 0.65 || ratio > 0.68 {
t.Fatalf("local fallback poster ratio = %.3f", ratio)
}
}
func TestApplyProviderMatchReplacesArtworkAndRemovesOldCache(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
images := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
images.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: http.Header{"Content-Type": []string{"image/jpeg"}},
Body: io.NopCloser(bytes.NewReader(testJPEG)),
Request: req,
}, nil
})}
scraper.SetImageProxy(images)
libPath := t.TempDir()
lib := model.Library{Name: "Movies", Path: libPath, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
oldPoster := "https://image.tmdb.org/t/p/w500/old-cache-poster.jpg"
newPoster := "https://image.tmdb.org/t/p/w500/new-cache-poster.jpg"
if err := images.PrefetchRemote(t.Context(), oldPoster); err != nil {
t.Fatal(err)
}
_, oldCachePath, _, err := images.remoteImageCachePaths(oldPoster)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(oldCachePath); err != nil {
t.Fatalf("old poster cache should exist before replace: %v", err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "Raw",
Path: filepath.Join(libPath, "raw.mkv"),
PosterURL: oldPoster,
ScrapeStatus: "matched",
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
match := &Match{Title: "Matched", PosterURL: newPoster}
if err := scraper.applyProviderMatch(t.Context(), &media, &lib, match); err != nil {
t.Fatal(err)
}
var stored model.Media
if err := repos.DB.First(&stored, "id = ?", media.ID).Error; err != nil {
t.Fatal(err)
}
if stored.PosterURL != newPoster {
t.Fatalf("poster = %q, want %q", stored.PosterURL, newPoster)
}
if _, err := os.Stat(oldCachePath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("old poster cache should be removed, stat err=%v", err)
}
_, newCachePath, _, err := images.remoteImageCachePaths(newPoster)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(newCachePath); err != nil {
t.Fatalf("new poster cache should exist: %v", err)
}
}