Files
2026-07-27 19:31:43 +08:00

619 lines
19 KiB
Go

package strmscrape
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"litepan/internal/mediaorganize/tmdb"
)
func TestGroupWorks_TVSeasonsCollapse(t *testing.T) {
root := t.TempDir()
show := filepath.Join(root, "三体 (2023)")
s1 := filepath.Join(show, "Season 01")
s2 := filepath.Join(show, "Season 02")
mustMkdir(t, s1)
mustMkdir(t, s2)
mustWrite(t, filepath.Join(s1, "E01.strm"), "x")
mustWrite(t, filepath.Join(s1, "E02.strm"), "x")
mustWrite(t, filepath.Join(s2, "E01.strm"), "x")
entries, err := scanStrmFiles(root)
if err != nil {
t.Fatal(err)
}
works := groupWorks(root, entries)
if len(works) != 1 {
t.Fatalf("want 1 work, got %d", len(works))
}
if len(works[0].entries) != 3 {
t.Fatalf("want 3 files in work, got %d", len(works[0].entries))
}
if inferMediaType(works[0]) != MediaTypeTV {
t.Fatalf("want tv, got %s", inferMediaType(works[0]))
}
item := buildItem(1, root, works[0])
if item.FileCount != 3 {
t.Fatalf("file_count=%d", item.FileCount)
}
nfo, poster := workMetaPaths(works[0], MediaTypeTV)
if filepath.Base(nfo) != "tvshow.nfo" || filepath.Base(poster) != "poster.jpg" {
t.Fatalf("meta paths = %s / %s", nfo, poster)
}
if seasonPosterPath(show, 1) != filepath.Join(show, "season01-poster.jpg") {
t.Fatalf("season poster path wrong")
}
}
func TestMovieMetaPathsMatchStrmStem(t *testing.T) {
root := t.TempDir()
movie := filepath.Join(root, "哪吒之魔童闹海 (2025)")
mustMkdir(t, movie)
strm := filepath.Join(movie, "Ne.Zha.2.2025.strm")
mustWrite(t, strm, "x")
works, err := scanWorks(root)
if err != nil {
t.Fatal(err)
}
nfo, poster := workMetaPaths(works[0], MediaTypeMovie)
if nfo != strings.TrimSuffix(strm, ".strm")+".nfo" {
t.Fatalf("nfo=%s", nfo)
}
if poster != filepath.Join(movie, "poster.jpg") {
t.Fatalf("poster=%s", poster)
}
}
func TestInferMediaType_MovieFolderIgnoresAudioFalseEpisode(t *testing.T) {
root := t.TempDir()
movie := filepath.Join(root, "哪吒之魔童闹海 (2025)")
mustMkdir(t, movie)
mustWrite(t, filepath.Join(movie, "Ne.Zha.2.2025.2160p.WEB-DL.DDP2.0.strm"), "x")
works, err := scanWorks(root)
if err != nil {
t.Fatal(err)
}
if len(works) != 1 {
t.Fatalf("works=%d", len(works))
}
if got := inferMediaType(works[0]); got != MediaTypeMovie {
t.Fatalf("want movie, got %s", got)
}
}
func TestGroupWorks_MovieFolders(t *testing.T) {
root := t.TempDir()
m1 := filepath.Join(root, "Inception (2010)")
m2 := filepath.Join(root, "Interstellar (2014)")
mustMkdir(t, m1)
mustMkdir(t, m2)
mustWrite(t, filepath.Join(m1, "Inception.strm"), "x")
mustWrite(t, filepath.Join(m2, "Interstellar.strm"), "x")
works, err := scanWorks(root)
if err != nil {
t.Fatal(err)
}
if len(works) != 2 {
t.Fatalf("want 2 works, got %d", len(works))
}
for _, g := range works {
if inferMediaType(g) != MediaTypeMovie {
t.Fatalf("%s: want movie", g.relKey)
}
if len(g.entries) != 1 {
t.Fatalf("%s: want 1 file", g.relKey)
}
}
}
func TestGroupWorks_FlatFilesStaySeparate(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "A.strm"), "x")
mustWrite(t, filepath.Join(root, "B.strm"), "x")
works, err := scanWorks(root)
if err != nil {
t.Fatal(err)
}
if len(works) != 2 {
t.Fatalf("want 2 flat works, got %d", len(works))
}
}
func TestPathToItemIDStable(t *testing.T) {
a := pathToItemID("Movies/Inception (2010)")
b := pathToItemID(filepath.FromSlash("Movies/Inception (2010)"))
if a == "" || a != b {
t.Fatalf("item id unstable: %q vs %q", a, b)
}
}
func TestNormalizeWriteMode(t *testing.T) {
if normalizeWriteMode("") != WriteModeMissingOnly {
t.Fatal("empty should be missing_only")
}
if normalizeWriteMode("overwrite") != WriteModeOverwrite {
t.Fatal("overwrite expected")
}
}
func TestParseStrmSeasonEpisode(t *testing.T) {
root := t.TempDir()
show := filepath.Join(root, "三体 (2023)")
s1 := filepath.Join(show, "Season 01")
mustMkdir(t, s1)
p := filepath.Join(s1, "三体.S01E02.strm")
mustWrite(t, p, "x")
sn, en := parseStrmSeasonEpisode(p)
if sn == nil || *sn != 1 {
t.Fatalf("season=%v", sn)
}
if en == nil || *en != 2 {
t.Fatalf("episode=%v", en)
}
}
func TestWriteSeasonAndEpisodeNFO(t *testing.T) {
root := t.TempDir()
seasonNFO := filepath.Join(root, "season.nfo")
if err := writeSeasonNFO(seasonNFO, 1, "第一季", "简介", "2023-01-01"); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(seasonNFO)
if err != nil {
t.Fatal(err)
}
text := string(body)
if !strings.Contains(text, "<season>") || !strings.Contains(text, "<seasonnumber>1</seasonnumber>") {
t.Fatalf("season nfo unexpected: %s", text)
}
epNFO := filepath.Join(root, "Show.S01E01.nfo")
if err := writeEpisodeNFO(epNFO, "开端", "三体", "本集简介", "2023-01-15", "123", 1, 1); err != nil {
t.Fatal(err)
}
body, err = os.ReadFile(epNFO)
if err != nil {
t.Fatal(err)
}
text = string(body)
if !strings.Contains(text, "<episodedetails>") || !strings.Contains(text, "<episode>1</episode>") {
t.Fatalf("episode nfo unexpected: %s", text)
}
if !strings.Contains(text, "<showtitle>三体</showtitle>") {
t.Fatalf("missing showtitle: %s", text)
}
}
func TestWriteMatchedPropagatesTVExtrasError(t *testing.T) {
root := t.TempDir()
show := filepath.Join(root, "三体 (2023)")
season := filepath.Join(show, "Season 01")
mustMkdir(t, season)
mustWrite(t, filepath.Join(season, "三体.S01E01.strm"), "x")
works, err := scanWorks(root)
if err != nil {
t.Fatal(err)
}
svc := &Service{}
client := tmdb.NewClient(tmdb.Options{}) // 空 API Key 会让季详情请求稳定失败
_, err = svc.writeMatchedOpts(context.Background(), client, works[0], tmdbInfo{
TMDBID: "123",
Title: "三体",
MediaType: MediaTypeTV,
EpisodeCount: 1,
}, true, true)
if err == nil || !strings.Contains(err.Error(), "补写季/集元数据失败") {
t.Fatalf("季集补写失败必须向上返回,实际 err=%v", err)
}
}
func TestStatusMissWhenOnlyNFO(t *testing.T) {
root := t.TempDir()
show := filepath.Join(root, "现在就出发 (2023)")
s1 := filepath.Join(show, "Season 01")
mustMkdir(t, s1)
mustWrite(t, filepath.Join(s1, "E01.strm"), "x")
mustWrite(t, filepath.Join(show, "tvshow.nfo"), "<tvshow><title>现在就出发</title><tmdbid>1</tmdbid></tvshow>\n")
works, err := scanWorks(root)
if err != nil {
t.Fatal(err)
}
item := buildItem(1, root, works[0])
if item.Status != ItemStatusMiss {
t.Fatalf("status=%s want miss (nfo only)", item.Status)
}
}
func TestStatusDoubtWithPending(t *testing.T) {
root := t.TempDir()
show := filepath.Join(root, "天龙八部")
s1 := filepath.Join(show, "Season 01")
mustMkdir(t, s1)
ep := filepath.Join(s1, "S01E01.strm")
mustWrite(t, ep, "x")
mustWrite(t, strings.TrimSuffix(ep, ".strm")+".nfo", "<episodedetails><title>1</title></episodedetails>\n")
mustWrite(t, filepath.Join(show, "tvshow.nfo"), "<tvshow><title>天龙八部</title><tmdbid>1</tmdbid></tvshow>\n")
mustWrite(t, filepath.Join(show, "poster.jpg"), "img")
works, err := scanWorks(root)
if err != nil {
t.Fatal(err)
}
_ = writePendingState(works[0], scrapeState{Status: PendingDoubt, EpLocal: 1, EpTMDB: 40})
item := buildItem(1, root, works[0])
if item.Status != ItemStatusDoubt {
t.Fatalf("status=%s want doubt", item.Status)
}
if item.EpLocal != 1 || item.EpTMDB != 40 {
t.Fatalf("ep local=%d tmdb=%d want from pending", item.EpLocal, item.EpTMDB)
}
clearPendingMarker(works[0])
item = buildItem(1, root, works[0])
if item.Status != ItemStatusOK {
t.Fatalf("after clear pending status=%s want ok", item.Status)
}
}
func TestInferMediaType_FlatTVWithoutSeasonDir(t *testing.T) {
root := t.TempDir()
show := filepath.Join(root, "现在就出发 (2023)")
mustMkdir(t, show)
mustWrite(t, filepath.Join(show, "S01E01.strm"), "x")
mustWrite(t, filepath.Join(show, "S01E02.strm"), "x")
works, err := scanWorks(root)
if err != nil {
t.Fatal(err)
}
if got := inferMediaType(works[0]); got != MediaTypeTV {
t.Fatalf("want tv for flat episodes, got %s", got)
}
}
func TestInferMediaType_SingleExplicitEpisodeInYearFolder(t *testing.T) {
root := t.TempDir()
show := filepath.Join(root, "三体 (2023)")
mustMkdir(t, show)
mustWrite(t, filepath.Join(show, "三体.S01E01.strm"), "x")
works, err := scanWorks(root)
if err != nil {
t.Fatal(err)
}
if got := inferMediaType(works[0]); got != MediaTypeTV {
t.Fatalf("want tv for one explicit episode, got %s", got)
}
}
func TestRootReadySkipsEvenIfEpisodeIncomplete(t *testing.T) {
root := t.TempDir()
show := filepath.Join(root, "海贼王")
s1 := filepath.Join(show, "Season 01")
mustMkdir(t, s1)
e1 := filepath.Join(s1, "S01E01.strm")
e2 := filepath.Join(s1, "S01E02.strm")
mustWrite(t, e1, "x")
mustWrite(t, e2, "x")
mustWrite(t, filepath.Join(show, "tvshow.nfo"), "<tvshow><title>海贼王</title></tvshow>\n")
mustWrite(t, filepath.Join(show, "poster.jpg"), "img")
mustWrite(t, strings.TrimSuffix(e1, ".strm")+".nfo", "<episodedetails><title>1</title></episodedetails>\n")
works, err := scanWorks(root)
if err != nil {
t.Fatal(err)
}
item := buildItem(1, root, works[0])
if item.Status != ItemStatusOK {
t.Fatalf("status=%s want ok", item.Status)
}
if item.TVState != TVStateEnded {
t.Fatalf("tv_state=%s want ended", item.TVState)
}
if workNeedsScrape(works[0], MediaTypeTV) {
t.Fatal("no pending + root ready should skip")
}
}
func TestPendingForcesScrapeAndUpdatingState(t *testing.T) {
root := t.TempDir()
show := filepath.Join(root, "罚罪 (2022)")
s1 := filepath.Join(show, "Season 01")
mustMkdir(t, s1)
mustWrite(t, filepath.Join(s1, "S01E01.strm"), "x")
mustWrite(t, filepath.Join(show, "tvshow.nfo"), "<tvshow><title>罚罪</title></tvshow>\n")
mustWrite(t, filepath.Join(show, "poster.jpg"), "img")
works, err := scanWorks(root)
if err != nil {
t.Fatal(err)
}
_ = writePendingState(works[0], scrapeState{Status: PendingUpdating, EpLocal: 1, EpTMDB: 40})
if !workNeedsScrape(works[0], MediaTypeTV) {
t.Fatal("pending must scrape")
}
item := buildItem(1, root, works[0])
if item.Status != ItemStatusOK {
t.Fatalf("status=%s want ok (追更且根齐备)", item.Status)
}
if !item.HasPending {
t.Fatal("updating should keep pending marker")
}
if item.TVState != TVStateUpdating {
t.Fatalf("tv_state=%s", item.TVState)
}
if item.EpTMDB != 40 || item.EpLocal != 1 {
t.Fatalf("ep local=%d tmdb=%d", item.EpLocal, item.EpTMDB)
}
}
func TestMarkNormalClearsPending(t *testing.T) {
root := t.TempDir()
show := filepath.Join(root, "短剧")
s1 := filepath.Join(show, "Season 01")
mustMkdir(t, s1)
mustWrite(t, filepath.Join(s1, "S01E01.strm"), "x")
mustWrite(t, filepath.Join(show, "tvshow.nfo"), "<tvshow><title>短剧</title></tvshow>\n")
mustWrite(t, filepath.Join(show, "poster.jpg"), "img")
works, err := scanWorks(root)
if err != nil {
t.Fatal(err)
}
_ = writePendingState(works[0], scrapeState{Status: PendingIncomplete, EpLocal: 1, EpTMDB: 1})
if err := markWorkNormal(works[0], MediaTypeTV); err != nil {
t.Fatal(err)
}
if hasPendingMarker(works[0]) {
t.Fatal("pending should be cleared")
}
item := buildItem(1, root, works[0])
if item.Status != ItemStatusOK || item.TVState != TVStateEnded {
t.Fatalf("status=%s tv_state=%s", item.Status, item.TVState)
}
if workNeedsScrape(works[0], MediaTypeTV) {
t.Fatal("after mark normal should skip")
}
}
func TestFinalizeKeepsPendingWhenLocalExceedsTMDB(t *testing.T) {
root := t.TempDir()
show := filepath.Join(root, "短剧")
s1 := filepath.Join(show, "Season 01")
mustMkdir(t, s1)
for i := 1; i <= 3; i++ {
p := filepath.Join(s1, fmt.Sprintf("S01E%02d.strm", i))
mustWrite(t, p, "x")
if i == 1 {
mustWrite(t, strings.TrimSuffix(p, ".strm")+".nfo", "<episodedetails><title>1</title></episodedetails>\n")
}
}
mustWrite(t, filepath.Join(show, "tvshow.nfo"), "<tvshow><title>短剧</title></tvshow>\n")
mustWrite(t, filepath.Join(show, "poster.jpg"), "img")
works, err := scanWorks(root)
if err != nil {
t.Fatal(err)
}
_ = writePendingMarker(works[0])
finalizeAfterScrape(works[0], MediaTypeTV, 1, false) // TMDB 只 1 集,本地 3 集
if !hasPendingMarker(works[0]) {
t.Fatal("short drama should keep pending (miss)")
}
st, _ := readPendingState(works[0])
if st.Status != PendingIncomplete {
t.Fatalf("status=%s want incomplete", st.Status)
}
item := buildItem(1, root, works[0])
if item.Status != ItemStatusMiss {
t.Fatalf("item status=%s want miss", item.Status)
}
if item.TVState == TVStateUpdating {
t.Fatal("short drama should not be labeled updating")
}
}
func TestFinalizeKeepsPendingWhenUpdating(t *testing.T) {
root := t.TempDir()
show := filepath.Join(root, "更新中")
s1 := filepath.Join(show, "Season 01")
mustMkdir(t, s1)
e1 := filepath.Join(s1, "S01E01.strm")
mustWrite(t, e1, "x")
mustWrite(t, strings.TrimSuffix(e1, ".strm")+".nfo", "<episodedetails><title>1</title></episodedetails>\n")
mustWrite(t, filepath.Join(show, "tvshow.nfo"), "<tvshow><title>x</title></tvshow>\n")
mustWrite(t, filepath.Join(show, "poster.jpg"), "img")
works, err := scanWorks(root)
if err != nil {
t.Fatal(err)
}
_ = writePendingMarker(works[0])
finalizeAfterScrape(works[0], MediaTypeTV, 40, false)
if !hasPendingMarker(works[0]) {
t.Fatal("updating should keep pending")
}
st, ok := readPendingState(works[0])
if !ok || st.Status != PendingUpdating || st.EpTMDB != 40 {
t.Fatalf("pending state=%+v ok=%v", st, ok)
}
}
func TestFinalizeKeepsPendingWhenDoubt(t *testing.T) {
root := t.TempDir()
show := filepath.Join(root, "天龙八部")
s1 := filepath.Join(show, "Season 01")
mustMkdir(t, s1)
e1 := filepath.Join(s1, "S01E01.strm")
mustWrite(t, e1, "x")
mustWrite(t, strings.TrimSuffix(e1, ".strm")+".nfo", "<episodedetails><title>1</title></episodedetails>\n")
mustWrite(t, filepath.Join(show, "tvshow.nfo"), "<tvshow><title>x</title></tvshow>\n")
mustWrite(t, filepath.Join(show, "poster.jpg"), "img")
works, err := scanWorks(root)
if err != nil {
t.Fatal(err)
}
_ = writePendingMarker(works[0])
finalizeAfterScrape(works[0], MediaTypeTV, 1, true)
st, ok := readPendingState(works[0])
if !ok || st.Status != PendingDoubt || st.EpLocal != 1 || st.EpTMDB != 1 {
t.Fatalf("pending state=%+v ok=%v", st, ok)
}
item := buildItem(1, root, works[0])
if item.Status != ItemStatusDoubt {
t.Fatalf("status=%s want doubt", item.Status)
}
}
func TestSeasonDirConflictSkippedInProgress(t *testing.T) {
root := t.TempDir()
show := filepath.Join(root, "罚罪 (2022)")
s1 := filepath.Join(show, "Season 1")
s2 := filepath.Join(show, "Season 2")
mustMkdir(t, s1)
mustMkdir(t, s2)
mustWrite(t, filepath.Join(s1, "罚罪.S01E29.strm"), "x")
mustWrite(t, filepath.Join(s2, "逍遥.S01E29.strm"), "x")
mustWrite(t, filepath.Join(show, "tvshow.nfo"), "<tvshow><title>罚罪</title></tvshow>\n")
mustWrite(t, filepath.Join(show, "poster.jpg"), "img")
works, err := scanWorks(root)
if err != nil {
t.Fatal(err)
}
total, scraped := countTVEpisodeProgress(works[0])
if total != 1 {
t.Fatalf("conflict file should be skipped, total=%d", total)
}
if scraped != 0 {
t.Fatalf("scraped=%d", scraped)
}
}
func TestCountTVEpisodeProgressSkipsSeasonZeroAndSpecials(t *testing.T) {
root := t.TempDir()
show := filepath.Join(root, "海贼王 (1999)")
s0 := filepath.Join(show, "Season 00")
s1 := filepath.Join(show, "Season 01")
sp := filepath.Join(show, "Specials")
mustMkdir(t, s0)
mustMkdir(t, s1)
mustMkdir(t, sp)
mustWrite(t, filepath.Join(s0, "S00E01.strm"), "x")
mustWrite(t, filepath.Join(s0, "S00E02.strm"), "x")
mustWrite(t, filepath.Join(sp, "特别篇01.strm"), "x")
e1 := filepath.Join(s1, "S01E01.strm")
e2 := filepath.Join(s1, "S01E02.strm")
mustWrite(t, e1, "x")
mustWrite(t, e2, "x")
mustWrite(t, strings.TrimSuffix(e1, ".strm")+".nfo", "<episodedetails><title>1</title></episodedetails>\n")
mustWrite(t, filepath.Join(show, "tvshow.nfo"), "<tvshow><title>航海王</title></tvshow>\n")
mustWrite(t, filepath.Join(show, "poster.jpg"), "img")
works, err := scanWorks(root)
if err != nil {
t.Fatal(err)
}
total, scraped := countTVEpisodeProgress(works[0])
if total != 2 {
t.Fatalf("want 2 main-season eps, total=%d", total)
}
if scraped != 1 {
t.Fatalf("scraped=%d want 1", scraped)
}
}
func TestListLocalRegularSeasonNumbersOnlyLocal(t *testing.T) {
root := t.TempDir()
show := filepath.Join(root, "亲爱的·客栈 (2017)")
s4 := filepath.Join(show, "Season 04")
sp := filepath.Join(show, "Specials")
mustMkdir(t, s4)
mustMkdir(t, sp)
mustWrite(t, filepath.Join(s4, "S04E01.strm"), "x")
mustWrite(t, filepath.Join(sp, "S00E01.strm"), "x")
works, err := scanWorks(root)
if err != nil {
t.Fatal(err)
}
got := listLocalRegularSeasonNumbers(works[0])
if len(got) != 1 || got[0] != 4 {
t.Fatalf("seasons=%v want [4]", got)
}
}
func TestSumTMDBSeasonEpisodeCountsLocalSeasonsOnly(t *testing.T) {
raw := []json.RawMessage{
json.RawMessage(`{"season_number":0,"episode_count":3}`),
json.RawMessage(`{"season_number":1,"episode_count":12}`),
json.RawMessage(`{"season_number":2,"episode_count":12}`),
json.RawMessage(`{"season_number":3,"episode_count":13}`),
json.RawMessage(`{"season_number":4,"episode_count":12}`),
}
if got := sumTMDBSeasonEpisodeCounts(raw, []int{4}); got != 12 {
t.Fatalf("got %d want 12", got)
}
if got := sumTMDBSeasonEpisodeCounts(raw, []int{1, 4}); got != 24 {
t.Fatalf("got %d want 24", got)
}
}
func TestEffectiveSeasonEpisodeCountUsesFinale(t *testing.T) {
eps := make([]tmdbEpisodeDetail, 0, 46)
for i := 1; i <= 46; i++ {
ep := tmdbEpisodeDetail{EpisodeNumber: i, EpisodeType: "standard"}
if i == 25 {
ep.EpisodeType = "finale"
}
eps = append(eps, ep)
}
detail := &tmdbSeasonDetail{Episodes: eps}
if got := effectiveSeasonEpisodeCount(detail, 46); got != 25 {
t.Fatalf("got %d want 25 (finale truncates shells)", got)
}
}
func TestEffectiveSeasonEpisodeCountFinaleWithAbsoluteEpisodeNumbers(t *testing.T) {
// 海贼王等:季内集号延续上季,finale 集号是绝对号,不能当成本季集数去累加
eps := make([]tmdbEpisodeDetail, 0, 52)
for i := 1000; i <= 1050; i++ {
ep := tmdbEpisodeDetail{EpisodeNumber: i, EpisodeType: "standard"}
if i == 1050 {
ep.EpisodeType = "finale"
}
eps = append(eps, ep)
}
for i := 1051; i <= 1060; i++ {
eps = append(eps, tmdbEpisodeDetail{EpisodeNumber: i, EpisodeType: "standard"})
}
detail := &tmdbSeasonDetail{Episodes: eps}
if got := effectiveSeasonEpisodeCount(detail, 61); got != 51 {
t.Fatalf("got %d want 51 (count eps <= finale, not finale number %d)", got, 1050)
}
}
func TestEffectiveSeasonEpisodeCountKeepsFallbackWithoutFinale(t *testing.T) {
detail := &tmdbSeasonDetail{
Episodes: []tmdbEpisodeDetail{
{EpisodeNumber: 1, EpisodeType: "standard"},
{EpisodeNumber: 12, EpisodeType: "mid_season"},
{EpisodeNumber: 13, EpisodeType: "standard"},
},
}
// 无 finale:不砍预占空壳,沿用 episode_count
if got := effectiveSeasonEpisodeCount(detail, 24); got != 24 {
t.Fatalf("got %d want 24", got)
}
if got := finaleEpisodeNumber(detail); got != 0 {
t.Fatalf("mid_season must not count as finale, got %d", got)
}
}
func mustMkdir(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
}
func mustWrite(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}