mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 21:06:38 +08:00
refactor: split oversized service tests
This commit is contained in:
@@ -0,0 +1,327 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOrganizeDirectoryReclassifiesExistingWrongCategoryMedia(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if r.URL.Path != "/search/tv" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"results": []map[string]any{{
|
||||||
|
"id": 292696,
|
||||||
|
"name": "莫离",
|
||||||
|
"original_name": "The First Jasmine",
|
||||||
|
"original_language": "zh",
|
||||||
|
"origin_country": []string{"CN"},
|
||||||
|
"genre_ids": []int{18},
|
||||||
|
"first_air_date": "2026-06-23",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
repos := newOrganizerTestRepo(t)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Organizer.SmartClassify = true
|
||||||
|
cfg.Secrets.TMDbAPIKey = "test-key"
|
||||||
|
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
||||||
|
scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop()))
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
srcRoot := filepath.Join(root, "downloads")
|
||||||
|
dest := filepath.Join(root, "media")
|
||||||
|
sourceFile := filepath.Join(srcRoot, "欧美剧", "The.First.Jasmine.S01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb", "The.First.Jasmine.S01E01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb.mkv")
|
||||||
|
writeOrgFile(t, sourceFile, "episode")
|
||||||
|
|
||||||
|
euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true}
|
||||||
|
domesticLib := model.Library{Name: "国产剧", Path: filepath.Join(dest, "电视剧", "国产剧"), Type: "tv", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &euusLib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repos.Library.Create(t.Context(), &domesticLib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wrongPath := filepath.Join(euusLib.Path, "The First Jasmine", "Season 01", "The First Jasmine - S01E01.mkv")
|
||||||
|
writeOrgFile(t, wrongPath, "existing")
|
||||||
|
if err := repos.DB.Create(&model.Media{
|
||||||
|
LibraryID: euusLib.ID,
|
||||||
|
Title: "莫离",
|
||||||
|
OriginalName: "The First Jasmine",
|
||||||
|
Path: wrongPath,
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 1,
|
||||||
|
TMDbID: 292696,
|
||||||
|
Languages: "zh",
|
||||||
|
Countries: "CN",
|
||||||
|
Genres: "剧情",
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
|
||||||
|
organizer.SetScraper(scraper)
|
||||||
|
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
||||||
|
SourcePath: srcRoot,
|
||||||
|
DestPath: euusLib.Path,
|
||||||
|
TransferMode: TransferCopy,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("organize directory: %v", err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(domesticLib.Path, "莫离", "Season 01", "莫离 - S01E01.mkv")
|
||||||
|
if res.Reclassified != 1 || res.Organized != 0 || res.Skipped != 0 {
|
||||||
|
t.Fatalf("result = %+v, want reclassified=1 only", res)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(wrongPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("wrong category path should be moved away, stat err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(want); err != nil {
|
||||||
|
t.Fatalf("reclassified media missing at %q: %v; items=%#v", want, err, res.Items)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(sourceFile); err != nil {
|
||||||
|
t.Fatalf("source download should remain untouched: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got model.Media
|
||||||
|
if err := repos.DB.First(&got, "path = ?", want).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.LibraryID != domesticLib.ID {
|
||||||
|
t.Fatalf("library_id = %q, want domestic library %q", got.LibraryID, domesticLib.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizeDirectoryReclassifiesScannedAnimeUsingDBMetadata(t *testing.T) {
|
||||||
|
repos := newOrganizerTestRepo(t)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Organizer.SmartClassify = true
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
dest := filepath.Join(root, "media")
|
||||||
|
euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true}
|
||||||
|
tvAnimeLib := model.Library{Name: "国漫", Path: filepath.Join(dest, "电视剧", "国漫"), Type: "tv", Enabled: true}
|
||||||
|
animeLib := model.Library{Name: "国漫", Path: filepath.Join(dest, "动漫", "国漫"), Type: "anime", Enabled: true}
|
||||||
|
for _, lib := range []*model.Library{&euusLib, &tvAnimeLib, &animeLib} {
|
||||||
|
if err := repos.Library.Create(t.Context(), lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wrongPath := filepath.Join(euusLib.Path, "Blades Of The Guardians", "Season 2", "Blades Of The Guardians - S02E01-1080p.TX.WEB-DL.mkv")
|
||||||
|
writeOrgFile(t, wrongPath, "episode")
|
||||||
|
if err := repos.DB.Create(&model.Media{
|
||||||
|
LibraryID: euusLib.ID,
|
||||||
|
Title: "镖人",
|
||||||
|
OriginalName: "Blades Of The Guardians",
|
||||||
|
Path: wrongPath,
|
||||||
|
SeasonNum: 2,
|
||||||
|
EpisodeNum: 1,
|
||||||
|
TMDbID: 107463,
|
||||||
|
Languages: "zh",
|
||||||
|
Countries: "CN",
|
||||||
|
Genres: "动画,动作冒险",
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
|
||||||
|
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
||||||
|
SourcePath: filepath.Join(euusLib.Path, "Blades Of The Guardians"),
|
||||||
|
DestPath: euusLib.Path,
|
||||||
|
TransferMode: TransferCopy,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("organize directory: %v", err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(animeLib.Path, "镖人", "Season 02", "镖人 - S02E01.mkv")
|
||||||
|
if res.Reclassified != 1 || res.Organized != 0 {
|
||||||
|
t.Fatalf("result = %+v, want scanned DB metadata reclassified only", res)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(wrongPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("wrong anime path should be moved away, stat err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(want); err != nil {
|
||||||
|
t.Fatalf("anime should move to physical anime library at %q: %v; items=%#v", want, err, res.Items)
|
||||||
|
}
|
||||||
|
var got model.Media
|
||||||
|
if err := repos.DB.First(&got, "path = ?", want).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.LibraryID != animeLib.ID {
|
||||||
|
t.Fatalf("library_id = %q, want anime library %q", got.LibraryID, animeLib.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReclassifyMisclassifiedMediaMovesScannedAnimeToPhysicalAnimeLibrary(t *testing.T) {
|
||||||
|
repos := newOrganizerTestRepo(t)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Organizer.SmartClassify = true
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
dest := filepath.Join(root, "media")
|
||||||
|
euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true}
|
||||||
|
tvAnimeLib := model.Library{Name: "国漫", Path: filepath.Join(dest, "电视剧", "国漫"), Type: "tv", Enabled: true}
|
||||||
|
animeLib := model.Library{Name: "国漫", Path: filepath.Join(dest, "动漫", "国漫"), Type: "anime", Enabled: true}
|
||||||
|
for _, lib := range []*model.Library{&euusLib, &tvAnimeLib, &animeLib} {
|
||||||
|
if err := repos.Library.Create(t.Context(), lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wrongPath := filepath.Join(euusLib.Path, "Blades Of The Guardians", "Season 2", "Blades Of The Guardians - S02E01.mkv")
|
||||||
|
writeOrgFile(t, wrongPath, "episode")
|
||||||
|
if err := repos.DB.Create(&model.Media{
|
||||||
|
LibraryID: euusLib.ID,
|
||||||
|
Title: "镖人",
|
||||||
|
OriginalName: "Blades Of The Guardians",
|
||||||
|
Path: wrongPath,
|
||||||
|
SeasonNum: 2,
|
||||||
|
EpisodeNum: 1,
|
||||||
|
TMDbID: 107463,
|
||||||
|
Languages: "zh",
|
||||||
|
Countries: "CN",
|
||||||
|
Genres: "动画,动作冒险",
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
|
||||||
|
res, err := organizer.ReclassifyMisclassifiedMedia(t.Context(), MediaCategoryReclassifyOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reclassify media: %v", err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(animeLib.Path, "镖人", "Season 02", "镖人 - S02E01.mkv")
|
||||||
|
if res.Reclassified != 1 {
|
||||||
|
t.Fatalf("reclassified = %d, want 1; items=%#v errors=%#v", res.Reclassified, res.Items, res.Errors)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(want); err != nil {
|
||||||
|
t.Fatalf("bulk reclassify target missing at %q: %v", want, err)
|
||||||
|
}
|
||||||
|
var got model.Media
|
||||||
|
if err := repos.DB.First(&got, "path = ?", want).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.LibraryID != animeLib.ID {
|
||||||
|
t.Fatalf("library_id = %q, want anime library %q", got.LibraryID, animeLib.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizeDirectoryCleansWrongCategoryDuplicateWhenTargetExists(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if r.URL.Path != "/search/tv" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"results": []map[string]any{{
|
||||||
|
"id": 292696,
|
||||||
|
"name": "莫离",
|
||||||
|
"original_name": "The First Jasmine",
|
||||||
|
"original_language": "zh",
|
||||||
|
"origin_country": []string{"CN"},
|
||||||
|
"genre_ids": []int{18},
|
||||||
|
"first_air_date": "2026-06-23",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
repos := newOrganizerTestRepo(t)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Organizer.SmartClassify = true
|
||||||
|
cfg.Secrets.TMDbAPIKey = "test-key"
|
||||||
|
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
||||||
|
scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop()))
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
srcRoot := filepath.Join(root, "downloads")
|
||||||
|
dest := filepath.Join(root, "media")
|
||||||
|
sourceFile := filepath.Join(srcRoot, "欧美剧", "The.First.Jasmine.S01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb", "The.First.Jasmine.S01E01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb.mkv")
|
||||||
|
writeOrgFile(t, sourceFile, "episode")
|
||||||
|
|
||||||
|
euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true}
|
||||||
|
domesticLib := model.Library{Name: "国产剧", Path: filepath.Join(dest, "电视剧", "国产剧"), Type: "tv", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &euusLib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repos.Library.Create(t.Context(), &domesticLib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetPath := filepath.Join(domesticLib.Path, "莫离", "Season 01", "莫离 - S01E01.mkv")
|
||||||
|
wrongPath := filepath.Join(euusLib.Path, "The First Jasmine", "Season 01", "The First Jasmine - S01E01.mkv")
|
||||||
|
writeOrgFile(t, targetPath, "same-bytes")
|
||||||
|
writeOrgFile(t, wrongPath, "same-bytes")
|
||||||
|
if err := repos.DB.Create(&model.Media{
|
||||||
|
LibraryID: domesticLib.ID,
|
||||||
|
Title: "莫离",
|
||||||
|
Path: targetPath,
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 1,
|
||||||
|
TMDbID: 292696,
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repos.DB.Create(&model.Media{
|
||||||
|
LibraryID: euusLib.ID,
|
||||||
|
Title: "莫离",
|
||||||
|
Path: wrongPath,
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 1,
|
||||||
|
TMDbID: 292696,
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
|
||||||
|
organizer.SetScraper(scraper)
|
||||||
|
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
||||||
|
SourcePath: srcRoot,
|
||||||
|
DestPath: euusLib.Path,
|
||||||
|
TransferMode: TransferCopy,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("organize directory: %v", err)
|
||||||
|
}
|
||||||
|
if res.Reclassified != 1 || res.Organized != 0 || res.Skipped != 0 {
|
||||||
|
t.Fatalf("result = %+v, want reclassified=1 only", res)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(targetPath); err != nil {
|
||||||
|
t.Fatalf("canonical target should remain: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(wrongPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("wrong category duplicate should be removed, stat err=%v", err)
|
||||||
|
}
|
||||||
|
var wrongRows int64
|
||||||
|
if err := repos.DB.Model(&model.Media{}).Where("path = ?", wrongPath).Count(&wrongRows).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if wrongRows != 0 {
|
||||||
|
t.Fatalf("wrong DB rows = %d, want 0", wrongRows)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(sourceFile); err != nil {
|
||||||
|
t.Fatalf("source download should remain untouched: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -279,318 +279,6 @@ func TestOrganizeDirectoryMetadataCategoryOverridesDownloadFolder(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOrganizeDirectoryReclassifiesExistingWrongCategoryMedia(t *testing.T) {
|
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
if r.URL.Path != "/search/tv" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"results": []map[string]any{{
|
|
||||||
"id": 292696,
|
|
||||||
"name": "莫离",
|
|
||||||
"original_name": "The First Jasmine",
|
|
||||||
"original_language": "zh",
|
|
||||||
"origin_country": []string{"CN"},
|
|
||||||
"genre_ids": []int{18},
|
|
||||||
"first_air_date": "2026-06-23",
|
|
||||||
}},
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
defer upstream.Close()
|
|
||||||
|
|
||||||
repos := newOrganizerTestRepo(t)
|
|
||||||
cfg := &config.Config{}
|
|
||||||
cfg.Organizer.SmartClassify = true
|
|
||||||
cfg.Secrets.TMDbAPIKey = "test-key"
|
|
||||||
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
|
||||||
scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop()))
|
|
||||||
|
|
||||||
root := t.TempDir()
|
|
||||||
srcRoot := filepath.Join(root, "downloads")
|
|
||||||
dest := filepath.Join(root, "media")
|
|
||||||
sourceFile := filepath.Join(srcRoot, "欧美剧", "The.First.Jasmine.S01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb", "The.First.Jasmine.S01E01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb.mkv")
|
|
||||||
writeOrgFile(t, sourceFile, "episode")
|
|
||||||
|
|
||||||
euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true}
|
|
||||||
domesticLib := model.Library{Name: "国产剧", Path: filepath.Join(dest, "电视剧", "国产剧"), Type: "tv", Enabled: true}
|
|
||||||
if err := repos.Library.Create(t.Context(), &euusLib); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := repos.Library.Create(t.Context(), &domesticLib); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
wrongPath := filepath.Join(euusLib.Path, "The First Jasmine", "Season 01", "The First Jasmine - S01E01.mkv")
|
|
||||||
writeOrgFile(t, wrongPath, "existing")
|
|
||||||
if err := repos.DB.Create(&model.Media{
|
|
||||||
LibraryID: euusLib.ID,
|
|
||||||
Title: "莫离",
|
|
||||||
OriginalName: "The First Jasmine",
|
|
||||||
Path: wrongPath,
|
|
||||||
SeasonNum: 1,
|
|
||||||
EpisodeNum: 1,
|
|
||||||
TMDbID: 292696,
|
|
||||||
Languages: "zh",
|
|
||||||
Countries: "CN",
|
|
||||||
Genres: "剧情",
|
|
||||||
ScrapeStatus: "matched",
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
|
|
||||||
organizer.SetScraper(scraper)
|
|
||||||
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
|
||||||
SourcePath: srcRoot,
|
|
||||||
DestPath: euusLib.Path,
|
|
||||||
TransferMode: TransferCopy,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("organize directory: %v", err)
|
|
||||||
}
|
|
||||||
want := filepath.Join(domesticLib.Path, "莫离", "Season 01", "莫离 - S01E01.mkv")
|
|
||||||
if res.Reclassified != 1 || res.Organized != 0 || res.Skipped != 0 {
|
|
||||||
t.Fatalf("result = %+v, want reclassified=1 only", res)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(wrongPath); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("wrong category path should be moved away, stat err=%v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(want); err != nil {
|
|
||||||
t.Fatalf("reclassified media missing at %q: %v; items=%#v", want, err, res.Items)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(sourceFile); err != nil {
|
|
||||||
t.Fatalf("source download should remain untouched: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var got model.Media
|
|
||||||
if err := repos.DB.First(&got, "path = ?", want).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.LibraryID != domesticLib.ID {
|
|
||||||
t.Fatalf("library_id = %q, want domestic library %q", got.LibraryID, domesticLib.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOrganizeDirectoryReclassifiesScannedAnimeUsingDBMetadata(t *testing.T) {
|
|
||||||
repos := newOrganizerTestRepo(t)
|
|
||||||
cfg := &config.Config{}
|
|
||||||
cfg.Organizer.SmartClassify = true
|
|
||||||
|
|
||||||
root := t.TempDir()
|
|
||||||
dest := filepath.Join(root, "media")
|
|
||||||
euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true}
|
|
||||||
tvAnimeLib := model.Library{Name: "国漫", Path: filepath.Join(dest, "电视剧", "国漫"), Type: "tv", Enabled: true}
|
|
||||||
animeLib := model.Library{Name: "国漫", Path: filepath.Join(dest, "动漫", "国漫"), Type: "anime", Enabled: true}
|
|
||||||
for _, lib := range []*model.Library{&euusLib, &tvAnimeLib, &animeLib} {
|
|
||||||
if err := repos.Library.Create(t.Context(), lib); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wrongPath := filepath.Join(euusLib.Path, "Blades Of The Guardians", "Season 2", "Blades Of The Guardians - S02E01-1080p.TX.WEB-DL.mkv")
|
|
||||||
writeOrgFile(t, wrongPath, "episode")
|
|
||||||
if err := repos.DB.Create(&model.Media{
|
|
||||||
LibraryID: euusLib.ID,
|
|
||||||
Title: "镖人",
|
|
||||||
OriginalName: "Blades Of The Guardians",
|
|
||||||
Path: wrongPath,
|
|
||||||
SeasonNum: 2,
|
|
||||||
EpisodeNum: 1,
|
|
||||||
TMDbID: 107463,
|
|
||||||
Languages: "zh",
|
|
||||||
Countries: "CN",
|
|
||||||
Genres: "动画,动作冒险",
|
|
||||||
ScrapeStatus: "matched",
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
|
|
||||||
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
|
||||||
SourcePath: filepath.Join(euusLib.Path, "Blades Of The Guardians"),
|
|
||||||
DestPath: euusLib.Path,
|
|
||||||
TransferMode: TransferCopy,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("organize directory: %v", err)
|
|
||||||
}
|
|
||||||
want := filepath.Join(animeLib.Path, "镖人", "Season 02", "镖人 - S02E01.mkv")
|
|
||||||
if res.Reclassified != 1 || res.Organized != 0 {
|
|
||||||
t.Fatalf("result = %+v, want scanned DB metadata reclassified only", res)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(wrongPath); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("wrong anime path should be moved away, stat err=%v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(want); err != nil {
|
|
||||||
t.Fatalf("anime should move to physical anime library at %q: %v; items=%#v", want, err, res.Items)
|
|
||||||
}
|
|
||||||
var got model.Media
|
|
||||||
if err := repos.DB.First(&got, "path = ?", want).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.LibraryID != animeLib.ID {
|
|
||||||
t.Fatalf("library_id = %q, want anime library %q", got.LibraryID, animeLib.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReclassifyMisclassifiedMediaMovesScannedAnimeToPhysicalAnimeLibrary(t *testing.T) {
|
|
||||||
repos := newOrganizerTestRepo(t)
|
|
||||||
cfg := &config.Config{}
|
|
||||||
cfg.Organizer.SmartClassify = true
|
|
||||||
|
|
||||||
root := t.TempDir()
|
|
||||||
dest := filepath.Join(root, "media")
|
|
||||||
euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true}
|
|
||||||
tvAnimeLib := model.Library{Name: "国漫", Path: filepath.Join(dest, "电视剧", "国漫"), Type: "tv", Enabled: true}
|
|
||||||
animeLib := model.Library{Name: "国漫", Path: filepath.Join(dest, "动漫", "国漫"), Type: "anime", Enabled: true}
|
|
||||||
for _, lib := range []*model.Library{&euusLib, &tvAnimeLib, &animeLib} {
|
|
||||||
if err := repos.Library.Create(t.Context(), lib); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wrongPath := filepath.Join(euusLib.Path, "Blades Of The Guardians", "Season 2", "Blades Of The Guardians - S02E01.mkv")
|
|
||||||
writeOrgFile(t, wrongPath, "episode")
|
|
||||||
if err := repos.DB.Create(&model.Media{
|
|
||||||
LibraryID: euusLib.ID,
|
|
||||||
Title: "镖人",
|
|
||||||
OriginalName: "Blades Of The Guardians",
|
|
||||||
Path: wrongPath,
|
|
||||||
SeasonNum: 2,
|
|
||||||
EpisodeNum: 1,
|
|
||||||
TMDbID: 107463,
|
|
||||||
Languages: "zh",
|
|
||||||
Countries: "CN",
|
|
||||||
Genres: "动画,动作冒险",
|
|
||||||
ScrapeStatus: "matched",
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
|
|
||||||
res, err := organizer.ReclassifyMisclassifiedMedia(t.Context(), MediaCategoryReclassifyOptions{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reclassify media: %v", err)
|
|
||||||
}
|
|
||||||
want := filepath.Join(animeLib.Path, "镖人", "Season 02", "镖人 - S02E01.mkv")
|
|
||||||
if res.Reclassified != 1 {
|
|
||||||
t.Fatalf("reclassified = %d, want 1; items=%#v errors=%#v", res.Reclassified, res.Items, res.Errors)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(want); err != nil {
|
|
||||||
t.Fatalf("bulk reclassify target missing at %q: %v", want, err)
|
|
||||||
}
|
|
||||||
var got model.Media
|
|
||||||
if err := repos.DB.First(&got, "path = ?", want).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.LibraryID != animeLib.ID {
|
|
||||||
t.Fatalf("library_id = %q, want anime library %q", got.LibraryID, animeLib.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOrganizeDirectoryCleansWrongCategoryDuplicateWhenTargetExists(t *testing.T) {
|
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
if r.URL.Path != "/search/tv" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"results": []map[string]any{{
|
|
||||||
"id": 292696,
|
|
||||||
"name": "莫离",
|
|
||||||
"original_name": "The First Jasmine",
|
|
||||||
"original_language": "zh",
|
|
||||||
"origin_country": []string{"CN"},
|
|
||||||
"genre_ids": []int{18},
|
|
||||||
"first_air_date": "2026-06-23",
|
|
||||||
}},
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
defer upstream.Close()
|
|
||||||
|
|
||||||
repos := newOrganizerTestRepo(t)
|
|
||||||
cfg := &config.Config{}
|
|
||||||
cfg.Organizer.SmartClassify = true
|
|
||||||
cfg.Secrets.TMDbAPIKey = "test-key"
|
|
||||||
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
|
||||||
scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop()))
|
|
||||||
|
|
||||||
root := t.TempDir()
|
|
||||||
srcRoot := filepath.Join(root, "downloads")
|
|
||||||
dest := filepath.Join(root, "media")
|
|
||||||
sourceFile := filepath.Join(srcRoot, "欧美剧", "The.First.Jasmine.S01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb", "The.First.Jasmine.S01E01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb.mkv")
|
|
||||||
writeOrgFile(t, sourceFile, "episode")
|
|
||||||
|
|
||||||
euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true}
|
|
||||||
domesticLib := model.Library{Name: "国产剧", Path: filepath.Join(dest, "电视剧", "国产剧"), Type: "tv", Enabled: true}
|
|
||||||
if err := repos.Library.Create(t.Context(), &euusLib); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := repos.Library.Create(t.Context(), &domesticLib); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
targetPath := filepath.Join(domesticLib.Path, "莫离", "Season 01", "莫离 - S01E01.mkv")
|
|
||||||
wrongPath := filepath.Join(euusLib.Path, "The First Jasmine", "Season 01", "The First Jasmine - S01E01.mkv")
|
|
||||||
writeOrgFile(t, targetPath, "same-bytes")
|
|
||||||
writeOrgFile(t, wrongPath, "same-bytes")
|
|
||||||
if err := repos.DB.Create(&model.Media{
|
|
||||||
LibraryID: domesticLib.ID,
|
|
||||||
Title: "莫离",
|
|
||||||
Path: targetPath,
|
|
||||||
SeasonNum: 1,
|
|
||||||
EpisodeNum: 1,
|
|
||||||
TMDbID: 292696,
|
|
||||||
ScrapeStatus: "matched",
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := repos.DB.Create(&model.Media{
|
|
||||||
LibraryID: euusLib.ID,
|
|
||||||
Title: "莫离",
|
|
||||||
Path: wrongPath,
|
|
||||||
SeasonNum: 1,
|
|
||||||
EpisodeNum: 1,
|
|
||||||
TMDbID: 292696,
|
|
||||||
ScrapeStatus: "matched",
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
|
|
||||||
organizer.SetScraper(scraper)
|
|
||||||
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
|
||||||
SourcePath: srcRoot,
|
|
||||||
DestPath: euusLib.Path,
|
|
||||||
TransferMode: TransferCopy,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("organize directory: %v", err)
|
|
||||||
}
|
|
||||||
if res.Reclassified != 1 || res.Organized != 0 || res.Skipped != 0 {
|
|
||||||
t.Fatalf("result = %+v, want reclassified=1 only", res)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(targetPath); err != nil {
|
|
||||||
t.Fatalf("canonical target should remain: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(wrongPath); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("wrong category duplicate should be removed, stat err=%v", err)
|
|
||||||
}
|
|
||||||
var wrongRows int64
|
|
||||||
if err := repos.DB.Model(&model.Media{}).Where("path = ?", wrongPath).Count(&wrongRows).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if wrongRows != 0 {
|
|
||||||
t.Fatalf("wrong DB rows = %d, want 0", wrongRows)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(sourceFile); err != nil {
|
|
||||||
t.Fatalf("source download should remain untouched: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOrganizeDirectoryDoesNotScrapeByDownloadCategoryFolder(t *testing.T) {
|
func TestOrganizeDirectoryDoesNotScrapeByDownloadCategoryFolder(t *testing.T) {
|
||||||
var queries []string
|
var queries []string
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScanCloudLibraryCachesFileLevelRemoteArtwork(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case "PROPFIND":
|
||||||
|
if r.URL.Path != "/dav/Movies" {
|
||||||
|
t.Fatalf("unexpected propfind path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/xml")
|
||||||
|
w.WriteHeader(http.StatusMultiStatus)
|
||||||
|
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
<d:response><d:href>/dav/Movies/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/dav/Movies/Movie.mkv</d:href><d:propstat><d:prop><d:displayname>Movie.mkv</d:displayname><d:getcontentlength>4096</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/dav/Movies/Movie.nfo</d:href><d:propstat><d:prop><d:displayname>Movie.nfo</d:displayname><d:getcontentlength>128</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/dav/Movies/Movie.jpg</d:href><d:propstat><d:prop><d:displayname>Movie.jpg</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
||||||
|
</d:multistatus>`))
|
||||||
|
case http.MethodGet:
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/dav/Movies/Movie.nfo":
|
||||||
|
_, _ = w.Write([]byte(`<movie><title>Sidecar Movie</title><year>2026</year></movie>`))
|
||||||
|
case "/dav/Movies/Movie.jpg":
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
_, _ = w.Write([]byte("file-level-poster"))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected get path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %s", r.Method)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
log := zap.NewNop()
|
||||||
|
storage := NewStorageConfigService(log, repos, NewCryptoService("", log))
|
||||||
|
if _, err := storage.Save(t.Context(), StorageInput{
|
||||||
|
Type: "openlist",
|
||||||
|
Config: map[string]any{
|
||||||
|
"url": upstream.URL,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil)
|
||||||
|
scanner.SetStorageConfig(storage)
|
||||||
|
imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log)
|
||||||
|
scanner.SetImageProxy(imageProxy)
|
||||||
|
|
||||||
|
res, err := scanner.ScanLibrary(t.Context(), lib.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scan cloud: %v", err)
|
||||||
|
}
|
||||||
|
if res.Added != 1 || res.LocalMetadata != 1 {
|
||||||
|
t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res)
|
||||||
|
}
|
||||||
|
var media model.Media
|
||||||
|
if err := repos.DB.First(&media).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if media.Title != "Sidecar Movie" || media.Year != 2026 {
|
||||||
|
t.Fatalf("metadata not applied: %#v", media)
|
||||||
|
}
|
||||||
|
if media.PosterURL != "/api/img/cloud/openlist?ref=%2FMovies%2FMovie.jpg" {
|
||||||
|
t.Fatalf("poster url = %q", media.PosterURL)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Movie.jpg") {
|
||||||
|
t.Fatal("file-level cloud poster should be cached locally during scan before media is exposed")
|
||||||
|
}
|
||||||
|
if got := rec.Body.String(); got != "file-level-poster" {
|
||||||
|
t.Fatalf("cached poster body = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanCloudLibraryUsesArtworkReferencedByRemoteNFO(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case "PROPFIND":
|
||||||
|
if r.URL.Path != "/dav/Movies" {
|
||||||
|
t.Fatalf("unexpected propfind path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/xml")
|
||||||
|
w.WriteHeader(http.StatusMultiStatus)
|
||||||
|
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
<d:response><d:href>/dav/Movies/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/dav/Movies/Movie.mkv</d:href><d:propstat><d:prop><d:displayname>Movie.mkv</d:displayname><d:getcontentlength>4096</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/dav/Movies/Movie.nfo</d:href><d:propstat><d:prop><d:displayname>Movie.nfo</d:displayname><d:getcontentlength>256</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/dav/Movies/Artwork.Custom.tbn</d:href><d:propstat><d:prop><d:displayname>Artwork.Custom.tbn</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/dav/Movies/Scene.Still.png</d:href><d:propstat><d:prop><d:displayname>Scene.Still.png</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
||||||
|
</d:multistatus>`))
|
||||||
|
case http.MethodGet:
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/dav/Movies/Movie.nfo":
|
||||||
|
_, _ = w.Write([]byte(`<movie><title>NFO Custom Artwork</title><thumb aspect="poster">Artwork.Custom.tbn</thumb><fanart><thumb>Scene.Still.png?version=1</thumb></fanart></movie>`))
|
||||||
|
case "/dav/Movies/Artwork.Custom.tbn":
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
_, _ = w.Write([]byte("custom-poster"))
|
||||||
|
case "/dav/Movies/Scene.Still.png":
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
_, _ = w.Write([]byte("custom-backdrop"))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected get path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %s", r.Method)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
log := zap.NewNop()
|
||||||
|
storage := NewStorageConfigService(log, repos, NewCryptoService("", log))
|
||||||
|
if _, err := storage.Save(t.Context(), StorageInput{
|
||||||
|
Type: "openlist",
|
||||||
|
Config: map[string]any{
|
||||||
|
"url": upstream.URL,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil)
|
||||||
|
scanner.SetStorageConfig(storage)
|
||||||
|
imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log)
|
||||||
|
scanner.SetImageProxy(imageProxy)
|
||||||
|
|
||||||
|
res, err := scanner.ScanLibrary(t.Context(), lib.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scan cloud: %v", err)
|
||||||
|
}
|
||||||
|
if res.Added != 1 || res.LocalMetadata != 1 {
|
||||||
|
t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res)
|
||||||
|
}
|
||||||
|
var media model.Media
|
||||||
|
if err := repos.DB.First(&media).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if media.Title != "NFO Custom Artwork" {
|
||||||
|
t.Fatalf("metadata title = %q", media.Title)
|
||||||
|
}
|
||||||
|
if media.PosterURL != "/api/img/cloud/openlist?ref=%2FMovies%2FArtwork.Custom.tbn" {
|
||||||
|
t.Fatalf("poster url = %q", media.PosterURL)
|
||||||
|
}
|
||||||
|
if media.BackdropURL != "/api/img/cloud/openlist?ref=%2FMovies%2FScene.Still.png" {
|
||||||
|
t.Fatalf("backdrop url = %q", media.BackdropURL)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Artwork.Custom.tbn") {
|
||||||
|
t.Fatal("NFO-referenced cloud poster should be cached locally during scan")
|
||||||
|
}
|
||||||
|
if got := rec.Body.String(); got != "custom-poster" {
|
||||||
|
t.Fatalf("cached poster body = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanCloudLibraryReadsRemoteNFOAndArtwork(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case "PROPFIND":
|
||||||
|
w.Header().Set("Content-Type", "application/xml")
|
||||||
|
w.WriteHeader(http.StatusMultiStatus)
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/dav/Anime/JianLai":
|
||||||
|
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
<d:response><d:href>/dav/Anime/JianLai/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/dav/Anime/JianLai/tvshow.nfo</d:href><d:propstat><d:prop><d:displayname>tvshow.nfo</d:displayname><d:getcontentlength>64</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/dav/Anime/JianLai/poster.jpg</d:href><d:propstat><d:prop><d:displayname>poster.jpg</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/dav/Anime/JianLai/Season1/</d:href><d:propstat><d:prop><d:displayname>Season1</d:displayname><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
||||||
|
</d:multistatus>`))
|
||||||
|
case "/dav/Anime/JianLai/Season1":
|
||||||
|
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
<d:response><d:href>/dav/Anime/JianLai/Season1/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/dav/Anime/JianLai/Season1/JianLai.S01E01.mkv</d:href><d:propstat><d:prop><d:displayname>JianLai.S01E01.mkv</d:displayname><d:getcontentlength>2048</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo</d:href><d:propstat><d:prop><d:displayname>JianLai.S01E01.nfo</d:displayname><d:getcontentlength>128</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
||||||
|
</d:multistatus>`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected propfind path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
case http.MethodGet:
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/dav/Anime/JianLai/tvshow.nfo":
|
||||||
|
_, _ = w.Write([]byte(`<tvshow><title>剑来</title><year>2024</year><plot>天地有剑气</plot></tvshow>`))
|
||||||
|
case "/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo":
|
||||||
|
_, _ = w.Write([]byte(`<episodedetails><showtitle>剑来</showtitle><title>第一集</title><season>1</season><episode>1</episode></episodedetails>`))
|
||||||
|
case "/dav/Anime/JianLai/poster.jpg":
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
_, _ = w.Write([]byte("cloud-poster-bytes"))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected get path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %s", r.Method)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
log := zap.NewNop()
|
||||||
|
storage := NewStorageConfigService(log, repos, NewCryptoService("", log))
|
||||||
|
if _, err := storage.Save(t.Context(), StorageInput{
|
||||||
|
Type: "openlist",
|
||||||
|
Config: map[string]any{
|
||||||
|
"url": upstream.URL,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lib := model.Library{Name: "OpenList · 国漫 · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil)
|
||||||
|
scanner.SetStorageConfig(storage)
|
||||||
|
imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log)
|
||||||
|
scanner.SetImageProxy(imageProxy)
|
||||||
|
|
||||||
|
res, err := scanner.ScanLibrary(t.Context(), lib.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scan cloud: %v", err)
|
||||||
|
}
|
||||||
|
if res.Added != 1 || res.LocalMetadata != 1 {
|
||||||
|
t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res)
|
||||||
|
}
|
||||||
|
var media model.Media
|
||||||
|
if err := repos.DB.First(&media).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 单集名(episode <title>「第一集」)不得写入 OriginalName(整剧原名/分组键)。
|
||||||
|
// tvshow.nfo 未提供 originaltitle, 故 OriginalName 应为空。
|
||||||
|
if media.Title != "剑来" || media.OriginalName != "" || media.Year != 2024 {
|
||||||
|
t.Fatalf("metadata not applied: %#v", media)
|
||||||
|
}
|
||||||
|
if media.SeasonNum != 1 || media.EpisodeNum != 1 {
|
||||||
|
t.Fatalf("episode numbers = %d/%d", media.SeasonNum, media.EpisodeNum)
|
||||||
|
}
|
||||||
|
if media.PosterURL != "/api/img/cloud/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg" {
|
||||||
|
t.Fatalf("poster url = %q", media.PosterURL)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Anime/JianLai/poster.jpg") {
|
||||||
|
t.Fatal("cloud poster should be cached locally during scan before media is exposed")
|
||||||
|
}
|
||||||
|
if got := rec.Body.String(); got != "cloud-poster-bytes" {
|
||||||
|
t.Fatalf("cached poster body = %q", got)
|
||||||
|
}
|
||||||
|
if media.ScrapeStatus != "matched" {
|
||||||
|
t.Fatalf("scrape status = %q", media.ScrapeStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanCloudLibraryRefreshesExistingRemoteNFOAndArtwork(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case "PROPFIND":
|
||||||
|
w.Header().Set("Content-Type", "application/xml")
|
||||||
|
w.WriteHeader(http.StatusMultiStatus)
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/dav/Anime/JianLai":
|
||||||
|
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
<d:response><d:href>/dav/Anime/JianLai/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/dav/Anime/JianLai/tvshow.nfo</d:href><d:propstat><d:prop><d:displayname>tvshow.nfo</d:displayname><d:getcontentlength>64</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/dav/Anime/JianLai/poster.jpg</d:href><d:propstat><d:prop><d:displayname>poster.jpg</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/dav/Anime/JianLai/Season1/</d:href><d:propstat><d:prop><d:displayname>Season1</d:displayname><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
||||||
|
</d:multistatus>`))
|
||||||
|
case "/dav/Anime/JianLai/Season1":
|
||||||
|
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:">
|
||||||
|
<d:response><d:href>/dav/Anime/JianLai/Season1/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/dav/Anime/JianLai/Season1/JianLai.S01E01.mkv</d:href><d:propstat><d:prop><d:displayname>JianLai.S01E01.mkv</d:displayname><d:getcontentlength>2048</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo</d:href><d:propstat><d:prop><d:displayname>JianLai.S01E01.nfo</d:displayname><d:getcontentlength>128</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
||||||
|
</d:multistatus>`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected propfind path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
case http.MethodGet:
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/dav/Anime/JianLai/tvshow.nfo":
|
||||||
|
_, _ = w.Write([]byte(`<tvshow><title>剑来</title><year>2024</year><plot>天地有剑气</plot><uniqueid type="tmdb">296753</uniqueid></tvshow>`))
|
||||||
|
case "/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo":
|
||||||
|
_, _ = w.Write([]byte(`<episodedetails><showtitle>剑来</showtitle><title>第一集</title><season>1</season><episode>1</episode></episodedetails>`))
|
||||||
|
case "/dav/Anime/JianLai/poster.jpg":
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
_, _ = w.Write([]byte("cloud-poster-bytes"))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected get path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %s", r.Method)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
log := zap.NewNop()
|
||||||
|
storage := NewStorageConfigService(log, repos, NewCryptoService("", log))
|
||||||
|
if _, err := storage.Save(t.Context(), StorageInput{
|
||||||
|
Type: "openlist",
|
||||||
|
Config: map[string]any{
|
||||||
|
"url": upstream.URL,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lib := model.Library{Name: "OpenList · 国漫 · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaPath := "cloud://openlist/Anime/JianLai/Season1/JianLai.S01E01.mkv"
|
||||||
|
old := model.Media{
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
Title: "JianLai.S01E01",
|
||||||
|
Path: mediaPath,
|
||||||
|
SizeBytes: 2048,
|
||||||
|
Container: "mkv",
|
||||||
|
PosterURL: "https://image.tmdb.org/t/p/w500/old.jpg",
|
||||||
|
STRMURL: BuildRelativeCloudPlayURL("openlist", "/Anime/JianLai/Season1/JianLai.S01E01.mkv"),
|
||||||
|
ScrapeStatus: "no_match",
|
||||||
|
}
|
||||||
|
if err := repos.Media.Upsert(t.Context(), &old); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil)
|
||||||
|
scanner.SetStorageConfig(storage)
|
||||||
|
imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log)
|
||||||
|
scanner.SetImageProxy(imageProxy)
|
||||||
|
|
||||||
|
res, err := scanner.ScanLibrary(t.Context(), lib.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scan cloud: %v", err)
|
||||||
|
}
|
||||||
|
if res.Updated != 1 || res.LocalMetadata != 1 {
|
||||||
|
t.Fatalf("scan result = %#v, want updated=1 local_metadata=1", res)
|
||||||
|
}
|
||||||
|
var media model.Media
|
||||||
|
if err := repos.DB.First(&media, "path = ?", mediaPath).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if media.Title != "剑来" || media.Year != 2024 || media.TMDbID != 296753 {
|
||||||
|
t.Fatalf("metadata not refreshed: %#v", media)
|
||||||
|
}
|
||||||
|
if media.PosterURL != "/api/img/cloud/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg" {
|
||||||
|
t.Fatalf("poster url = %q", media.PosterURL)
|
||||||
|
}
|
||||||
|
if media.ScrapeStatus != "matched" {
|
||||||
|
t.Fatalf("scrape status = %q", media.ScrapeStatus)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Anime/JianLai/poster.jpg") {
|
||||||
|
t.Fatal("refreshed cloud poster should be cached locally during scan")
|
||||||
|
}
|
||||||
|
if got := rec.Body.String(); got != "cloud-poster-bytes" {
|
||||||
|
t.Fatalf("cached poster body = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,378 +81,6 @@ func TestScanCloudLibraryReadsRemoteSTRMTarget(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanCloudLibraryCachesFileLevelRemoteArtwork(t *testing.T) {
|
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case "PROPFIND":
|
|
||||||
if r.URL.Path != "/dav/Movies" {
|
|
||||||
t.Fatalf("unexpected propfind path %s", r.URL.Path)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/xml")
|
|
||||||
w.WriteHeader(http.StatusMultiStatus)
|
|
||||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<d:multistatus xmlns:d="DAV:">
|
|
||||||
<d:response><d:href>/dav/Movies/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/Movies/Movie.mkv</d:href><d:propstat><d:prop><d:displayname>Movie.mkv</d:displayname><d:getcontentlength>4096</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/Movies/Movie.nfo</d:href><d:propstat><d:prop><d:displayname>Movie.nfo</d:displayname><d:getcontentlength>128</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/Movies/Movie.jpg</d:href><d:propstat><d:prop><d:displayname>Movie.jpg</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
|
||||||
</d:multistatus>`))
|
|
||||||
case http.MethodGet:
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/dav/Movies/Movie.nfo":
|
|
||||||
_, _ = w.Write([]byte(`<movie><title>Sidecar Movie</title><year>2026</year></movie>`))
|
|
||||||
case "/dav/Movies/Movie.jpg":
|
|
||||||
w.Header().Set("Content-Type", "image/jpeg")
|
|
||||||
_, _ = w.Write([]byte("file-level-poster"))
|
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected get path %s", r.URL.Path)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected method %s", r.Method)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer upstream.Close()
|
|
||||||
|
|
||||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{})
|
|
||||||
repos := repository.New(db)
|
|
||||||
log := zap.NewNop()
|
|
||||||
storage := NewStorageConfigService(log, repos, NewCryptoService("", log))
|
|
||||||
if _, err := storage.Save(t.Context(), StorageInput{
|
|
||||||
Type: "openlist",
|
|
||||||
Config: map[string]any{
|
|
||||||
"url": upstream.URL,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
|
|
||||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil)
|
|
||||||
scanner.SetStorageConfig(storage)
|
|
||||||
imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log)
|
|
||||||
scanner.SetImageProxy(imageProxy)
|
|
||||||
|
|
||||||
res, err := scanner.ScanLibrary(t.Context(), lib.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("scan cloud: %v", err)
|
|
||||||
}
|
|
||||||
if res.Added != 1 || res.LocalMetadata != 1 {
|
|
||||||
t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res)
|
|
||||||
}
|
|
||||||
var media model.Media
|
|
||||||
if err := repos.DB.First(&media).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if media.Title != "Sidecar Movie" || media.Year != 2026 {
|
|
||||||
t.Fatalf("metadata not applied: %#v", media)
|
|
||||||
}
|
|
||||||
if media.PosterURL != "/api/img/cloud/openlist?ref=%2FMovies%2FMovie.jpg" {
|
|
||||||
t.Fatalf("poster url = %q", media.PosterURL)
|
|
||||||
}
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Movie.jpg") {
|
|
||||||
t.Fatal("file-level cloud poster should be cached locally during scan before media is exposed")
|
|
||||||
}
|
|
||||||
if got := rec.Body.String(); got != "file-level-poster" {
|
|
||||||
t.Fatalf("cached poster body = %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanCloudLibraryUsesArtworkReferencedByRemoteNFO(t *testing.T) {
|
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case "PROPFIND":
|
|
||||||
if r.URL.Path != "/dav/Movies" {
|
|
||||||
t.Fatalf("unexpected propfind path %s", r.URL.Path)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/xml")
|
|
||||||
w.WriteHeader(http.StatusMultiStatus)
|
|
||||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<d:multistatus xmlns:d="DAV:">
|
|
||||||
<d:response><d:href>/dav/Movies/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/Movies/Movie.mkv</d:href><d:propstat><d:prop><d:displayname>Movie.mkv</d:displayname><d:getcontentlength>4096</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/Movies/Movie.nfo</d:href><d:propstat><d:prop><d:displayname>Movie.nfo</d:displayname><d:getcontentlength>256</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/Movies/Artwork.Custom.tbn</d:href><d:propstat><d:prop><d:displayname>Artwork.Custom.tbn</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/Movies/Scene.Still.png</d:href><d:propstat><d:prop><d:displayname>Scene.Still.png</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
|
||||||
</d:multistatus>`))
|
|
||||||
case http.MethodGet:
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/dav/Movies/Movie.nfo":
|
|
||||||
_, _ = w.Write([]byte(`<movie><title>NFO Custom Artwork</title><thumb aspect="poster">Artwork.Custom.tbn</thumb><fanart><thumb>Scene.Still.png?version=1</thumb></fanart></movie>`))
|
|
||||||
case "/dav/Movies/Artwork.Custom.tbn":
|
|
||||||
w.Header().Set("Content-Type", "image/jpeg")
|
|
||||||
_, _ = w.Write([]byte("custom-poster"))
|
|
||||||
case "/dav/Movies/Scene.Still.png":
|
|
||||||
w.Header().Set("Content-Type", "image/png")
|
|
||||||
_, _ = w.Write([]byte("custom-backdrop"))
|
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected get path %s", r.URL.Path)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected method %s", r.Method)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer upstream.Close()
|
|
||||||
|
|
||||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{})
|
|
||||||
repos := repository.New(db)
|
|
||||||
log := zap.NewNop()
|
|
||||||
storage := NewStorageConfigService(log, repos, NewCryptoService("", log))
|
|
||||||
if _, err := storage.Save(t.Context(), StorageInput{
|
|
||||||
Type: "openlist",
|
|
||||||
Config: map[string]any{
|
|
||||||
"url": upstream.URL,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
|
|
||||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil)
|
|
||||||
scanner.SetStorageConfig(storage)
|
|
||||||
imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log)
|
|
||||||
scanner.SetImageProxy(imageProxy)
|
|
||||||
|
|
||||||
res, err := scanner.ScanLibrary(t.Context(), lib.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("scan cloud: %v", err)
|
|
||||||
}
|
|
||||||
if res.Added != 1 || res.LocalMetadata != 1 {
|
|
||||||
t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res)
|
|
||||||
}
|
|
||||||
var media model.Media
|
|
||||||
if err := repos.DB.First(&media).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if media.Title != "NFO Custom Artwork" {
|
|
||||||
t.Fatalf("metadata title = %q", media.Title)
|
|
||||||
}
|
|
||||||
if media.PosterURL != "/api/img/cloud/openlist?ref=%2FMovies%2FArtwork.Custom.tbn" {
|
|
||||||
t.Fatalf("poster url = %q", media.PosterURL)
|
|
||||||
}
|
|
||||||
if media.BackdropURL != "/api/img/cloud/openlist?ref=%2FMovies%2FScene.Still.png" {
|
|
||||||
t.Fatalf("backdrop url = %q", media.BackdropURL)
|
|
||||||
}
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Artwork.Custom.tbn") {
|
|
||||||
t.Fatal("NFO-referenced cloud poster should be cached locally during scan")
|
|
||||||
}
|
|
||||||
if got := rec.Body.String(); got != "custom-poster" {
|
|
||||||
t.Fatalf("cached poster body = %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanCloudLibraryReadsRemoteNFOAndArtwork(t *testing.T) {
|
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case "PROPFIND":
|
|
||||||
w.Header().Set("Content-Type", "application/xml")
|
|
||||||
w.WriteHeader(http.StatusMultiStatus)
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/dav/Anime/JianLai":
|
|
||||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<d:multistatus xmlns:d="DAV:">
|
|
||||||
<d:response><d:href>/dav/Anime/JianLai/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/Anime/JianLai/tvshow.nfo</d:href><d:propstat><d:prop><d:displayname>tvshow.nfo</d:displayname><d:getcontentlength>64</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/Anime/JianLai/poster.jpg</d:href><d:propstat><d:prop><d:displayname>poster.jpg</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/Anime/JianLai/Season1/</d:href><d:propstat><d:prop><d:displayname>Season1</d:displayname><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
|
||||||
</d:multistatus>`))
|
|
||||||
case "/dav/Anime/JianLai/Season1":
|
|
||||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<d:multistatus xmlns:d="DAV:">
|
|
||||||
<d:response><d:href>/dav/Anime/JianLai/Season1/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/Anime/JianLai/Season1/JianLai.S01E01.mkv</d:href><d:propstat><d:prop><d:displayname>JianLai.S01E01.mkv</d:displayname><d:getcontentlength>2048</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo</d:href><d:propstat><d:prop><d:displayname>JianLai.S01E01.nfo</d:displayname><d:getcontentlength>128</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
|
||||||
</d:multistatus>`))
|
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected propfind path %s", r.URL.Path)
|
|
||||||
}
|
|
||||||
case http.MethodGet:
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/dav/Anime/JianLai/tvshow.nfo":
|
|
||||||
_, _ = w.Write([]byte(`<tvshow><title>剑来</title><year>2024</year><plot>天地有剑气</plot></tvshow>`))
|
|
||||||
case "/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo":
|
|
||||||
_, _ = w.Write([]byte(`<episodedetails><showtitle>剑来</showtitle><title>第一集</title><season>1</season><episode>1</episode></episodedetails>`))
|
|
||||||
case "/dav/Anime/JianLai/poster.jpg":
|
|
||||||
w.Header().Set("Content-Type", "image/jpeg")
|
|
||||||
_, _ = w.Write([]byte("cloud-poster-bytes"))
|
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected get path %s", r.URL.Path)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected method %s", r.Method)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer upstream.Close()
|
|
||||||
|
|
||||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{})
|
|
||||||
repos := repository.New(db)
|
|
||||||
log := zap.NewNop()
|
|
||||||
storage := NewStorageConfigService(log, repos, NewCryptoService("", log))
|
|
||||||
if _, err := storage.Save(t.Context(), StorageInput{
|
|
||||||
Type: "openlist",
|
|
||||||
Config: map[string]any{
|
|
||||||
"url": upstream.URL,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
lib := model.Library{Name: "OpenList · 国漫 · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true}
|
|
||||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil)
|
|
||||||
scanner.SetStorageConfig(storage)
|
|
||||||
imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log)
|
|
||||||
scanner.SetImageProxy(imageProxy)
|
|
||||||
|
|
||||||
res, err := scanner.ScanLibrary(t.Context(), lib.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("scan cloud: %v", err)
|
|
||||||
}
|
|
||||||
if res.Added != 1 || res.LocalMetadata != 1 {
|
|
||||||
t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res)
|
|
||||||
}
|
|
||||||
var media model.Media
|
|
||||||
if err := repos.DB.First(&media).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// 单集名(episode <title>「第一集」)不得写入 OriginalName(整剧原名/分组键)。
|
|
||||||
// tvshow.nfo 未提供 originaltitle, 故 OriginalName 应为空。
|
|
||||||
if media.Title != "剑来" || media.OriginalName != "" || media.Year != 2024 {
|
|
||||||
t.Fatalf("metadata not applied: %#v", media)
|
|
||||||
}
|
|
||||||
if media.SeasonNum != 1 || media.EpisodeNum != 1 {
|
|
||||||
t.Fatalf("episode numbers = %d/%d", media.SeasonNum, media.EpisodeNum)
|
|
||||||
}
|
|
||||||
if media.PosterURL != "/api/img/cloud/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg" {
|
|
||||||
t.Fatalf("poster url = %q", media.PosterURL)
|
|
||||||
}
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Anime/JianLai/poster.jpg") {
|
|
||||||
t.Fatal("cloud poster should be cached locally during scan before media is exposed")
|
|
||||||
}
|
|
||||||
if got := rec.Body.String(); got != "cloud-poster-bytes" {
|
|
||||||
t.Fatalf("cached poster body = %q", got)
|
|
||||||
}
|
|
||||||
if media.ScrapeStatus != "matched" {
|
|
||||||
t.Fatalf("scrape status = %q", media.ScrapeStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanCloudLibraryRefreshesExistingRemoteNFOAndArtwork(t *testing.T) {
|
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case "PROPFIND":
|
|
||||||
w.Header().Set("Content-Type", "application/xml")
|
|
||||||
w.WriteHeader(http.StatusMultiStatus)
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/dav/Anime/JianLai":
|
|
||||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<d:multistatus xmlns:d="DAV:">
|
|
||||||
<d:response><d:href>/dav/Anime/JianLai/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/Anime/JianLai/tvshow.nfo</d:href><d:propstat><d:prop><d:displayname>tvshow.nfo</d:displayname><d:getcontentlength>64</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/Anime/JianLai/poster.jpg</d:href><d:propstat><d:prop><d:displayname>poster.jpg</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/Anime/JianLai/Season1/</d:href><d:propstat><d:prop><d:displayname>Season1</d:displayname><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
|
||||||
</d:multistatus>`))
|
|
||||||
case "/dav/Anime/JianLai/Season1":
|
|
||||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<d:multistatus xmlns:d="DAV:">
|
|
||||||
<d:response><d:href>/dav/Anime/JianLai/Season1/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/Anime/JianLai/Season1/JianLai.S01E01.mkv</d:href><d:propstat><d:prop><d:displayname>JianLai.S01E01.mkv</d:displayname><d:getcontentlength>2048</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
|
||||||
<d:response><d:href>/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo</d:href><d:propstat><d:prop><d:displayname>JianLai.S01E01.nfo</d:displayname><d:getcontentlength>128</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response>
|
|
||||||
</d:multistatus>`))
|
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected propfind path %s", r.URL.Path)
|
|
||||||
}
|
|
||||||
case http.MethodGet:
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/dav/Anime/JianLai/tvshow.nfo":
|
|
||||||
_, _ = w.Write([]byte(`<tvshow><title>剑来</title><year>2024</year><plot>天地有剑气</plot><uniqueid type="tmdb">296753</uniqueid></tvshow>`))
|
|
||||||
case "/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo":
|
|
||||||
_, _ = w.Write([]byte(`<episodedetails><showtitle>剑来</showtitle><title>第一集</title><season>1</season><episode>1</episode></episodedetails>`))
|
|
||||||
case "/dav/Anime/JianLai/poster.jpg":
|
|
||||||
w.Header().Set("Content-Type", "image/jpeg")
|
|
||||||
_, _ = w.Write([]byte("cloud-poster-bytes"))
|
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected get path %s", r.URL.Path)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected method %s", r.Method)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer upstream.Close()
|
|
||||||
|
|
||||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{})
|
|
||||||
repos := repository.New(db)
|
|
||||||
log := zap.NewNop()
|
|
||||||
storage := NewStorageConfigService(log, repos, NewCryptoService("", log))
|
|
||||||
if _, err := storage.Save(t.Context(), StorageInput{
|
|
||||||
Type: "openlist",
|
|
||||||
Config: map[string]any{
|
|
||||||
"url": upstream.URL,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
lib := model.Library{Name: "OpenList · 国漫 · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true}
|
|
||||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaPath := "cloud://openlist/Anime/JianLai/Season1/JianLai.S01E01.mkv"
|
|
||||||
old := model.Media{
|
|
||||||
LibraryID: lib.ID,
|
|
||||||
Title: "JianLai.S01E01",
|
|
||||||
Path: mediaPath,
|
|
||||||
SizeBytes: 2048,
|
|
||||||
Container: "mkv",
|
|
||||||
PosterURL: "https://image.tmdb.org/t/p/w500/old.jpg",
|
|
||||||
STRMURL: BuildRelativeCloudPlayURL("openlist", "/Anime/JianLai/Season1/JianLai.S01E01.mkv"),
|
|
||||||
ScrapeStatus: "no_match",
|
|
||||||
}
|
|
||||||
if err := repos.Media.Upsert(t.Context(), &old); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil)
|
|
||||||
scanner.SetStorageConfig(storage)
|
|
||||||
imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log)
|
|
||||||
scanner.SetImageProxy(imageProxy)
|
|
||||||
|
|
||||||
res, err := scanner.ScanLibrary(t.Context(), lib.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("scan cloud: %v", err)
|
|
||||||
}
|
|
||||||
if res.Updated != 1 || res.LocalMetadata != 1 {
|
|
||||||
t.Fatalf("scan result = %#v, want updated=1 local_metadata=1", res)
|
|
||||||
}
|
|
||||||
var media model.Media
|
|
||||||
if err := repos.DB.First(&media, "path = ?", mediaPath).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if media.Title != "剑来" || media.Year != 2024 || media.TMDbID != 296753 {
|
|
||||||
t.Fatalf("metadata not refreshed: %#v", media)
|
|
||||||
}
|
|
||||||
if media.PosterURL != "/api/img/cloud/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg" {
|
|
||||||
t.Fatalf("poster url = %q", media.PosterURL)
|
|
||||||
}
|
|
||||||
if media.ScrapeStatus != "matched" {
|
|
||||||
t.Fatalf("scrape status = %q", media.ScrapeStatus)
|
|
||||||
}
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Anime/JianLai/poster.jpg") {
|
|
||||||
t.Fatal("refreshed cloud poster should be cached locally during scan")
|
|
||||||
}
|
|
||||||
if got := rec.Body.String(); got != "cloud-poster-bytes" {
|
|
||||||
t.Fatalf("cached poster body = %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanCloudLibraryReadsMovieDirectoryNFOAndCleanTitleArtwork(t *testing.T) {
|
func TestScanCloudLibraryReadsMovieDirectoryNFOAndCleanTitleArtwork(t *testing.T) {
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
|
|||||||
Reference in New Issue
Block a user