优化番号海报图片获取逻辑

优化番号海报图片获取逻辑
This commit is contained in:
truewhile
2026-08-20 22:31:00 +08:00
parent 111c38c36d
commit aa15cfe1f1
3 changed files with 208 additions and 24 deletions
+94
View File
@@ -0,0 +1,94 @@
package service
import (
"bytes"
"image"
"image/draw"
"image/jpeg"
_ "image/png" // register PNG decoder
"strings"
)
// IsAdultMediaPathOrMetadata reports whether media is adult based on path, mediaType, nsfw flag, or adult code.
func IsAdultMediaPathOrMetadata(path, mediaType string, nsfw bool) bool {
if nsfw {
return true
}
if normalizeOrganizeMediaType(mediaType) == "adult" {
return true
}
if AdultCodeFromMediaPath(path) != "" {
return true
}
return false
}
// IsAdultArtworkURL reports whether the URL is likely an adult cover/poster image.
func IsAdultArtworkURL(raw string) bool {
lower := strings.ToLower(raw)
return strings.Contains(lower, "javbus") ||
strings.Contains(lower, "javdb") ||
strings.Contains(lower, "jdbstatic") ||
strings.Contains(lower, "busjav") ||
strings.Contains(lower, "dmmbus") ||
strings.Contains(lower, "cdnbus") ||
strings.Contains(lower, "javsee") ||
strings.Contains(lower, "/covers/") ||
strings.Contains(lower, "pics.dmm.co.jp")
}
// CropAdultCoverPoster checks if the image data is a wide full-jacket DVD cover (width > height * 1.15).
// If so, it crops the right portion (the front cover poster) and returns the encoded JPEG bytes.
// If the image is already portrait or cannot be decoded, it safely returns the original data.
func CropAdultCoverPoster(data []byte) ([]byte, string, error) {
if len(data) == 0 {
return data, "", nil
}
src, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return data, format, err
}
bounds := src.Bounds()
width := bounds.Dx()
height := bounds.Dy()
if width <= 0 || height <= 0 {
return data, 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
}
// Front cover width is typically ~0.70-0.72 of the height (or right ~50-52% of total width)
cropWidth := int(float64(height) * 0.71)
maxCropWidth := int(float64(width) * 0.53)
if cropWidth > maxCropWidth {
cropWidth = maxCropWidth
}
if cropWidth <= 0 {
cropWidth = width / 2
}
minX := bounds.Max.X - cropWidth
if minX < bounds.Min.X {
minX = bounds.Min.X
}
cropRect := image.Rect(minX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)
type subImager interface {
SubImage(r image.Rectangle) image.Image
}
var cropped image.Image
if si, ok := src.(subImager); ok {
cropped = si.SubImage(cropRect)
} else {
dst := image.NewRGBA(image.Rect(0, 0, cropRect.Dx(), cropRect.Dy()))
draw.Draw(dst, dst.Bounds(), src, cropRect.Min, draw.Src)
cropped = dst
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, cropped, &jpeg.Options{Quality: 92}); err != nil {
return data, format, err
}
return buf.Bytes(), "image/jpeg", nil
}
+77
View File
@@ -0,0 +1,77 @@
package service
import (
"bytes"
"image"
"image/color"
"image/draw"
"image/jpeg"
"testing"
)
func createTestImage(width, height int, leftColor, rightColor color.Color) []byte {
img := image.NewRGBA(image.Rect(0, 0, width, height))
draw.Draw(img, image.Rect(0, 0, width/2, height), &image.Uniform{C: leftColor}, image.Point{}, draw.Src)
draw.Draw(img, image.Rect(width/2, 0, width, height), &image.Uniform{C: rightColor}, image.Point{}, draw.Src)
var buf bytes.Buffer
_ = jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90})
return buf.Bytes()
}
func TestCropAdultCoverPosterWideLandscape(t *testing.T) {
// Create an 800x538 wide DVD jacket cover
origBytes := createTestImage(800, 538, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
croppedBytes, ctype, err := CropAdultCoverPoster(origBytes)
if err != nil {
t.Fatalf("CropAdultCoverPoster error: %v", err)
}
if ctype != "image/jpeg" {
t.Fatalf("expected image/jpeg, got %s", ctype)
}
croppedImg, _, err := image.Decode(bytes.NewReader(croppedBytes))
if err != nil {
t.Fatalf("decode cropped image: %v", err)
}
bounds := croppedImg.Bounds()
croppedWidth := bounds.Dx()
croppedHeight := bounds.Dy()
if croppedHeight != 538 {
t.Fatalf("expected height 538, got %d", croppedHeight)
}
if croppedWidth <= 0 || croppedWidth >= 800 {
t.Fatalf("unexpected cropped width: %d", croppedWidth)
}
// Verify aspect ratio is roughly standard portrait (~0.68 - 0.73)
ratio := float64(croppedWidth) / float64(croppedHeight)
if ratio < 0.65 || ratio > 0.75 {
t.Fatalf("expected portrait ratio ~0.71, got %f (%dx%d)", ratio, croppedWidth, croppedHeight)
}
// Verify the cropped image contains the right side color (blue), not the left side color (red)
midPixel := croppedImg.At(croppedWidth/2, croppedHeight/2)
r, _, b, _ := midPixel.RGBA()
if b < r {
t.Fatalf("expected right side image content (dominant blue), got r=%d b=%d", r, b)
}
}
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)
if err != nil {
t.Fatalf("CropAdultCoverPoster error: %v", err)
}
croppedImg, _, err := image.Decode(bytes.NewReader(croppedBytes))
if err != nil {
t.Fatalf("decode image: %v", err)
}
if croppedImg.Bounds().Dx() != 500 || croppedImg.Bounds().Dy() != 700 {
t.Fatalf("portrait image was unexpectedly resized: %dx%d", croppedImg.Bounds().Dx(), croppedImg.Bounds().Dy())
}
}
@@ -44,34 +44,47 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context,
if base == "" || base == "." {
return
}
if refreshed.PosterURL != "" {
s.downloadArtworkToPath(ctx, dir, base+"-poster", refreshed.PosterURL)
isAdult := IsAdultMediaPathOrMetadata(refreshed.Path, refreshed.LibraryID, refreshed.NSFW) || IsAdultArtworkURL(refreshed.PosterURL)
if refreshed.PosterURL != "" {
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult)
}
if refreshed.BackdropURL != "" {
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.BackdropURL, false)
} else if isAdult && refreshed.PosterURL != "" {
// 番号海报原图为完整封套横图,在无独立背景图时直接作为背景图写出
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.PosterURL, false)
}
}
if refreshed.BackdropURL != "" {
s.downloadArtworkToPath(ctx, dir, base+"-backdrop", refreshed.BackdropURL)
func (s *ScraperService) downloadArtworkToPath(ctx context.Context, dir, name, raw string) {
s.downloadArtworkToPathWithOptions(ctx, dir, name, raw, false)
}
}
// downloadArtworkToPath fetches an artwork URL via the image proxy cache and
// writes it under dir/<name>.<ext>. It is best-effort: any failure is logged
// and returns without error so it never breaks scraping.
func (s *ScraperService) downloadArtworkToPath(ctx context.Context, dir, name, raw string) {
if !isHTTPish(raw) {
return
// downloadArtworkToPathWithOptions fetches an artwork URL via the image proxy cache and
// writes it under dir/<name>.<ext>. For adult posters, it crops the right half of the cover.
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool) {
if !isHTTPish(raw) {
return
}
data, ctype, err := s.images.Fetch(ctx, raw)
if err != nil || len(data) == 0 {
s.log.Warn("scrape artwork download failed",
zap.String("name", name),
zap.String("url", raw),
zap.Error(err))
return
}
if !isImageContentType(ctype) || isTransparentPlaceholderData(data) {
return
}
if cropAdultPoster {
if cropped, croppedType, err := CropAdultCoverPoster(data); err == nil && len(cropped) > 0 {
data = cropped
ctype = croppedType
}
}
s.writeArtworkDataToPath(dir, name, ctype, data)
}
data, ctype, err := s.images.Fetch(ctx, raw)
if err != nil || len(data) == 0 {
s.log.Warn("scrape artwork download failed",
zap.String("name", name),
zap.String("url", raw),
zap.Error(err))
return
}
if !isImageContentType(ctype) || isTransparentPlaceholderData(data) {
return
}
s.writeArtworkDataToPath(dir, name, ctype, data)
}
// writeArtworkDataToPath writes in-memory artwork bytes to dir/<name>.<ext>
// using a temp file + rename so readers never observe a partial file. Returns