mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
b0fe40142a
* Rebrand MMTL to MeBox across codebase and assets Rename the project display name, Go module path, environment variable prefix (MEBOX_*), Docker image references, and UI branding from MMTL/mmtl to MeBox/mebox. Replace logo assets with the new MeBox icon and keep legacy SQLite migration support for existing mmtl.db deployments. Co-authored-by: truewhile <truewhile@users.noreply.github.com> * Fix logo icons: use cube-only crop without truncated text Previous icon generation cropped too much of the source image, including partial MeBox wordmark text that was cut off in square icon containers. Regenerate logo-64/192/512, favicon, and SVG from cube-only region. Co-authored-by: truewhile <truewhile@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: truewhile <truewhile@users.noreply.github.com>
435 lines
15 KiB
Go
435 lines
15 KiB
Go
package service
|
||
|
||
import (
|
||
"errors"
|
||
"io"
|
||
"net/http"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/truewhile/MeBox/internal/model"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
func TestEnrichOneReturnsNoMatchPersistenceError(t *testing.T) {
|
||
scraper, repos, closeServer := newTestScraper(t)
|
||
defer closeServer()
|
||
|
||
lib := model.Library{Name: "电影", Path: t.TempDir(), Type: "movie", Enabled: true}
|
||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
media := model.Media{
|
||
LibraryID: lib.ID,
|
||
Title: "Definitely Missing Metadata Candidate",
|
||
Path: filepath.Join(lib.Path, "missing.mkv"),
|
||
ScrapeStatus: "pending",
|
||
}
|
||
if err := repos.DB.Create(&media).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
wantErr := errors.New("forced no-match update failure")
|
||
callbackName := "test:fail-no-match-update"
|
||
if err := repos.DB.Callback().Update().Before("gorm:update").Register(callbackName, func(tx *gorm.DB) {
|
||
if tx.Statement.Table == "media" {
|
||
tx.AddError(wantErr)
|
||
}
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { _ = repos.DB.Callback().Update().Remove(callbackName) })
|
||
|
||
if err := scraper.EnrichOne(t.Context(), &media); !errors.Is(err, wantErr) {
|
||
t.Fatalf("EnrichOne() error=%v, want %v", err, wantErr)
|
||
}
|
||
}
|
||
|
||
func TestEnrichOneUsesExistingTMDbIDForCloudMedia(t *testing.T) {
|
||
scraper, repos, closeServer := newTestScraper(t)
|
||
defer closeServer()
|
||
|
||
lib := model.Library{Name: "OpenList · 国漫", Path: "cloud://openlist/国漫", Type: "anime", Enabled: true}
|
||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
media := model.Media{
|
||
LibraryID: lib.ID,
|
||
Title: "dirty release title",
|
||
Path: "cloud://openlist/国漫/间谍过家家 (2022) {tmdb-12345}/Season 1/间谍过家家.S01E01.2160p.mkv",
|
||
SeasonNum: 1,
|
||
EpisodeNum: 1,
|
||
TMDbID: 12345,
|
||
ScrapeStatus: "pending",
|
||
}
|
||
if err := repos.DB.Create(&media).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var got model.Media
|
||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got.ScrapeStatus != "matched" || got.Title != "间谍过家家" || got.TMDbID != 12345 || got.PosterURL == "" {
|
||
t.Fatalf("tmdb id scrape did not apply match: title=%q status=%q tmdb=%d poster=%q", got.Title, got.ScrapeStatus, got.TMDbID, got.PosterURL)
|
||
}
|
||
}
|
||
|
||
func TestEnrichOneForceRematchReplacesStaleExternalIDsAndTaxonomy(t *testing.T) {
|
||
scraper, repos, closeServer := newTestScraper(t)
|
||
defer closeServer()
|
||
|
||
lib := model.Library{Name: "国产剧", Path: t.TempDir(), Type: "tv", Enabled: true}
|
||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
media := model.Media{
|
||
LibraryID: lib.ID,
|
||
Title: "错误旧匹配",
|
||
Path: filepath.Join(lib.Path, "Spy Family", "Season 01", "Spy Family - S01E01.mkv"),
|
||
SeasonNum: 1,
|
||
EpisodeNum: 1,
|
||
TMDbID: 999,
|
||
BangumiID: 88,
|
||
DoubanID: "stale",
|
||
Countries: "CN",
|
||
Languages: "zh",
|
||
Genres: "Drama",
|
||
ScrapeStatus: "matched",
|
||
}
|
||
if err := repos.DB.Create(&media).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if err := scraper.EnrichOneWithOptions(t.Context(), &media, ScrapeOptions{ForceRematch: true}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var got model.Media
|
||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got.TMDbID != 12345 || got.BangumiID != 0 || got.DoubanID != "" {
|
||
t.Fatalf("external ids after forced rematch=%d/%d/%q, want 12345/0/empty", got.TMDbID, got.BangumiID, got.DoubanID)
|
||
}
|
||
if got.Title != "间谍过家家" || got.Countries != "JP" || got.Languages != "ja" || !strings.Contains(got.Genres, "Animation") {
|
||
t.Fatalf("forced rematch metadata=%#v, want corrected Japanese animation metadata", got)
|
||
}
|
||
}
|
||
|
||
func TestEnrichOneWritesTMDbIDColumn(t *testing.T) {
|
||
scraper, repos, closeServer := newTestScraper(t)
|
||
defer closeServer()
|
||
|
||
lib := model.Library{Name: "番剧", Path: t.TempDir(), Type: "tv", Enabled: true}
|
||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
mediaPath := filepath.Join(lib.Path, "间谍过家家 - S02E01.mkv")
|
||
if err := repos.DB.Create(&model.Media{
|
||
LibraryID: lib.ID,
|
||
Title: "间谍过家家",
|
||
Path: mediaPath,
|
||
SeasonNum: 2,
|
||
EpisodeNum: 1,
|
||
ScrapeStatus: "pending",
|
||
}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
var media model.Media
|
||
if err := repos.DB.First(&media, "path = ?", mediaPath).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
var got model.Media
|
||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got.ScrapeStatus != "matched" || got.TMDbID != 12345 {
|
||
t.Fatalf("unexpected scraped media: status=%q tmdb=%d", got.ScrapeStatus, got.TMDbID)
|
||
}
|
||
}
|
||
|
||
func TestEnrichOneTreatsEpisodicMediaInMovieLibraryAsTV(t *testing.T) {
|
||
scraper, repos, closeServer := newTestScraper(t)
|
||
defer closeServer()
|
||
|
||
lib := model.Library{Name: "混合库", Path: t.TempDir(), Type: "movie", Enabled: true}
|
||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
media := model.Media{
|
||
LibraryID: lib.ID,
|
||
Title: "间谍过家家 S02E01",
|
||
Path: filepath.Join(lib.Path, "间谍过家家", "Season 02", "间谍过家家 - S02E01.mkv"),
|
||
SeasonNum: 2,
|
||
EpisodeNum: 1,
|
||
ScrapeStatus: "pending",
|
||
}
|
||
if err := repos.DB.Create(&media).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
var got model.Media
|
||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got.ScrapeStatus != "matched" || got.TMDbID != 12345 {
|
||
t.Fatalf("episodic media in movie library should use tv scrape: status=%q tmdb=%d", got.ScrapeStatus, got.TMDbID)
|
||
}
|
||
}
|
||
|
||
func TestDetermineMediaTypeForMediaHonorsExplicitMatchType(t *testing.T) {
|
||
scraper := &ScraperService{}
|
||
lib := &model.Library{Name: "欧美剧", Type: "tv"}
|
||
media := &model.Media{
|
||
Title: "错误识别的电影",
|
||
Path: filepath.Join("library", "欧美剧", "错误识别的电影 (2024)", "错误识别的电影.S01E202.mkv"),
|
||
SeasonNum: 1,
|
||
EpisodeNum: 202,
|
||
}
|
||
|
||
tests := []struct {
|
||
name string
|
||
match *Match
|
||
want string
|
||
}{
|
||
{name: "movie match overrides stale episode hints", match: &Match{MediaType: "movie"}, want: "movie"},
|
||
{name: "tv match stays tv", match: &Match{MediaType: "tv"}, want: "tv"},
|
||
{name: "anime match uses tmdb tv endpoint", match: &Match{MediaType: "anime"}, want: "tv"},
|
||
{name: "variety match uses tmdb tv endpoint", match: &Match{MediaType: "variety"}, want: "tv"},
|
||
{name: "adult match uses tmdb movie endpoint", match: &Match{MediaType: "adult"}, want: "movie"},
|
||
{name: "unknown match falls back to episodic hints", match: &Match{}, want: "tv"},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
if got := scraper.determineMediaTypeForMedia(lib, media, tt.match); got != tt.want {
|
||
t.Fatalf("determineMediaTypeForMedia() = %q, want %q", got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestEnrichOneWritesTMDbEpisodeMetadata(t *testing.T) {
|
||
scraper, repos, closeServer := newTestScraper(t)
|
||
defer closeServer()
|
||
|
||
lib := model.Library{Name: "番剧", Path: t.TempDir(), Type: "tv", Enabled: true}
|
||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
mediaPath := filepath.Join(lib.Path, "间谍过家家 - S02E01.mkv")
|
||
existingPoster := "https://image.tmdb.org/t/p/w500/existing-poster.jpg"
|
||
existingBackdrop := "https://image.tmdb.org/t/p/w1280/existing-backdrop.jpg"
|
||
media := model.Media{
|
||
LibraryID: lib.ID,
|
||
Title: "间谍过家家",
|
||
Path: mediaPath,
|
||
SeasonNum: 2,
|
||
EpisodeNum: 1,
|
||
PosterURL: existingPoster,
|
||
BackdropURL: existingBackdrop,
|
||
ScrapeStatus: "pending",
|
||
}
|
||
if err := repos.DB.Create(&media).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
var got model.Media
|
||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 单集专属信息(简介/剧照/评分/时长)应回填到该集行。
|
||
if got.Overview != "单集剧情" {
|
||
t.Fatalf("episode overview not saved: overview=%q", got.Overview)
|
||
}
|
||
if !strings.HasSuffix(got.BackdropURL, "/images/w500/still.jpg") || got.DurationSec != 24*60 {
|
||
t.Fatalf("episode still/runtime not saved: backdrop=%q duration=%d", got.BackdropURL, got.DurationSec)
|
||
}
|
||
if got.Rating < 9.09 || got.Rating > 9.11 {
|
||
t.Fatalf("episode rating = %v, want 9.1", got.Rating)
|
||
}
|
||
if got.EpisodeTitle != "任务代号: 猫" {
|
||
t.Fatalf("episode_title should store per-episode name, got %q", got.EpisodeTitle)
|
||
}
|
||
// original_name 必须保持「整剧原名」,绝不能被单集名(任务代号: 猫)覆盖,
|
||
// 否则同剧每集 original_name 不同会导致合集被拆成多集无法合并。
|
||
if got.OriginalName != "SPY×FAMILY" {
|
||
t.Fatalf("original_name should stay series-level, got %q (episode name must not overwrite it)", got.OriginalName)
|
||
}
|
||
}
|
||
|
||
func TestEnrichOneSkipsTMDbEpisodeStillWhenDisabled(t *testing.T) {
|
||
scraper, repos, closeServer := newTestScraper(t)
|
||
defer closeServer()
|
||
|
||
lib := model.Library{Name: "番剧", Path: t.TempDir(), Type: "tv", Enabled: true}
|
||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
mediaPath := filepath.Join(lib.Path, "间谍过家家 - S02E01.mkv")
|
||
existingPoster := "https://image.tmdb.org/t/p/w500/existing-poster.jpg"
|
||
existingBackdrop := "https://image.tmdb.org/t/p/w1280/existing-backdrop.jpg"
|
||
media := model.Media{
|
||
LibraryID: lib.ID,
|
||
Title: "间谍过家家",
|
||
Path: mediaPath,
|
||
SeasonNum: 2,
|
||
EpisodeNum: 1,
|
||
PosterURL: existingPoster,
|
||
BackdropURL: existingBackdrop,
|
||
ScrapeStatus: "pending",
|
||
}
|
||
if err := repos.DB.Create(&media).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
episodeArtwork := false
|
||
if err := scraper.EnrichOneWithOptions(t.Context(), &media, ScrapeOptions{EpisodeArtwork: &episodeArtwork}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
var got model.Media
|
||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got.Overview != "单集剧情" || got.DurationSec != 24*60 {
|
||
t.Fatalf("episode metadata should still be saved: overview=%q duration=%d", got.Overview, got.DurationSec)
|
||
}
|
||
if got.Rating < 9.09 || got.Rating > 9.11 {
|
||
t.Fatalf("episode rating = %v, want 9.1", got.Rating)
|
||
}
|
||
if strings.HasSuffix(got.BackdropURL, "/images/w500/still.jpg") {
|
||
t.Fatalf("episode still should not be saved when disabled: backdrop=%q", got.BackdropURL)
|
||
}
|
||
if !strings.HasSuffix(got.PosterURL, "/images/w500/poster.jpg") {
|
||
t.Fatalf("series poster should still be saved when episode artwork is disabled: got %q", got.PosterURL)
|
||
}
|
||
if !strings.HasSuffix(got.BackdropURL, "/images/w1280/backdrop.jpg") {
|
||
t.Fatalf("series backdrop should still be saved when episode artwork is disabled: got %q", got.BackdropURL)
|
||
}
|
||
if got.PosterURL == existingPoster || got.BackdropURL == existingBackdrop {
|
||
t.Fatalf("main artwork should be refreshed while episode still is skipped: poster=%q backdrop=%q", got.PosterURL, got.BackdropURL)
|
||
}
|
||
}
|
||
|
||
func TestApplyManualMatchSkipsTMDbEpisodeStillWhenDisabled(t *testing.T) {
|
||
scraper, repos, closeServer := newTestScraper(t)
|
||
defer closeServer()
|
||
|
||
lib := model.Library{Name: "番剧", Path: t.TempDir(), Type: "tv", Enabled: true}
|
||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
media := model.Media{
|
||
LibraryID: lib.ID,
|
||
Title: "待匹配",
|
||
Path: filepath.Join(lib.Path, "间谍过家家 - S02E01.mkv"),
|
||
SeasonNum: 2,
|
||
EpisodeNum: 1,
|
||
ScrapeStatus: "pending",
|
||
}
|
||
if err := repos.DB.Create(&media).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
episodeArtwork := false
|
||
got, err := scraper.ApplyManualMatch(t.Context(), media.ID, ManualScrapeRequest{
|
||
Source: "tmdb",
|
||
MediaType: "tv",
|
||
Title: "间谍过家家",
|
||
TMDbID: 12345,
|
||
EpisodeArtwork: &episodeArtwork,
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got == nil {
|
||
t.Fatal("manual match returned nil media")
|
||
}
|
||
if got.Overview != "单集剧情" || got.DurationSec != 24*60 {
|
||
t.Fatalf("episode metadata should still be saved: overview=%q duration=%d", got.Overview, got.DurationSec)
|
||
}
|
||
if strings.HasSuffix(got.BackdropURL, "/images/w500/still.jpg") {
|
||
t.Fatalf("manual episode still should not be saved when disabled: backdrop=%q", got.BackdropURL)
|
||
}
|
||
if !strings.HasSuffix(got.PosterURL, "/images/w500/poster.jpg") {
|
||
t.Fatalf("series poster should still be saved when manual episode artwork is disabled: got %q", got.PosterURL)
|
||
}
|
||
if !strings.HasSuffix(got.BackdropURL, "/images/w1280/backdrop.jpg") {
|
||
t.Fatalf("series backdrop should still be saved when manual episode artwork is disabled: got %q", got.BackdropURL)
|
||
}
|
||
}
|
||
|
||
func TestEnrichOneAdultScrapesArtwork(t *testing.T) {
|
||
scraper, repos, closeServer := newTestScraper(t)
|
||
defer closeServer()
|
||
|
||
lib := model.Library{Name: "小姐姐", Path: t.TempDir(), Type: "movie", Enabled: true}
|
||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
media := model.Media{
|
||
LibraryID: lib.ID,
|
||
Title: "IPX-235-C",
|
||
OriginalName: "IPX-235-C",
|
||
Path: filepath.Join(lib.Path, "IPX-235-C.strm"),
|
||
ScrapeStatus: "pending",
|
||
}
|
||
if err := repos.DB.Create(&media).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// Mock adult provider:RoundTripper 拦截 provider 发出的全部请求(不访问
|
||
// 外网),任何站点都返回带封面链接的测试 HTML——保证 CI 上不命中真实
|
||
// javdb/dmm 站点(外网可达时会把真实封面写进断言导致失败)。
|
||
adultHTML := `<html><h3>IPX-235 测试番号封面</h3><a class="bigImage" href="/pics/cover/ipx235.jpg"></a></html>`
|
||
mockAdultTransport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||
return &http.Response{
|
||
StatusCode: http.StatusOK,
|
||
Header: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}},
|
||
Body: io.NopCloser(strings.NewReader(adultHTML)),
|
||
}, nil
|
||
})
|
||
scraper.adult = &AdultProvider{
|
||
client: &http.Client{Transport: mockAdultTransport},
|
||
}
|
||
|
||
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
var got model.Media
|
||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got.ScrapeStatus != "matched" {
|
||
t.Fatalf("ScrapeStatus = %q, want 'matched'", got.ScrapeStatus)
|
||
}
|
||
// 封面来自 mock HTML 的 cover 链接(Host 随站点变化,只校验路径后缀)。
|
||
if got.PosterURL != "https://pics.dmm.co.jp/digital/video/ipx00235/ipx00235pl.jpg" && !strings.HasSuffix(got.PosterURL, "/pics/cover/ipx235.jpg") {
|
||
t.Fatalf("unexpected PosterURL = %q", got.PosterURL)
|
||
}
|
||
}
|
||
|
||
// roundTripFunc 把函数适配为 http.RoundTripper,用于测试中拦截全部外网请求。
|
||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||
|
||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|