Merge changes from prometheus/mebox-40a7378b

This commit is contained in:
truewhile
2026-11-12 22:38:54 +08:00
parent 5806839601
commit 08d521b65d
8 changed files with 118 additions and 14 deletions
+23 -3
View File
@@ -73,17 +73,17 @@ func CropAdultCoverPosterForCode(data []byte, code string) ([]byte, string, erro
}
src, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return data, format, err
return data, imageMIMEForFormat(format), err
}
bounds := src.Bounds()
width := bounds.Dx()
height := bounds.Dy()
if width <= 0 || height <= 0 {
return data, format, nil
return data, imageMIMEForFormat(format), nil
}
// If the image is already portrait or square, keep it as-is.
if float64(width) <= float64(height)*1.15 {
return data, format, nil
return data, imageMIMEForFormat(format), nil
}
position := 1.0
@@ -101,6 +101,26 @@ func CropAdultCoverPosterForCode(data []byte, code string) ([]byte, string, erro
return buf.Bytes(), "image/jpeg", nil
}
// imageMIMEForFormat maps the format name returned by image.Decode ("jpeg" / "png" / "webp" …) to a MIME
// type. The sidecar extension is derived from the MIME type, so handing back the bare format name made
// every already-portrait poster fall through to the legacy ".img" extension.
func imageMIMEForFormat(format string) string {
switch strings.ToLower(strings.TrimSpace(format)) {
case "jpeg", "jpg":
return "image/jpeg"
case "png":
return "image/png"
case "webp":
return "image/webp"
case "gif":
return "image/gif"
case "avif":
return "image/avif"
default:
return ""
}
}
// adultCoverNeedsFaceDetection reports whether the cover composition has to be probed with the face
// detector, mirroring upstream MetaTube: only "special" numbers (FC2 / HEYZO / 素人 / 无码) qualify,
// regular DVD numbers are always cropped at the right-most panel.
+5 -1
View File
@@ -167,10 +167,14 @@ func TestCropAdultCoverPosterKeepsPortrait(t *testing.T) {
// Create an already vertical portrait image (500x700)
origBytes := createTestImage(500, 700, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
croppedBytes, _, err := CropAdultCoverPoster(origBytes)
croppedBytes, ctype, err := CropAdultCoverPoster(origBytes)
if err != nil {
t.Fatalf("CropAdultCoverPoster error: %v", err)
}
// 未裁剪时必须回传真正的 MIME(image/jpeg),否则侧车文件扩展名会退化成 .img。
if ctype != "image/jpeg" {
t.Fatalf("portrait ctype = %q, want image/jpeg", ctype)
}
croppedImg, _, err := image.Decode(bytes.NewReader(croppedBytes))
if err != nil {
@@ -96,7 +96,8 @@ func TestAdultProviderRouting(t *testing.T) {
if len(candidates) != 1 {
t.Fatalf("expected 1 candidate, got %d", len(candidates))
}
wantPoster := mtServer.URL + "/v1/images/primary/javdb/999?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fposter.jpg"
// SSIS-001 是常规番号:MetaTube 代理不得开启人脸定位,固定取最右侧封面面板。
wantPoster := mtServer.URL + "/v1/images/primary/javdb/999?auto=false&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fposter.jpg"
wantBackdrop := mtServer.URL + "/v1/images/backdrop/javdb/999?quality=90"
if candidates[0].PosterURL != wantPoster || candidates[0].BackdropURL != wantBackdrop {
t.Fatalf("candidate artwork was not enriched: %#v", candidates[0])
@@ -170,8 +171,8 @@ func TestBuiltinAdultScrapeUsesMetaTubeFaceAwareArtwork(t *testing.T) {
if match == nil {
t.Fatal("expected built-in match")
}
wantPoster := metaTubeServer.URL + "/v1/images/primary/AVE/999?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fwide-cover.jpg"
wantPoster := metaTubeServer.URL + "/v1/images/primary/AVE/999?auto=false&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fwide-cover.jpg"
if match.PosterURL != wantPoster {
t.Fatalf("built-in poster = %q, want face-aware URL %q", match.PosterURL, wantPoster)
t.Fatalf("built-in poster = %q, want MetaTube-processed URL %q", match.PosterURL, wantPoster)
}
}
+1 -1
View File
@@ -93,7 +93,7 @@ func TestManualAdultMatchUsesSelectedMetaTubeDetailsAndRealBackdrop(t *testing.T
if detailCalls.Load() != 1 {
t.Fatalf("selected MetaTube detail calls = %d, want 1", detailCalls.Load())
}
wantPoster := upstream.URL + "/v1/images/primary/AVE/94600?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fposter.jpg"
wantPoster := upstream.URL + "/v1/images/primary/AVE/94600?auto=false&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fposter.jpg"
if match.PosterURL != wantPoster {
t.Fatalf("selected poster = %q, want %q", match.PosterURL, wantPoster)
}
+10 -4
View File
@@ -352,7 +352,7 @@ func (p *MetaTubeProvider) convertSearchResultToMatch(cfg MetaTubeConfig, query
year := parseYearFromDate(res.ReleaseDate)
posterSource := firstNonEmpty(res.BigCoverURL, res.CoverURL, res.BigThumbURL, res.ThumbURL)
posterURL, backdropURL := metaTubeArtworkURLs(cfg, res.Provider, res.ID, posterSource)
posterURL, backdropURL := metaTubeArtworkURLs(cfg, res.Provider, res.ID, posterSource, code)
if posterURL == "" {
posterURL = firstNonEmpty(res.BigThumbURL, res.ThumbURL, res.BigCoverURL, res.CoverURL)
}
@@ -396,7 +396,7 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(cfg MetaTubeConfig, movie *Me
year := parseYearFromDate(movie.ReleaseDate)
posterSource := firstNonEmpty(movie.BigCoverURL, movie.CoverURL, movie.BigThumbURL, movie.ThumbURL)
posterURL, backdropURL := metaTubeArtworkURLs(cfg, movie.Provider, movie.ID, posterSource)
posterURL, backdropURL := metaTubeArtworkURLs(cfg, movie.Provider, movie.ID, posterSource, code)
if posterURL == "" {
posterURL = firstNonEmpty(movie.BigThumbURL, movie.ThumbURL, movie.BigCoverURL, movie.CoverURL)
}
@@ -440,7 +440,13 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(cfg MetaTubeConfig, movie *Me
}
}
func metaTubeArtworkURLs(cfg MetaTubeConfig, provider, id, posterSource string) (string, string) {
// metaTubeArtworkURLs 构造 MetaTube 图片代理地址。
//
// auto(服务端人脸定位)只能对特殊番号(FC2 / HEYZO / 素人 / 无码)开启,这与 MetaTube
// 官方插件的默认行为一致:常规 DVD 番号的封套是 [封底 | 书脊 | 封面] 横图,封底那半张是样本
// 剧照拼贴、密布人脸,服务端的人脸主簇会落到拼贴/书脊上,裁出来就是「半张封底」。
// 常规番号固定 pos=1(最右侧封面面板)才能拿到正确封面。
func metaTubeArtworkURLs(cfg MetaTubeConfig, provider, id, posterSource, code string) (string, string) {
serverURL := strings.TrimRight(strings.TrimSpace(cfg.ServerURL), "/")
provider = strings.TrimSpace(provider)
id = strings.TrimSpace(id)
@@ -459,7 +465,7 @@ func metaTubeArtworkURLs(cfg MetaTubeConfig, provider, id, posterSource string)
}
if cfg.CropCover {
values.Set("pos", "1")
values.Set("auto", "true")
values.Set("auto", strconv.FormatBool(adultCoverNeedsFaceDetection(code)))
} else {
values.Set("pos", "-1")
values.Set("auto", "false")
+30 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go.uber.org/zap"
@@ -137,7 +138,8 @@ func TestMetaTubeProviderGetMovie(t *testing.T) {
if match.Overview != "超绝美少女相沢みなみ出道作品!" {
t.Errorf("unexpected overview: %s", match.Overview)
}
if want := server.URL + "/v1/images/primary/javdb/123456?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fbig_cover.jpg"; match.PosterURL != want {
// IPX-235 是常规 DVD 番号:服务端不得开启人脸定位(auto=false),否则主簇会落到封底的剧照拼贴。
if want := server.URL + "/v1/images/primary/javdb/123456?auto=false&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fbig_cover.jpg"; match.PosterURL != want {
t.Errorf("poster URL = %q, want %q", match.PosterURL, want)
}
if want := server.URL + "/v1/images/backdrop/javdb/123456?quality=90"; match.BackdropURL != want {
@@ -187,3 +189,30 @@ func TestMetaTubeProviderTestConnection(t *testing.T) {
t.Fatalf("expected failure for wrong token, got %v", resFail)
}
}
// MetaTube 代理的 auto 参数决定服务端是否用 Pigo 做人脸定位裁切。
// 常规 DVD 番号的封套封底是剧照拼贴、密布人脸,开启 auto 会裁出「半张封底」;
// 只有 FC2 / HEYZO / 素人 / 无码这类构图不固定的封面才应该开启。
func TestMetaTubeArtworkURLsFaceDetectionPolicy(t *testing.T) {
cfg := MetaTubeConfig{ServerURL: "https://mt.example", CropCover: true}
cases := []struct {
code string
wantAuto string
}{
{"IPX-235", "false"},
{"SSNI-677", "false"},
{"NGOD-361", "false"},
{"FC2-PPV-482896", "true"},
{"HEYZO-1234", "true"},
{"123456-789", "true"},
}
for _, c := range cases {
poster, _ := metaTubeArtworkURLs(cfg, "javdb", "123456", "https://example.com/pl.jpg", c.code)
if !strings.Contains(poster, "auto="+c.wantAuto) {
t.Errorf("%s: poster URL = %q, want auto=%s", c.code, poster, c.wantAuto)
}
if !strings.Contains(poster, "pos=1") {
t.Errorf("%s: poster URL = %q, want pos=1", c.code, poster)
}
}
}
@@ -140,7 +140,10 @@ func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, d
if cropAdultPoster {
if cropped, croppedType, err := CropAdultCoverPosterForCode(data, adultCode); err == nil && len(cropped) > 0 {
data = cropped
ctype = croppedType
// 裁剪函数在“无需裁剪”时会原样返回,类型可能为空:此时保留 sniff 出来的 MIME。
if strings.TrimSpace(croppedType) != "" {
ctype = croppedType
}
}
}
return s.writeArtworkDataToPath(dir, name, ctype, data)
@@ -197,9 +200,25 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data []
return ""
}
s.log.Debug("scrape artwork written", zap.String("dst", dst))
s.removeLegacyArtworkImg(dir, name, dst)
return dst
}
// removeLegacyArtworkImg 清理同名历史 "<name>.img" 文件。
//
// 早期版本把 image.Decode 的格式名("jpeg"/"webp")当作 MIME 写进扩展名映射,
// 导致这些海报全部以 .img 落盘。现在写出正确扩展名后,旧 .img 只会是孤立垃圾,
// 但 local_metadata_artwork 仍会扫到它,所以一并清掉。
func (s *ScraperService) removeLegacyArtworkImg(dir, name, dst string) {
legacy := filepath.Join(dir, name+".img")
if strings.EqualFold(legacy, dst) {
return
}
if err := os.Remove(legacy); err == nil {
s.log.Debug("scrape artwork removed legacy .img", zap.String("dst", legacy))
}
}
// artworkFileUnchanged 报告 path 是否已经就是 data 这些字节。
//
// 刮削在刷新海报地址时会无条件重写 sidecar 图片,即使下载回来的字节一模一样。
@@ -172,6 +172,31 @@ func TestDownloadArtworkMigratesLocalImgToCanonicalCroppedJPEG(t *testing.T) {
}
}
// 已经裁好的竖版海报(例如 MetaTube 服务端裁好的 2:3 图)再次写入时不能再退化成 .img:
// 早期版本把 image.Decode 的格式名当 MIME 用,落盘扩展名就成了 .img。
func TestDownloadArtworkKeepsCanonicalExtensionForPortraitSource(t *testing.T) {
scraper := &ScraperService{log: zap.NewNop()}
dir := t.TempDir()
src := filepath.Join(dir, "movie-poster-src.jpg")
portrait := createTestImage(400, 600, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
if err := os.WriteFile(src, portrait, 0o644); err != nil {
t.Fatal(err)
}
// 模拟历史遗留的 .img 退格式文件。
legacy := filepath.Join(dir, "movie-poster.img")
if err := os.WriteFile(legacy, portrait, 0o644); err != nil {
t.Fatal(err)
}
dst := scraper.downloadArtworkToPathWithOptions(t.Context(), dir, "movie-poster", src, true, "SSNI-677")
if filepath.Base(dst) != "movie-poster.jpg" {
t.Fatalf("destination = %q, want canonical .jpg (not .img)", dst)
}
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
t.Fatalf("legacy .img should be cleaned up, stat err = %v", err)
}
}
// TestWriteMediaArtworkFilesAfterScrapeSkipsCloud verifies that cloud media
// never has artwork written anywhere.
func TestWriteMediaArtworkFilesAfterScrapeSkipsCloud(t *testing.T) {