diff --git a/internal/service/adult_image_crop.go b/internal/service/adult_image_crop.go new file mode 100644 index 0000000..f342bb6 --- /dev/null +++ b/internal/service/adult_image_crop.go @@ -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 +} diff --git a/internal/service/adult_image_crop_test.go b/internal/service/adult_image_crop_test.go new file mode 100644 index 0000000..6121ccd --- /dev/null +++ b/internal/service/adult_image_crop_test.go @@ -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()) + } +} diff --git a/internal/service/scraper_write_metadata_files.go b/internal/service/scraper_write_metadata_files.go index f14ab7d..19dc063 100644 --- a/internal/service/scraper_write_metadata_files.go +++ b/internal/service/scraper_write_metadata_files.go @@ -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/.. 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/.. 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/. // using a temp file + rename so readers never observe a partial file. Returns