diff --git a/internal/service/adult_image_crop.go b/internal/service/adult_image_crop.go index 411f99d..31c41bf 100644 --- a/internal/service/adult_image_crop.go +++ b/internal/service/adult_image_crop.go @@ -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. diff --git a/internal/service/adult_image_crop_test.go b/internal/service/adult_image_crop_test.go index 4eead39..25cfe03 100644 --- a/internal/service/adult_image_crop_test.go +++ b/internal/service/adult_image_crop_test.go @@ -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 { diff --git a/internal/service/adult_scraper_routing_test.go b/internal/service/adult_scraper_routing_test.go index 36e0e29..f754b94 100644 --- a/internal/service/adult_scraper_routing_test.go +++ b/internal/service/adult_scraper_routing_test.go @@ -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) } } diff --git a/internal/service/manual_scrape_test.go b/internal/service/manual_scrape_test.go index 22d7743..f18ae77 100644 --- a/internal/service/manual_scrape_test.go +++ b/internal/service/manual_scrape_test.go @@ -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) } diff --git a/internal/service/metatube.go b/internal/service/metatube.go index d3fb3b3..12a51fa 100644 --- a/internal/service/metatube.go +++ b/internal/service/metatube.go @@ -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") diff --git a/internal/service/metatube_test.go b/internal/service/metatube_test.go index 1dd32d1..4803cda 100644 --- a/internal/service/metatube_test.go +++ b/internal/service/metatube_test.go @@ -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) + } + } +} diff --git a/internal/service/scraper_write_metadata_files.go b/internal/service/scraper_write_metadata_files.go index 3d63f5e..bb860c9 100644 --- a/internal/service/scraper_write_metadata_files.go +++ b/internal/service/scraper_write_metadata_files.go @@ -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 清理同名历史 ".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 图片,即使下载回来的字节一模一样。 diff --git a/internal/service/scraper_write_metadata_files_test.go b/internal/service/scraper_write_metadata_files_test.go index 9cf945b..4ae98c4 100644 --- a/internal/service/scraper_write_metadata_files_test.go +++ b/internal/service/scraper_write_metadata_files_test.go @@ -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) {