mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
640 lines
22 KiB
Go
640 lines
22 KiB
Go
package service
|
||
|
||
import (
|
||
"fmt"
|
||
"testing"
|
||
"time"
|
||
|
||
"go.uber.org/zap"
|
||
|
||
"github.com/truewhile/MeBox/internal/config"
|
||
"github.com/truewhile/MeBox/internal/model"
|
||
"github.com/truewhile/MeBox/internal/repository"
|
||
)
|
||
|
||
func TestListRecentSeriesCardsCountsAllEpisodesInSeries(t *testing.T) {
|
||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||
repos := repository.New(db)
|
||
lib := model.Library{Name: "国漫", Path: "/media/anime", Type: "anime", Enabled: true}
|
||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
|
||
rows := make([]model.Media, 0, 40)
|
||
for i := 1; i <= 40; i++ {
|
||
created := now.Add(-48 * time.Hour)
|
||
if i > 23 {
|
||
created = now.Add(time.Duration(i) * time.Minute)
|
||
}
|
||
rows = append(rows, model.Media{
|
||
Base: model.Base{ID: fmt.Sprintf("recent-ep-%02d", i), CreatedAt: created, UpdatedAt: created},
|
||
LibraryID: lib.ID,
|
||
Title: "史上最强炼体老祖",
|
||
Path: fmt.Sprintf("/media/anime/国漫/史上最强炼体老祖/Season 01/史上最强炼体老祖.S01E%02d.mkv", i),
|
||
SeasonNum: 1,
|
||
EpisodeNum: i,
|
||
})
|
||
}
|
||
if err := repos.DB.Create(&rows).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||
|
||
cards, err := svc.ListRecentSeriesCards(t.Context(), 24, MediaVisibility{IncludeNSFW: true})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(cards) != 1 {
|
||
t.Fatalf("recent cards = %#v, want one series card", cards)
|
||
}
|
||
if cards[0].Count != 40 {
|
||
t.Fatalf("recent series count = %d, want full 40 episodes", cards[0].Count)
|
||
}
|
||
expectedLastAdded := now.Add(40 * time.Minute)
|
||
if cards[0].LastAddedAt == nil || !cards[0].LastAddedAt.Equal(expectedLastAdded) {
|
||
t.Fatalf("recent series LastAddedAt = %v, want %v", cards[0].LastAddedAt, expectedLastAdded)
|
||
}
|
||
}
|
||
|
||
func TestMediaSeriesKeyCollapsesNestedSpecialFolders(t *testing.T) {
|
||
main := model.Media{
|
||
LibraryID: "lib-tv",
|
||
Path: `cloud://openlist/动漫/国漫/示例剧/Season 01/示例剧.S01E01.mkv`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 1,
|
||
}
|
||
special := model.Media{
|
||
LibraryID: "lib-tv",
|
||
Path: `cloud://openlist/动漫/国漫/示例剧/Extras/Season 01/示例剧.SP01.mkv`,
|
||
}
|
||
|
||
if got, want := mediaSeriesKey(special), mediaSeriesKey(main); got != want {
|
||
t.Fatalf("special key=%q, want main key=%q", got, want)
|
||
}
|
||
|
||
cards := groupMediaSeriesCards([]model.Media{main, special})
|
||
if len(cards) != 1 || cards[0].Count != 2 {
|
||
t.Fatalf("cards=%#v, want one merged series card with two items", cards)
|
||
}
|
||
}
|
||
|
||
func TestGroupMediaSeriesCardsSortsByLatestEpisodeTime(t *testing.T) {
|
||
now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
|
||
newerFirstEpisode := model.Media{
|
||
Base: model.Base{CreatedAt: now.Add(-72 * time.Hour), UpdatedAt: now.Add(-72 * time.Hour)},
|
||
LibraryID: "lib-tv",
|
||
Title: "更新合集",
|
||
Path: `F:\media\电视剧\国产剧\更新合集\Season 01\更新合集.S01E01.mkv`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 1,
|
||
}
|
||
newerLatestEpisode := model.Media{
|
||
Base: model.Base{CreatedAt: now, UpdatedAt: now},
|
||
LibraryID: "lib-tv",
|
||
Title: "更新合集",
|
||
Path: `F:\media\电视剧\国产剧\更新合集\Season 01\更新合集.S01E02.mkv`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 2,
|
||
}
|
||
olderSeries := model.Media{
|
||
Base: model.Base{CreatedAt: now.Add(-24 * time.Hour), UpdatedAt: now.Add(-24 * time.Hour)},
|
||
LibraryID: "lib-tv",
|
||
Title: "较早合集",
|
||
Path: `F:\media\电视剧\国产剧\较早合集\Season 01\较早合集.S01E01.mkv`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 1,
|
||
}
|
||
|
||
cards := groupMediaSeriesCards([]model.Media{olderSeries, newerFirstEpisode, newerLatestEpisode})
|
||
if len(cards) != 2 {
|
||
t.Fatalf("cards=%#v, want two series cards", cards)
|
||
}
|
||
if cards[0].Key != mediaSeriesKey(newerFirstEpisode) {
|
||
t.Fatalf("first card key=%q, want latest series key=%q", cards[0].Key, mediaSeriesKey(newerFirstEpisode))
|
||
}
|
||
if cards[0].Count != 2 {
|
||
t.Fatalf("latest series count=%d, want 2", cards[0].Count)
|
||
}
|
||
}
|
||
|
||
func TestMediaSeriesKeyCollapsesSpecialTitleSuffix(t *testing.T) {
|
||
main := model.Media{
|
||
LibraryID: "lib-tv",
|
||
Path: `cloud://openlist/电视剧/欧美剧/Example Show/Season 01/Example.Show.S01E01.mkv`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 1,
|
||
}
|
||
special := model.Media{
|
||
LibraryID: "lib-tv",
|
||
Path: `cloud://openlist/电视剧/欧美剧/Example Show Specials/Example.Show.Special.01.mkv`,
|
||
SeasonNum: 0,
|
||
EpisodeNum: 1,
|
||
}
|
||
chineseSpecial := model.Media{
|
||
LibraryID: "lib-tv",
|
||
Path: `cloud://openlist/动漫/国漫/示例剧 特别篇/示例剧.SP01.mkv`,
|
||
SeasonNum: 0,
|
||
EpisodeNum: 1,
|
||
}
|
||
chineseMain := model.Media{
|
||
LibraryID: "lib-tv",
|
||
Path: `cloud://openlist/动漫/国漫/示例剧/Season 01/示例剧.S01E01.mkv`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 1,
|
||
}
|
||
|
||
if got, want := mediaSeriesKey(special), mediaSeriesKey(main); got != want {
|
||
t.Fatalf("english special key=%q, want main key=%q", got, want)
|
||
}
|
||
if got, want := mediaSeriesKey(chineseSpecial), mediaSeriesKey(chineseMain); got != want {
|
||
t.Fatalf("chinese special key=%q, want main key=%q", got, want)
|
||
}
|
||
}
|
||
|
||
func TestMediaSeriesKeyCollapsesSeasonZeroAndSpecialAliases(t *testing.T) {
|
||
main := model.Media{
|
||
LibraryID: "lib-anime",
|
||
Path: `cloud://openlist/动漫/日番/宝可梦 (1997) {tmdb-60572}/Season 1/宝可梦.S01E01.mkv`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 1,
|
||
}
|
||
seasonZero := model.Media{
|
||
LibraryID: "lib-anime",
|
||
Path: `cloud://openlist/动漫/日番/宝可梦 (1997) {tmdb-60572}/Season 0/宝可梦.S00E34.mkv`,
|
||
SeasonNum: 0,
|
||
EpisodeNum: 34,
|
||
}
|
||
specialEpisode := model.Media{
|
||
LibraryID: "lib-anime",
|
||
Path: `cloud://openlist/动漫/日番/宝可梦 Special Episode/宝可梦.SP01.mkv`,
|
||
SeasonNum: 0,
|
||
EpisodeNum: 1,
|
||
}
|
||
extraEpisode := model.Media{
|
||
LibraryID: "lib-anime",
|
||
Path: `cloud://openlist/动漫/日番/宝可梦 番外篇/宝可梦.SP02.mkv`,
|
||
SeasonNum: 0,
|
||
EpisodeNum: 2,
|
||
}
|
||
|
||
want := mediaSeriesKey(main)
|
||
for name, item := range map[string]model.Media{
|
||
"season zero": seasonZero,
|
||
"special episode": specialEpisode,
|
||
"番外篇": extraEpisode,
|
||
} {
|
||
if got := mediaSeriesKey(item); got != want {
|
||
t.Fatalf("%s key=%q, want main key=%q", name, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestMediaSeriesKeyCollapsesNumberedSpecialSuffixes(t *testing.T) {
|
||
main := model.Media{
|
||
LibraryID: "lib-tv",
|
||
Path: `F:\media\电视剧\欧美剧\Example Show\Season 01\Example Show - S01E01.mkv`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 1,
|
||
}
|
||
chineseMain := model.Media{
|
||
LibraryID: "lib-tv",
|
||
Path: `F:\media\电视剧\欧美剧\示例剧\Season 01\示例剧.S01E01.mkv`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 1,
|
||
}
|
||
cases := map[string]struct {
|
||
item model.Media
|
||
want model.Media
|
||
}{
|
||
"sp number": {
|
||
item: model.Media{
|
||
LibraryID: "lib-tv",
|
||
Path: `F:\media\电视剧\欧美剧\Example Show SP01\Example Show.SP01.mkv`,
|
||
SeasonNum: 0,
|
||
EpisodeNum: 1,
|
||
},
|
||
want: main,
|
||
},
|
||
"ova number": {
|
||
item: model.Media{
|
||
LibraryID: "lib-tv",
|
||
Path: `F:\media\电视剧\欧美剧\Example Show OVA 1\Example Show.OVA.1.mkv`,
|
||
SeasonNum: 0,
|
||
EpisodeNum: 1,
|
||
},
|
||
want: main,
|
||
},
|
||
"season zero episode": {
|
||
item: model.Media{
|
||
LibraryID: "lib-tv",
|
||
Path: `F:\media\电视剧\欧美剧\Example Show S00E01\Example Show.S00E01.mkv`,
|
||
SeasonNum: 0,
|
||
EpisodeNum: 1,
|
||
},
|
||
want: main,
|
||
},
|
||
"wrapped special": {
|
||
item: model.Media{
|
||
LibraryID: "lib-tv",
|
||
Path: `F:\media\电视剧\欧美剧\Example Show [Special]\Example Show.Special.mkv`,
|
||
SeasonNum: 0,
|
||
EpisodeNum: 1,
|
||
},
|
||
want: main,
|
||
},
|
||
"chinese numbered special": {
|
||
item: model.Media{
|
||
LibraryID: "lib-tv",
|
||
Path: `F:\media\电视剧\欧美剧\示例剧 特别篇 第1集\示例剧.SP01.mkv`,
|
||
SeasonNum: 0,
|
||
EpisodeNum: 1,
|
||
},
|
||
want: chineseMain,
|
||
},
|
||
}
|
||
for name, tt := range cases {
|
||
want := mediaSeriesKey(tt.want)
|
||
if got := mediaSeriesKey(tt.item); got != want {
|
||
t.Fatalf("%s key=%q, want main key=%q", name, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestMediaSeriesKeyCleansReleaseNoiseFolders(t *testing.T) {
|
||
clean := model.Media{
|
||
LibraryID: "lib-variety",
|
||
Path: `F:\media\电视剧\综艺\Hntv Spring Festival Gala S01e (2026)\Season 1\Hntv Spring Festival Gala S01e - S01E202.ts`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 202,
|
||
}
|
||
dirty := model.Media{
|
||
LibraryID: "lib-variety",
|
||
Path: `F:\media\电视剧\综艺\Hntv Spring Festival Gala Fps Hlg Qhstudio S01e (2026)\Season 1\Hntv Spring Festival Gala Fps Hlg Qhstudio S01e - S01E202.ts`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 202,
|
||
}
|
||
if got, want := mediaSeriesKey(dirty), mediaSeriesKey(clean); got != want {
|
||
t.Fatalf("dirty folder key=%q, want clean folder key=%q", got, want)
|
||
}
|
||
|
||
noisyRelease := model.Media{
|
||
LibraryID: "lib-tv",
|
||
Path: `F:\media\电视剧\欧美剧\Motherhood Of Taihang Aac2 Mweb\Season 1\Motherhood Of Taihang Aac2 Mweb - S01E01-Aac2.Mweb.mkv`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 1,
|
||
}
|
||
cleanRelease := model.Media{
|
||
LibraryID: "lib-tv",
|
||
Path: `F:\media\电视剧\欧美剧\Motherhood Of Taihang\Season 1\Motherhood Of Taihang - S01E01.mkv`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 1,
|
||
}
|
||
if got, want := mediaSeriesKey(noisyRelease), mediaSeriesKey(cleanRelease); got != want {
|
||
t.Fatalf("release-noise folder key=%q, want clean key=%q", got, want)
|
||
}
|
||
}
|
||
|
||
func TestMediaSeriesKeyTreatsDomesticTelevisionFolderAsSeries(t *testing.T) {
|
||
main := model.Media{
|
||
LibraryID: "lib-domestic-tv",
|
||
Path: `/media/国产电视剧/人世间 (2022) [TMDBID-156568]/人世间.S01E01.mkv`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 1,
|
||
TMDbID: 156568,
|
||
}
|
||
weakEpisode := model.Media{
|
||
LibraryID: "lib-domestic-tv",
|
||
Path: `/media/国产电视剧/人世间 (2022) [TMDBID-156568]/人世间.S01E02.mkv`,
|
||
// Some local/cloud scans may miss S/E at first while local NFO or
|
||
// scraper metadata already carries an episode-level TMDb id.
|
||
TMDbID: 4375419,
|
||
}
|
||
folderRecord := model.Media{
|
||
LibraryID: "lib-domestic-tv",
|
||
Path: `/media/国产电视剧/人世间 (2022) [TMDBID-156568]`,
|
||
Title: "人世间",
|
||
TMDbID: 156568,
|
||
}
|
||
|
||
if got, want := mediaSeriesKey(weakEpisode), mediaSeriesKey(main); got != want {
|
||
t.Fatalf("domestic television folder key=%q, want main key=%q", got, want)
|
||
}
|
||
if got, want := mediaSeriesKey(folderRecord), mediaSeriesKey(main); got != want {
|
||
t.Fatalf("domestic television folder record key=%q, want main key=%q", got, want)
|
||
}
|
||
|
||
cards := groupMediaSeriesCards([]model.Media{main, weakEpisode, folderRecord})
|
||
if len(cards) != 1 || cards[0].Count != 3 {
|
||
t.Fatalf("cards=%#v, want one merged series card with three items", cards)
|
||
}
|
||
}
|
||
|
||
func TestMediaSeriesKeyUsesSeriesDirectoryExternalID(t *testing.T) {
|
||
episodeIDOnly := model.Media{
|
||
LibraryID: "lib-domestic-tv",
|
||
Path: `/media/电视剧/国产剧/人世间 (2022)/Season 01/人世间.S01E03.{tmdb-7129826}.mkv`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 3,
|
||
TMDbID: 7129826,
|
||
}
|
||
cleanFolder := model.Media{
|
||
LibraryID: "lib-domestic-tv",
|
||
Path: `/media/电视剧/国产剧/人世间 (2022)/Season 01/人世间.S01E04.mkv`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 4,
|
||
TMDbID: 156568,
|
||
}
|
||
if got, want := mediaSeriesKey(episodeIDOnly), mediaSeriesKey(cleanFolder); got != want {
|
||
t.Fatalf("episode filename tmdb id should not split clean folder key=%q, want %q", got, want)
|
||
}
|
||
}
|
||
|
||
func TestGroupMediaSeriesCardsMergesPollutedEpisodeFoldersBySharedShowID(t *testing.T) {
|
||
items := []model.Media{
|
||
{
|
||
LibraryID: "lib-variety",
|
||
Title: "脱口秀和Ta的朋友们",
|
||
Path: `F:\media\电视剧\综艺\脱口秀和Ta的朋友们 第01期\Season 01\show.S01E01.mkv`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 1,
|
||
TMDbID: 260001,
|
||
ScrapeStatus: "matched",
|
||
},
|
||
{
|
||
LibraryID: "lib-variety",
|
||
Title: "脱口秀和Ta的朋友们",
|
||
Path: `F:\media\电视剧\综艺\脱口秀和Ta的朋友们 第02期\Season 01\show.S01E02.mkv`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 2,
|
||
TMDbID: 260001,
|
||
ScrapeStatus: "matched",
|
||
},
|
||
}
|
||
|
||
cards := groupMediaSeriesCards(items)
|
||
if len(cards) != 1 || cards[0].Count != 2 {
|
||
t.Fatalf("cards=%#v, want one show card with two episodes", cards)
|
||
}
|
||
}
|
||
|
||
func TestGroupMediaSeriesCardsKeepsMixedTitlesInSameEpisodicDirectoryTogether(t *testing.T) {
|
||
items := []model.Media{
|
||
{
|
||
Base: model.Base{ID: "main-1"},
|
||
LibraryID: "anime",
|
||
Title: "住在拔作岛上的我应该如何是好?",
|
||
Path: `/media/影视库/动漫/拔作岛/[64bitsub][Nukitashi][01][AVC_2×FLAC].mkv.strm`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 1,
|
||
ScrapeStatus: "matched",
|
||
},
|
||
{
|
||
Base: model.Base{ID: "main-2"},
|
||
LibraryID: "anime",
|
||
Title: "住在拔作岛上的我应该如何是好?",
|
||
Path: `/media/影视库/动漫/拔作岛/[64bitsub][Nukitashi][02][AVC_2×FLAC].mkv.strm`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 2,
|
||
ScrapeStatus: "matched",
|
||
},
|
||
{
|
||
Base: model.Base{ID: "special-1"},
|
||
LibraryID: "anime",
|
||
Title: "nukitashi",
|
||
Path: `/media/影视库/动漫/拔作岛/[64bitsub][Nukitashi][CM_01][AVC_FLAC].mkv.strm`,
|
||
ScrapeStatus: "matched",
|
||
},
|
||
{
|
||
Base: model.Base{ID: "special-2"},
|
||
LibraryID: "anime",
|
||
Title: "nukitashi",
|
||
Path: `/media/影视库/动漫/拔作岛/[64bitsub][Nukitashi][PV_01][AVC_FLAC].mkv.strm`,
|
||
ScrapeStatus: "matched",
|
||
},
|
||
}
|
||
|
||
cards := groupMediaSeriesCards(items)
|
||
if len(cards) != 1 {
|
||
t.Fatalf("cards=%#v, want main episodes and specials in the same directory folded into one card", cards)
|
||
}
|
||
if cards[0].Count != 4 {
|
||
t.Fatalf("series count=%d, want 4 items", cards[0].Count)
|
||
}
|
||
}
|
||
|
||
func TestGroupMediaSeriesCardsKeepsMovieVersionsAsOneMovie(t *testing.T) {
|
||
items := []model.Media{
|
||
{
|
||
Base: model.Base{ID: "movie-copy-a"},
|
||
LibraryID: "foreign-movies",
|
||
Title: "杀的就是你",
|
||
Path: `F:\media\电影\外语电影\They Will Kill You (2026)\movie-a.mkv`,
|
||
TMDbID: 1292695,
|
||
},
|
||
{
|
||
Base: model.Base{ID: "movie-copy-b"},
|
||
LibraryID: "western-movies",
|
||
Title: "杀的就是你",
|
||
Path: `F:\media\电影\欧美电影\They Will Kill You (2026)\movie-b.mkv`,
|
||
TMDbID: 1292695,
|
||
},
|
||
}
|
||
|
||
cards := groupMediaSeriesCards(items)
|
||
if len(cards) != 1 {
|
||
t.Fatalf("cards=%#v, want duplicate movie locations folded into one card", cards)
|
||
}
|
||
if cards[0].Count != 1 {
|
||
t.Fatalf("movie card count=%d, want 1 so versions are not shown as episodes", cards[0].Count)
|
||
}
|
||
}
|
||
|
||
func TestGroupMediaSeriesCardsKeepsTheatricalMovieWithTVSeries(t *testing.T) {
|
||
episode := model.Media{
|
||
Base: model.Base{ID: "episode"},
|
||
LibraryID: "anime",
|
||
Title: "摇曳露营△",
|
||
Path: `/media/动漫/摇曳露营△ (2018)/Season 01/摇曳露营△.S01E01.mkv`,
|
||
PosterURL: "https://image.tmdb.org/t/p/w500/episode.jpg",
|
||
SeasonNum: 1,
|
||
EpisodeNum: 1,
|
||
}
|
||
theatrical := model.Media{
|
||
Base: model.Base{ID: "theatrical"},
|
||
LibraryID: "anime",
|
||
Title: "摇曳露营△ 剧场版",
|
||
Path: `/media/动漫/摇曳露营△ (2018)/摇曳露营△ 剧场版 (2022)/Eiga.Yurukyan.2022.Bluray.mkv`,
|
||
PosterURL: "/media/动漫/摇曳露营△ (2018)/剧场版/poster.jpg",
|
||
BackdropURL: "/media/动漫/摇曳露营△ (2018)/剧场版/background.jpg",
|
||
Overview: "剧场版简介",
|
||
TMDbID: 566466,
|
||
}
|
||
|
||
cards := groupMediaSeriesCards([]model.Media{episode, theatrical})
|
||
if len(cards) != 1 {
|
||
t.Fatalf("cards=%#v, want theatrical movie retained with TV series", cards)
|
||
}
|
||
if cards[0].Count != 2 {
|
||
t.Fatalf("series card count=%d, want TV episode plus theatrical movie", cards[0].Count)
|
||
}
|
||
if cards[0].Rep.ID != episode.ID {
|
||
t.Fatalf("series representative=%q, want TV episode %q", cards[0].Rep.ID, episode.ID)
|
||
}
|
||
if cards[0].Rep.Title != episode.Title {
|
||
t.Fatalf("series representative title=%q, want %q", cards[0].Rep.Title, episode.Title)
|
||
}
|
||
}
|
||
|
||
func TestGroupMediaSeriesCardsDoesNotCollideMovieAndTVExternalIDs(t *testing.T) {
|
||
movie := model.Media{
|
||
Base: model.Base{ID: "movie"},
|
||
LibraryID: "movies",
|
||
Title: "同号电影",
|
||
Path: `/media/电影/同号电影 (2026)/movie.mkv`,
|
||
TMDbID: 12345,
|
||
}
|
||
episode := model.Media{
|
||
Base: model.Base{ID: "episode"},
|
||
LibraryID: "tv",
|
||
Title: "同号剧集",
|
||
Path: `/media/tv/同号剧集/episode.mkv`,
|
||
SeasonNum: 1,
|
||
EpisodeNum: 1,
|
||
TMDbID: 12345,
|
||
}
|
||
|
||
cards := groupMediaSeriesCards([]model.Media{movie, episode})
|
||
if len(cards) != 2 {
|
||
t.Fatalf("cards=%#v, want movie and TV item kept separate despite equal numeric TMDb IDs", cards)
|
||
}
|
||
}
|
||
|
||
func TestMediaSeriesKeyDoesNotUseGenericLibraryFolderAsSeriesTitle(t *testing.T) {
|
||
items := []model.Media{
|
||
{LibraryID: "tv", Path: `/media/电视剧/Alpha Show.S01E01.mkv`, SeasonNum: 1, EpisodeNum: 1},
|
||
{LibraryID: "tv", Path: `/media/电视剧/Beta Show.S01E01.mkv`, SeasonNum: 1, EpisodeNum: 1},
|
||
}
|
||
cards := groupMediaSeriesCards(items)
|
||
if len(cards) != 2 {
|
||
t.Fatalf("cards=%#v, want two independent series from a flat library root", cards)
|
||
}
|
||
}
|
||
|
||
func TestGroupMediaSeriesCardsBridgesReleaseFoldersByMatchedSeriesTitle(t *testing.T) {
|
||
items := []model.Media{
|
||
{LibraryID: "tv", Title: "同一部剧", ScrapeStatus: "matched", Path: `/media/电视剧/同一部剧 版本甲/Season 01/ep1.mkv`, SeasonNum: 1, EpisodeNum: 1},
|
||
{LibraryID: "tv", Title: "同一部剧", ScrapeStatus: "matched", Path: `/media/电视剧/同一部剧 版本乙/Season 01/ep2.mkv`, SeasonNum: 1, EpisodeNum: 2},
|
||
}
|
||
cards := groupMediaSeriesCards(items)
|
||
if len(cards) != 1 || cards[0].Count != 2 {
|
||
t.Fatalf("cards=%#v, want one series bridged by matched title", cards)
|
||
}
|
||
}
|
||
|
||
func TestGroupMediaSeriesCardsKeepsIndependentMoviesSeparateInSharedSubdirectory(t *testing.T) {
|
||
// 同一分类子目录下存放多部不同标题的独立电影,不应被强制折叠成 1 部
|
||
items := []model.Media{
|
||
{LibraryID: "movies", Title: "老师2024偷窥篇", Path: `/media/小姐姐/国产/nana/老师2024偷窥篇.strm`},
|
||
{LibraryID: "movies", Title: "紫光灯下的肉体诱惑", Path: `/media/小姐姐/国产/nana/紫光灯下的肉体诱惑.strm`},
|
||
{LibraryID: "movies", Title: "修洗衣机", Path: `/media/小姐姐/国产/nana/修洗衣机.strm`},
|
||
}
|
||
cards := groupMediaSeriesCards(items)
|
||
if len(cards) != 3 {
|
||
t.Fatalf("got %d cards, want 3 independent movie cards", len(cards))
|
||
}
|
||
|
||
// 但同一部电影的 CD1 和 CD2 仍应正确折叠为 1 部
|
||
cdItems := []model.Media{
|
||
{LibraryID: "movies", Title: "cd1", Path: `/media/电影/指环王 (2001)/cd1.mkv`},
|
||
{LibraryID: "movies", Title: "cd2", Path: `/media/电影/指环王 (2001)/cd2.mkv`},
|
||
}
|
||
cdCards := groupMediaSeriesCards(cdItems)
|
||
if len(cdCards) != 1 {
|
||
t.Fatalf("got %d cards for cd1/cd2, want 1 folded movie card", len(cdCards))
|
||
}
|
||
}
|
||
|
||
func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
|
||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||
repos := repository.New(db)
|
||
lib := model.Library{Base: model.Base{ID: "lib-movies"}, Name: "电影", Type: "movies", Enabled: true}
|
||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
m1 := model.Media{
|
||
Base: model.Base{ID: "m1"},
|
||
LibraryID: lib.ID,
|
||
Title: "老师2024偷窥篇",
|
||
Path: `/media/小姐姐/国产/nana/老师2024偷窥篇.strm`,
|
||
}
|
||
m2 := model.Media{
|
||
Base: model.Base{ID: "m2"},
|
||
LibraryID: lib.ID,
|
||
Title: "紫光灯下的肉体诱惑",
|
||
Path: `/media/小姐姐/国产/nana/紫光灯下的肉体诱惑.strm`,
|
||
}
|
||
m3 := model.Media{
|
||
Base: model.Base{ID: "m3"},
|
||
LibraryID: lib.ID,
|
||
Title: "修洗衣机",
|
||
Path: `/media/小姐姐/国产/nana/修洗衣机.strm`,
|
||
}
|
||
if err := repos.DB.Create(&[]model.Media{m1, m2, m3}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||
eps, err := svc.ListMediaEpisodes(t.Context(), "m1", MediaVisibility{IncludeNSFW: true})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(eps) != 1 || eps[0].ID != "m1" {
|
||
t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps)
|
||
}
|
||
}
|
||
|
||
func TestListLibrarySeriesCardsCachesPrecomputedCards(t *testing.T) {
|
||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||
repos := repository.New(db)
|
||
lib := model.Library{Name: "动画", Path: "/media/anime", Type: "anime", Enabled: true}
|
||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
rows := []model.Media{
|
||
{LibraryID: lib.ID, Title: "示例动画", Path: "/media/anime/示例动画/S01E01.mkv", SeasonNum: 1, EpisodeNum: 1},
|
||
{LibraryID: lib.ID, Title: "示例动画", Path: "/media/anime/示例动画/S01E02.mkv", SeasonNum: 1, EpisodeNum: 2},
|
||
}
|
||
if err := repos.DB.Create(&rows).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
|
||
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
|
||
visibility := MediaVisibility{IncludeNSFW: true}
|
||
cards, total, err := svc.ListLibrarySeriesCards(t.Context(), lib.ID, visibility)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if total != 1 || len(cards) != 1 || cards[0].Count != 2 {
|
||
t.Fatalf("cold series cards = %#v, total = %d; want one two-episode card", cards, total)
|
||
}
|
||
|
||
cachedObj, ok := svc.cache.GetObject(svc.libraryRowsCacheKey(lib.ID, visibility))
|
||
if !ok {
|
||
t.Fatal("expected full library rows and precomputed cards to be cached")
|
||
}
|
||
cached, ok := cachedObj.(*libraryRowsCacheValue)
|
||
if !ok || cached.Cards == nil || len(cached.Cards) != 1 {
|
||
t.Fatalf("cached rows value = %#v, want one precomputed card", cachedObj)
|
||
}
|
||
|
||
cards, total, err = svc.ListLibrarySeriesCards(t.Context(), lib.ID, visibility)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if total != 1 || len(cards) != 1 || cards[0].Count != 2 {
|
||
t.Fatalf("warm series cards = %#v, total = %d; want cached result", cards, total)
|
||
}
|
||
}
|