mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
307 lines
9.9 KiB
Go
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)
|
|
}
|
|
}
|