Compare commits

...

3 Commits

Author SHA1 Message Date
truewhile 1ea29cca9e bug处理 2026-09-09 00:39:00 +08:00
truewhile ec5f81e6cb bug处理 2026-09-09 00:18:04 +08:00
truewhile 8eb017fedc 优化 2026-09-09 00:03:14 +08:00
24 changed files with 390 additions and 51 deletions
+1 -1
View File
@@ -19,6 +19,7 @@ require (
github.com/ulikunitz/xz v0.5.12
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.49.0
golang.org/x/image v0.37.0
golang.org/x/sys v0.42.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
@@ -86,7 +87,6 @@ require (
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.25.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/image v0.37.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
+35
View File
@@ -1,7 +1,9 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
@@ -359,6 +361,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
Auth: auth,
Media: service.NewMediaService(cfg, log, repos),
Stream: service.NewStreamService(cfg, log, repos, nil),
Subtitle: service.NewSubtitleService(cfg, log, repos),
Permissions: permissions,
}
if err := repos.User.Create(t.Context(), &model.User{
@@ -440,6 +443,38 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
}
}
func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
loginToken := signedTestToken(t, secret)
resolveCalls := 0
svc.Subtitle.SetStrmPlayTargetResolver(func(context.Context, string) (*service.StrmPlayResult, error) {
resolveCalls++
return nil, errors.New("probe resolver called")
})
request := func(path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "http://nas.local"+path, nil)
req.Header.Set("Authorization", "Bearer "+loginToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
if w := request("/api/media/media-1/subtitles"); w.Code != http.StatusOK {
t.Fatalf("direct subtitle status = %d body=%s", w.Code, w.Body.String())
}
if resolveCalls != 0 {
t.Fatalf("direct subtitle request resolved cloud media %d times, want 0", resolveCalls)
}
if w := request("/api/media/media-1/subtitles?include_embedded=true"); w.Code != http.StatusOK {
t.Fatalf("HLS subtitle status = %d body=%s", w.Code, w.Body.String())
}
if resolveCalls != 1 {
t.Fatalf("HLS subtitle request resolved cloud media %d times, want 1", resolveCalls)
}
}
func TestHLSPlaylistForRemoteEmbyMediaDisabled(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
svc.EmbyRemote = &service.EmbyRemoteService{}
+7 -1
View File
@@ -16,7 +16,13 @@ func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{"tracks": []service.SubtitleTrack{}})
return
}
tracks, err := svc.Subtitle.Discover(c.Request.Context(), id)
var tracks []service.SubtitleTrack
var err error
if c.Query("include_embedded") == "true" {
tracks, err = svc.Subtitle.Discover(c.Request.Context(), id)
} else {
tracks, err = svc.Subtitle.DiscoverExternalOnly(c.Request.Context(), id)
}
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
+1
View File
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/metatube-community/metatube-sdk-go/detector"
_ "golang.org/x/image/webp" // register WebP decoder for AVE covers
)
const adultPosterRatio = 2.0 / 3.0
+22
View File
@@ -2,6 +2,7 @@ package service
import (
"bytes"
"encoding/base64"
"image"
"image/color"
"image/draw"
@@ -58,6 +59,27 @@ func TestCropAdultCoverPosterWideLandscape(t *testing.T) {
}
}
func TestCropAdultCoverPosterDecodesWebP(t *testing.T) {
wideWebP, err := base64.StdEncoding.DecodeString("UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoJAAYAAUAmJaACdLoB+AADsAD+8ut//NgVzXPv9//S4P0uD9Lg/9KQAAA=")
if err != nil {
t.Fatal(err)
}
croppedBytes, ctype, err := CropAdultCoverPoster(wideWebP)
if err != nil {
t.Fatalf("crop WebP: %v", err)
}
if ctype != "image/jpeg" {
t.Fatalf("cropped WebP type = %q, want image/jpeg", ctype)
}
cropped, _, err := image.Decode(bytes.NewReader(croppedBytes))
if err != nil {
t.Fatal(err)
}
if got := float64(cropped.Bounds().Dx()) / float64(cropped.Bounds().Dy()); got < 0.65 || got > 0.68 {
t.Fatalf("cropped WebP ratio = %.3f, want 2:3", got)
}
}
func TestCropAdultCoverPosterCentersDetectedFace(t *testing.T) {
originalDetector := findPrimaryFaceAxisRatio
findPrimaryFaceAxisRatio = func(image.Image, float64, bool) (float64, bool) {
+1
View File
@@ -23,6 +23,7 @@ func parseAdultDetailHTML(body, code, source, detailURL string) *Match {
}
match := &Match{
Provider: strings.ToLower(strings.TrimSpace(source)),
OriginalName: code,
MediaType: "adult",
NSFW: true,
+1 -1
View File
@@ -73,7 +73,7 @@ func isLocalImagePath(raw string) bool {
}
ext := strings.ToLower(filepath.Ext(raw))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn":
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn", ".img":
return true
default:
return false
+10 -4
View File
@@ -13,6 +13,12 @@ import (
"github.com/truewhile/MeBox/internal/config"
)
func TestIsLocalImagePathAcceptsLegacyImgSidecar(t *testing.T) {
if !isLocalImagePath(filepath.Join(t.TempDir(), "movie-poster.img")) {
t.Fatal("legacy .img sidecar should remain displayable")
}
}
var testJPEG = []byte{
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F',
0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01,
@@ -117,12 +123,12 @@ func TestIsPrivateHost(t *testing.T) {
// Hostnames must NOT be blocked even though GFW DNS poisoning may resolve
// them to private/loopback IPs — blocking them broke legitimate posters.
allowed := []string{"image.tmdb.org", "lain.bgm.tv", "example.com", "8.8.8.8"}
for _, h := range allowed {
if isPrivateHost(h) {
t.Errorf("isPrivateHost(%q) = true, want false", h)
}
for _, h := range allowed {
if isPrivateHost(h) {
t.Errorf("isPrivateHost(%q) = true, want false", h)
}
}
}
func TestImageProxyAllowedRemoteHostBypassesPrivateCheck(t *testing.T) {
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
+2
View File
@@ -310,6 +310,7 @@ func (p *MetaTubeProvider) convertSearchResultToMatch(cfg MetaTubeConfig, query
}
return &Match{
Provider: "metatube",
MediaType: "adult",
Title: formattedTitle,
OriginalName: code,
@@ -383,6 +384,7 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(cfg MetaTubeConfig, movie *Me
}
return &Match{
Provider: "metatube",
MediaType: "adult",
Title: formattedTitle,
OriginalName: code,
+3
View File
@@ -62,6 +62,9 @@ func TestMetaTubeProviderSearch(t *testing.T) {
t.Fatalf("expected 1 match, got %d", len(matches))
}
m := matches[0]
if m.Provider != "metatube" {
t.Errorf("expected provider metatube, got %q", m.Provider)
}
if m.OriginalName != "IPX-235" {
t.Errorf("expected original name IPX-235, got %s", m.OriginalName)
}
+10 -1
View File
@@ -93,7 +93,11 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi
}
if match == nil {
if local != nil && !local.PathHint {
return s.applyLocalMetadataMatch(ctx, m, local)
err := s.applyLocalMetadataMatch(ctx, m, local)
if err == nil {
options.recordProvider("local")
}
return err
}
if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", m.ID).
Update("scrape_status", "no_match").Error; err != nil {
@@ -227,6 +231,11 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
"thetvdb_id": match.TheTVDBID,
"source": map[bool]string{true: "adult"}[match.NSFW],
})
provider := strings.ToLower(strings.TrimSpace(match.Provider))
if provider == "" {
provider = "unknown"
}
options.recordProvider(provider)
return nil
}
+38 -1
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"net/url"
"strings"
"time"
@@ -20,10 +21,34 @@ func (s *ScraperService) prepareScrapedArtworkURL(ctx context.Context, mediaID,
if s == nil || s.images == nil || !isHTTPish(candidate) {
return candidate, ""
}
fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
originalSource := metaTubeOriginalArtworkURL(candidate)
timeout := 15 * time.Second
if originalSource != "" {
// MetaTube face processing can become very slow under a batch scrape.
// Fail over quickly so the same source can be processed locally.
timeout = 8 * time.Second
}
fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
err := s.images.PrefetchRemote(fetchCtx, candidate)
cancel()
if err != nil {
if originalSource != "" {
fallbackCtx, fallbackCancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
fallbackErr := s.images.PrefetchRemote(fallbackCtx, originalSource)
fallbackCancel()
if fallbackErr == nil {
s.log.Warn("MetaTube artwork processing failed; using local face-aware crop",
zap.String("media_id", mediaID),
zap.String("field", field),
zap.String("candidate", candidate),
zap.String("source", originalSource),
zap.Error(err))
if current != "" && isHTTPish(current) {
return originalSource, current
}
return originalSource, ""
}
}
if current != "" {
s.log.Warn("scrape artwork prefetch failed; keeping existing artwork",
zap.String("media_id", mediaID),
@@ -46,6 +71,18 @@ func (s *ScraperService) prepareScrapedArtworkURL(ctx context.Context, mediaID,
return candidate, ""
}
func metaTubeOriginalArtworkURL(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || !strings.Contains(parsed.Path, "/v1/images/primary/") {
return ""
}
source := strings.TrimSpace(parsed.Query().Get("url"))
if !isHTTPish(source) {
return ""
}
return source
}
func (s *ScraperService) removeCachedScrapedArtwork(urls ...string) {
if s == nil || s.images == nil {
return
+95 -2
View File
@@ -3,8 +3,11 @@ package service
import (
"bytes"
"errors"
"image"
"image/color"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -67,10 +70,14 @@ func TestApplyProviderMatchInvalidatesMediaCache(t *testing.T) {
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
match := &Match{Title: "Matched", PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg", BackdropURL: "https://image.tmdb.org/t/p/w1280/backdrop.jpg"}
if err := scraper.applyProviderMatch(t.Context(), &media, &lib, match); err != nil {
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) {
@@ -145,6 +152,92 @@ func TestApplyProviderMatchKeepsExistingArtworkWhenNewPrefetchFails(t *testing.T
}
}
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()
+4
View File
@@ -27,11 +27,13 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
}
if s.tmdb != nil && s.tmdb.Enabled() {
if match := s.lookupAutomaticTMDb(ctx, kind, query, year); match != nil {
match.Provider = "tmdb"
return match
}
}
if s.douban != nil && s.douban.Enabled() {
if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
m.Provider = "douban"
return m
} else if err != nil {
s.log.Debug("douban search failed", zap.String("query", query), zap.Error(err))
@@ -39,6 +41,7 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
}
if s.bangumi != nil && s.bangumi.Enabled() {
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
m.Provider = "bangumi"
return m
} else if err != nil {
s.log.Debug("bangumi search failed", zap.String("query", query), zap.Error(err))
@@ -46,6 +49,7 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
}
if (kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows") && s.thetvdb != nil && s.thetvdb.Enabled() {
if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
m.Provider = "thetvdb"
return m
} else if err != nil {
s.log.Debug("thetvdb search failed", zap.String("query", query), zap.Error(err))
+4
View File
@@ -23,6 +23,7 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
}
if match := s.manualTMDbMatchByID(ctx, m.TMDbID, normalizeMediaType(mediaType, m.Title, "")); match != nil {
if s.mediaExternalIDMatchTrusted(m, lib, match, "tmdb") {
match.Provider = "tmdb"
preferExistingLocalizedEpisodeTitle(m, lib, match)
return match
}
@@ -31,6 +32,7 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
if strings.TrimSpace(m.DoubanID) != "" && s.douban != nil && s.douban.Enabled() {
if match, err := s.douban.GetMatchByID(ctx, strings.TrimSpace(m.DoubanID)); err == nil && match != nil {
if s.mediaExternalIDMatchTrusted(m, lib, match, "douban") {
match.Provider = "douban"
preferExistingLocalizedEpisodeTitle(m, lib, match)
return match
}
@@ -41,6 +43,7 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
if m.BangumiID > 0 && s.bangumi != nil && s.bangumi.Enabled() {
if match, err := s.bangumi.GetSubject(ctx, m.BangumiID); err == nil && match != nil {
if s.mediaExternalIDMatchTrusted(m, lib, match, "bangumi") {
match.Provider = "bangumi"
preferExistingLocalizedEpisodeTitle(m, lib, match)
return match
}
@@ -51,6 +54,7 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
if strings.TrimSpace(m.TheTVDBID) != "" && s.thetvdb != nil && s.thetvdb.Enabled() {
if match, err := s.thetvdb.GetSeriesMatchByID(ctx, strings.TrimSpace(m.TheTVDBID)); err == nil && match != nil {
if s.mediaExternalIDMatchTrusted(m, lib, match, "thetvdb") {
match.Provider = "thetvdb"
preferExistingLocalizedEpisodeTitle(m, lib, match)
return match
}
+7
View File
@@ -8,6 +8,7 @@ type ScrapeOptions struct {
DeferEpisodeDetails bool
ForceRematch bool
RebuildIdentity bool
resultProvider *string
}
func (o ScrapeOptions) episodeArtworkEnabled() bool {
@@ -18,3 +19,9 @@ func skipEpisodeArtworkOptions(retryNoMatch bool) ScrapeOptions {
episodeArtwork := false
return ScrapeOptions{RetryNoMatch: retryNoMatch, EpisodeArtwork: &episodeArtwork}
}
func (o ScrapeOptions) recordProvider(provider string) {
if o.resultProvider != nil {
*o.resultProvider = provider
}
}
+5 -10
View File
@@ -115,10 +115,12 @@ func (s *ScraperService) processScrapeTask(ctx context.Context, task *model.Scra
}
epArtwork := task.EpisodeImages
usedProvider := ""
options := ScrapeOptions{
EpisodeArtwork: &epArtwork,
IncludeMatched: task.RefreshMatched,
RetryNoMatch: true,
resultProvider: &usedProvider,
}
enrichErr := s.EnrichOneWithOptions(ctx, media, options)
@@ -133,16 +135,9 @@ func (s *ScraperService) processScrapeTask(ctx context.Context, task *model.Scra
task.MatchedYear = refreshed.Year
task.PosterURL = refreshed.PosterURL
task.BackdropURL = refreshed.BackdropURL
if refreshed.TMDbID > 0 {
task.Provider = "tmdb"
} else if strings.TrimSpace(refreshed.DoubanID) != "" {
task.Provider = "douban"
} else if refreshed.BangumiID > 0 {
task.Provider = "bangumi"
} else if strings.TrimSpace(refreshed.TheTVDBID) != "" {
task.Provider = "thetvdb"
} else {
task.Provider = "metatube"
task.Provider = strings.ToLower(strings.TrimSpace(usedProvider))
if task.Provider == "" {
task.Provider = "unknown"
}
} else {
task.Status = model.ScrapeTaskFailed
@@ -91,10 +91,22 @@ func shouldCropAdultPoster(media *model.Media, lib *model.Library) bool {
// 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) string {
if !isHTTPish(raw) {
var (
data []byte
ctype string
err error
)
switch {
case isHTTPish(raw):
data, ctype, err = s.images.Fetch(ctx, raw)
case isLocalImagePath(raw):
data, err = os.ReadFile(sanitizeLocalPath(resolveMappedDestinationPath(raw)))
if err == nil {
ctype = detectContentType(data)
}
default:
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),
@@ -105,6 +117,12 @@ func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, d
if !isImageContentType(ctype) || isTransparentPlaceholderData(data) {
return ""
}
// Upstreams sometimes report non-standard values such as image/jpg.
// Use the decoded bytes as the source of truth so sidecars receive a
// standard extension instead of the legacy .img fallback.
if detected := detectContentType(data); isImageContentType(detected) {
ctype = detected
}
if cropAdultPoster {
if cropped, croppedType, err := CropAdultCoverPoster(data); err == nil && len(cropped) > 0 {
data = cropped
@@ -166,7 +184,7 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data []
// extension that could confuse media players.
func imageExtForContentType(ctype string) string {
switch strings.ToLower(strings.TrimSpace(strings.Split(ctype, ";")[0])) {
case "image/jpeg", "image/pjpeg":
case "image/jpeg", "image/jpg", "image/pjpeg":
return ".jpg"
case "image/png", "image/x-png":
return ".png"
@@ -88,6 +88,7 @@ func TestShouldCropAdultPosterUsesLibraryType(t *testing.T) {
func TestImageExtForContentType(t *testing.T) {
cases := map[string]string{
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/pjpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
@@ -103,6 +104,32 @@ func TestImageExtForContentType(t *testing.T) {
}
}
func TestDownloadArtworkMigratesLocalImgToCanonicalCroppedJPEG(t *testing.T) {
scraper := &ScraperService{log: zap.NewNop()}
dir := t.TempDir()
legacy := filepath.Join(dir, "movie-poster.img")
wide := createTestImage(900, 600, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
if err := os.WriteFile(legacy, wide, 0o644); err != nil {
t.Fatal(err)
}
dst := scraper.downloadArtworkToPathWithOptions(t.Context(), dir, "movie-poster", legacy, true)
if filepath.Base(dst) != "movie-poster.jpg" {
t.Fatalf("destination = %q, want canonical JPEG sidecar", dst)
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
cropped, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
if got := float64(cropped.Bounds().Dx()) / float64(cropped.Bounds().Dy()); got < 0.65 || got > 0.68 {
t.Fatalf("migrated poster ratio = %.3f, want 2:3", got)
}
}
// TestWriteMediaArtworkFilesAfterScrapeSkipsCloud verifies that cloud media
// never has artwork written anywhere.
func TestWriteMediaArtworkFilesAfterScrapeSkipsCloud(t *testing.T) {
+35 -19
View File
@@ -94,28 +94,28 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti
// DiscoverExternalOnly 只返回媒体旁边的外挂字幕文件,不含容器内嵌字幕轨。
// Emby 字幕接口(/Videos/:id/Subtitles/...)用。
func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
tracks, err := s.discover(ctx, mediaID)
cacheKey := "external:" + mediaID
if tracks, ok := s.cachedDiscovery(cacheKey); ok {
return tracks, nil
}
tracks, err := s.discoverExternalUncached(ctx, mediaID)
if err != nil {
return nil, err
}
out := make([]SubtitleTrack, 0, len(tracks))
for _, track := range tracks {
if track.Source != "embedded" {
out = append(out, track)
}
}
return out, nil
s.rememberDiscovery(cacheKey, tracks)
return tracks, nil
}
func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
if tracks, ok := s.cachedDiscovery(mediaID); ok {
cacheKey := "all:" + mediaID
if tracks, ok := s.cachedDiscovery(cacheKey); ok {
return tracks, nil
}
tracks, err := s.discoverUncached(ctx, mediaID)
if err != nil {
return nil, err
}
s.rememberDiscovery(mediaID, tracks)
s.rememberDiscovery(cacheKey, tracks)
return tracks, nil
}
@@ -159,6 +159,30 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
if m == nil {
return nil, errors.New("media not found")
}
tracks := discoverExternalSubtitleTracks(m)
embedded, err := s.discoverEmbedded(ctx, m)
if err != nil {
if s.log != nil {
s.log.Debug("discover embedded subtitles failed", zap.String("media_id", mediaID), zap.Error(err))
}
} else {
tracks = append(tracks, embedded...)
}
return tracks, nil
}
func (s *SubtitleService) discoverExternalUncached(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return nil, err
}
if m == nil {
return nil, errors.New("media not found")
}
return discoverExternalSubtitleTracks(m), nil
}
func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
dir := filepath.Dir(m.Path)
bases := mediaSidecarBaseVariants(m.Path)
if len(bases) == 0 {
@@ -214,15 +238,7 @@ func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string)
})
}
}
embedded, err := s.discoverEmbedded(ctx, m)
if err != nil {
if s.log != nil {
s.log.Debug("discover embedded subtitles failed", zap.String("media_id", mediaID), zap.Error(err))
}
} else {
tracks = append(tracks, embedded...)
}
return tracks, nil
return tracks
}
type embeddedSubtitleProbe struct {
+39
View File
@@ -2,7 +2,9 @@ package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
@@ -48,6 +50,43 @@ func TestSubtitleDiscoverNoTracksReturnsEmptySlice(t *testing.T) {
}
}
func TestDiscoverExternalOnlyDoesNotResolveOrProbeCloudMedia(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
t.Fatal(err)
}
media := model.Media{
Title: "Cloud Media",
Path: "cloud://115/example/video.mkv",
STRMURL: "cloud://115/example/video.mkv",
}
if err := db.Create(&media).Error; err != nil {
t.Fatal(err)
}
svc := NewSubtitleService(&config.Config{}, zap.NewNop(), repository.New(db))
resolveCalls := 0
svc.SetStrmPlayTargetResolver(func(context.Context, string) (*StrmPlayResult, error) {
resolveCalls++
return nil, errors.New("resolver must not be called")
})
tracks, err := svc.DiscoverExternalOnly(t.Context(), media.ID)
if err != nil {
t.Fatal(err)
}
if resolveCalls != 0 {
t.Fatalf("STRM resolver called %d times, want 0", resolveCalls)
}
if len(tracks) != 0 {
t.Fatalf("len(tracks) = %d, want 0", len(tracks))
}
}
func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
var probe embeddedSubtitleProbe
raw := []byte(`{"streams":[
+1
View File
@@ -4,6 +4,7 @@ package service
// across providers; provider-specific IDs sit side-by-side so the scraper
// orchestrator can write them all into a single update.
type Match struct {
Provider string `json:"provider,omitempty"`
TMDbID int `json:"tmdb_id"`
BangumiID int `json:"bangumi_id"`
DoubanID string `json:"douban_id,omitempty"`
+4 -2
View File
@@ -13,9 +13,11 @@ export interface SubtitleTrack {
}
export const subtitlesAPI = {
list: (mediaId: string) =>
list: (mediaId: string, includeEmbedded = false) =>
api
.get<{ tracks: SubtitleTrack[] | null }>(`/media/${mediaId}/subtitles`)
.get<{ tracks: SubtitleTrack[] | null }>(`/media/${mediaId}/subtitles`, {
params: includeEmbedded ? { include_embedded: 'true' } : undefined,
})
.then((r) => r.data.tracks ?? []),
url: (mediaId: string, path: string) => {
+17 -6
View File
@@ -237,8 +237,22 @@ export function PlayerPage() {
// 404 / 无权限等:给出可见错误提示,避免永久「加载中」
setLoadError(`无法加载该媒体:${apiErrorMessage(err)}`)
})
return () => {
cancelled = true
}
}, [id, modeParam, directOnly])
// Wire up the actual <video> element when we know the mode.
// Depend on media.id (not the media object): refreshing duration after
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
const mediaId = media?.id
// 直连播放只发现外挂字幕;只有 HLS 模式需要探测可烧录的内嵌字幕。
useEffect(() => {
if (!mediaId) return
let cancelled = false
subtitlesAPI
.list(id)
.list(mediaId, mode === 'hls')
.then((tracks) => {
if (cancelled) return
const list = tracks ?? []
@@ -249,16 +263,13 @@ export function PlayerPage() {
.catch(() => {
if (cancelled) return
setSubs([])
setSubtitleIndex(-1)
})
return () => {
cancelled = true
}
}, [id, modeParam, directOnly])
}, [mediaId, mode])
// Wire up the actual <video> element when we know the mode.
// Depend on media.id (not the media object): refreshing duration after
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
const mediaId = media?.id
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
const burnedSubtitleStream =
selectedSubtitle?.delivery === 'burn' ? selectedSubtitle.stream_index : undefined