Files
truewhile b0fe40142a Rebrand MMTL to MeBox (name, logo, Docker image) (#17)
* 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>
2026-09-02 16:26:28 +08:00

435 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) }