「第一集」)不得写入 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.Bytes(); !bytes.Equal(got, testJPEG) {
- t.Fatalf("cached poster body = %x", 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(`
-
- /dav/Anime/JianLai/
- /dav/Anime/JianLai/tvshow.nfotvshow.nfo64
- /dav/Anime/JianLai/poster.jpgposter.jpg1024
- /dav/Anime/JianLai/Season1/Season1
-`))
- case "/dav/Anime/JianLai/Season1":
- _, _ = w.Write([]byte(`
-
- /dav/Anime/JianLai/Season1/
- /dav/Anime/JianLai/Season1/JianLai.S01E01.mkvJianLai.S01E01.mkv2048
- /dav/Anime/JianLai/Season1/JianLai.S01E01.nfoJianLai.S01E01.nfo128
-`))
- 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(`剑来2024天地有剑气296753`))
- case "/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo":
- _, _ = w.Write([]byte(`剑来第一集11`))
- case "/dav/Anime/JianLai/poster.jpg":
- w.Header().Set("Content-Type", "image/jpeg")
- _, _ = w.Write(testJPEG)
- 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.Bytes(); !bytes.Equal(got, testJPEG) {
- t.Fatalf("cached poster body = %x", got)
- }
-}
diff --git a/internal/service/scanner_cloud_autocategory_test.go b/internal/service/scanner_cloud_autocategory_test.go
deleted file mode 100644
index 67d4fac..0000000
--- a/internal/service/scanner_cloud_autocategory_test.go
+++ /dev/null
@@ -1,457 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "strings"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/glebarez/sqlite"
- "go.uber.org/zap"
- "gorm.io/gorm"
-
- "github.com/ShukeBta/MediaStationGo/internal/config"
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestScanRootCloudLibraryCreatesAutoCategoryLibraries(t *testing.T) {
- empty := false
- upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) {
- if empty {
- return nil, 0
- }
- switch path {
- case "/":
- return []openListTestEntry{
- {Name: "电视剧", IsDir: true},
- {Name: "电影", IsDir: true},
- {Name: "国漫", IsDir: true},
- }, 3
- case "/电视剧":
- return []openListTestEntry{{Name: "欧美剧", IsDir: true}}, 1
- case "/电视剧/欧美剧":
- return []openListTestEntry{{Name: "The Show", IsDir: true}}, 1
- case "/电视剧/欧美剧/The Show":
- return []openListTestEntry{{Name: "The.Show.S01E01.mkv", Size: 101}}, 1
- case "/电影":
- return []openListTestEntry{{Name: "华语电影", IsDir: true}}, 1
- case "/电影/华语电影":
- return []openListTestEntry{{Name: "Movie.2024.mkv", Size: 202}}, 1
- case "/国漫":
- return []openListTestEntry{{Name: "剑来", IsDir: true}}, 1
- case "/国漫/剑来":
- return []openListTestEntry{{Name: "剑来.S01E01.mkv", Size: 303}}, 1
- default:
- t.Fatalf("unexpected openlist path %q", path)
- return nil, 0
- }
- })
- 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{
- "server": upstream.URL,
- "token": "openlist-token",
- },
- }); err != nil {
- t.Fatal(err)
- }
- root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true}
- if err := repos.Library.Create(t.Context(), &root); err != nil {
- t.Fatal(err)
- }
- scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil)
- scanner.SetStorageConfig(storage)
-
- res, err := scanner.ScanLibrary(t.Context(), root.ID)
- if err != nil {
- t.Fatalf("scan root cloud: %v", err)
- }
- if res.Visited != 3 || res.Added != 3 {
- t.Fatalf("scan result = %#v, want visited=3 added=3", res)
- }
-
- libs, err := repos.Library.List(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- byDisplayDir := map[string]model.Library{}
- for _, lib := range libs {
- if !CloudLibraryAutoCategory(lib) {
- continue
- }
- info, ok := ParseCloudLibraryMount(lib.Path)
- if !ok {
- t.Fatalf("auto category path did not parse: %q", lib.Path)
- }
- byDisplayDir[info.DisplayDir] = lib
- }
- wantTypes := map[string]string{
- "电视剧/欧美剧": "tv",
- "电影/华语电影": "movie",
- "动漫/国漫": "anime",
- }
- for dir, wantType := range wantTypes {
- lib, ok := byDisplayDir[dir]
- if !ok {
- t.Fatalf("missing auto category library %q; got %#v", dir, byDisplayDir)
- }
- if lib.Type != wantType {
- t.Fatalf("auto category %s type = %s, want %s", dir, lib.Type, wantType)
- }
- }
-
- var rows []model.Media
- if err := repos.DB.Order("path").Find(&rows).Error; err != nil {
- t.Fatal(err)
- }
- if len(rows) != 3 {
- t.Fatalf("media rows = %d, want 3", len(rows))
- }
- wantLibraries := map[string]string{
- "cloud://openlist/电视剧/欧美剧/The Show/The.Show.S01E01.mkv": byDisplayDir["电视剧/欧美剧"].ID,
- "cloud://openlist/电影/华语电影/Movie.2024.mkv": byDisplayDir["电影/华语电影"].ID,
- "cloud://openlist/动漫/国漫/剑来/剑来.S01E01.mkv": byDisplayDir["动漫/国漫"].ID,
- }
- for _, row := range rows {
- if row.LibraryID != wantLibraries[row.Path] {
- t.Fatalf("%s library_id = %s, want %s", row.Path, row.LibraryID, wantLibraries[row.Path])
- }
- }
-
- res, err = scanner.ScanLibrary(t.Context(), root.ID)
- if err != nil {
- t.Fatalf("rescan root cloud: %v", err)
- }
- if res.Added != 0 || res.Updated != 0 || res.Skipped != 3 {
- t.Fatalf("rescan should skip unchanged auto-category rows, got %#v", res)
- }
- libs, err = repos.Library.List(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- autoCount := 0
- for _, lib := range libs {
- if CloudLibraryAutoCategory(lib) {
- autoCount++
- }
- }
- if autoCount != 3 {
- t.Fatalf("auto category library count after rescan = %d, want 3", autoCount)
- }
-
- empty = true
- res, err = scanner.ScanLibrary(t.Context(), root.ID)
- if err != nil {
- t.Fatalf("empty rescan root cloud: %v", err)
- }
- if res.Removed != 3 {
- t.Fatalf("removed = %d, want 3", res.Removed)
- }
- if got := countMedia(t, repos); got != 0 {
- t.Fatalf("media count after auto-category prune = %d, want 0", got)
- }
-}
-
-func TestScanRootCloudAutoCategoryAppendsExistingLibraryRoot(t *testing.T) {
- upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) {
- switch path {
- case "/":
- return []openListTestEntry{{Name: "电影", IsDir: true}}, 1
- case "/电影":
- return []openListTestEntry{{Name: "华语电影", IsDir: true}}, 1
- case "/电影/华语电影":
- return []openListTestEntry{{Name: "Movie.2024.mkv", Size: 202}}, 1
- default:
- t.Fatalf("unexpected openlist path %q", path)
- return nil, 0
- }
- })
- defer upstream.Close()
-
- db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{})
- repos := repository.New(db)
- storage := newOpenListStorageForTest(t, repos, upstream.URL)
- local := model.Library{Name: "华语电影", Path: "/media/电影/华语电影", Type: "movie", Enabled: true}
- if err := repos.Library.CreateWithRoots(t.Context(), &local, []model.LibraryRoot{{
- Name: "华语电影",
- Path: local.Path,
- Enabled: true,
- }}); err != nil {
- t.Fatal(err)
- }
- root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true}
- if err := repos.Library.Create(t.Context(), &root); err != nil {
- t.Fatal(err)
- }
- scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
- scanner.SetStorageConfig(storage)
-
- res, err := scanner.ScanLibrary(t.Context(), root.ID)
- if err != nil {
- t.Fatalf("scan root cloud: %v", err)
- }
- if res.Added != 1 {
- t.Fatalf("added = %d, want 1", res.Added)
- }
- libs, err := repos.Library.List(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- for _, lib := range libs {
- if CloudLibraryAutoCategory(lib) {
- t.Fatalf("auto category should append to existing library, got extra library %#v", lib)
- }
- }
- roots, err := repos.Library.ListRoots(t.Context(), local.ID)
- if err != nil {
- t.Fatal(err)
- }
- if len(roots) != 2 {
- t.Fatalf("roots = %#v, want local root plus cloud root", roots)
- }
- cloudRoot := roots[1]
- if cloudRoot.Name != "华语电影" || !CloudLibraryAutoCategory(model.Library{Path: cloudRoot.Path}) {
- t.Fatalf("cloud root = %#v, want auto-category 华语电影 root", cloudRoot)
- }
- info, ok := ParseCloudLibraryMount(cloudRoot.Path)
- if !ok || info.DisplayDir != "电影/华语电影" || info.ScanDir != "电影/华语电影" {
- t.Fatalf("cloud root mount = %#v, want display/scan 电影/华语电影", info)
- }
- var media model.Media
- if err := repos.DB.First(&media, "path = ?", "cloud://openlist/电影/华语电影/Movie.2024.mkv").Error; err != nil {
- t.Fatal(err)
- }
- if media.LibraryID != local.ID || media.LibraryRootID != cloudRoot.ID {
- t.Fatalf("media placement = library %s root %s, want %s/%s", media.LibraryID, media.LibraryRootID, local.ID, cloudRoot.ID)
- }
-}
-
-func TestScanRootCloudAutoCategoryPreservesFlatScanDir(t *testing.T) {
- upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) {
- switch path {
- case "/":
- return []openListTestEntry{{Name: "国漫", IsDir: true}}, 1
- case "/国漫":
- return []openListTestEntry{{Name: "剑来", IsDir: true}}, 1
- case "/国漫/剑来":
- return []openListTestEntry{{Name: "剑来.S01E01.mkv", Size: 303}}, 1
- default:
- t.Fatalf("unexpected openlist path %q", path)
- return nil, 0
- }
- })
- defer upstream.Close()
-
- db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{})
- repos := repository.New(db)
- storage := newOpenListStorageForTest(t, repos, upstream.URL)
- local := model.Library{Name: "国漫", Path: "/media/动漫/国漫", Type: "anime", Enabled: true}
- if err := repos.Library.CreateWithRoots(t.Context(), &local, []model.LibraryRoot{{
- Name: "国漫",
- Path: local.Path,
- Enabled: true,
- }}); err != nil {
- t.Fatal(err)
- }
- root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true}
- if err := repos.Library.Create(t.Context(), &root); err != nil {
- t.Fatal(err)
- }
- scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
- scanner.SetStorageConfig(storage)
-
- if _, err := scanner.ScanLibrary(t.Context(), root.ID); err != nil {
- t.Fatalf("scan root cloud: %v", err)
- }
- roots, err := repos.Library.ListRoots(t.Context(), local.ID)
- if err != nil {
- t.Fatal(err)
- }
- if len(roots) != 2 {
- t.Fatalf("roots = %#v, want local root plus flat cloud root", roots)
- }
- cloudRoot := roots[1]
- info, ok := ParseCloudLibraryMount(cloudRoot.Path)
- if !ok || info.DisplayDir != "动漫/国漫" || info.ScanDir != "国漫" {
- t.Fatalf("flat cloud root mount = %#v, want display 动漫/国漫 and scan 国漫", info)
- }
- res, err := scanner.ScanLibraryRoot(t.Context(), local.ID, cloudRoot.ID)
- if err != nil {
- t.Fatalf("scan flat cloud root: %v", err)
- }
- if res.Skipped != 1 && res.Updated != 1 {
- t.Fatalf("flat cloud root rescan = %#v, want existing media refreshed/skipped", res)
- }
-}
-
-func TestScanRootCloudAutoCategoryMigratesExistingAutoLibrary(t *testing.T) {
- upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) {
- switch path {
- case "/":
- return []openListTestEntry{{Name: "电视剧", IsDir: true}}, 1
- case "/电视剧":
- return []openListTestEntry{{Name: "欧美剧", IsDir: true}}, 1
- case "/电视剧/欧美剧":
- return []openListTestEntry{{Name: "The Show", IsDir: true}}, 1
- case "/电视剧/欧美剧/The Show":
- return []openListTestEntry{{Name: "The.Show.S01E01.mkv", Size: 101}}, 1
- default:
- t.Fatalf("unexpected openlist path %q", path)
- return nil, 0
- }
- })
- defer upstream.Close()
-
- db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{})
- repos := repository.New(db)
- storage := newOpenListStorageForTest(t, repos, upstream.URL)
- local := model.Library{Name: "欧美剧", Path: "/media/电视剧/欧美剧", Type: "tv", Enabled: true}
- if err := repos.Library.CreateWithRoots(t.Context(), &local, []model.LibraryRoot{{
- Name: "欧美剧",
- Path: local.Path,
- Enabled: true,
- }}); err != nil {
- t.Fatal(err)
- }
- root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true}
- oldAuto := model.Library{Name: "欧美剧", Path: BuildCloudAutoCategoryLibraryPath("openlist", "电视剧/欧美剧"), Type: "tv", Enabled: true}
- for _, lib := range []*model.Library{&root, &oldAuto} {
- if err := repos.Library.Create(t.Context(), lib); err != nil {
- t.Fatal(err)
- }
- }
- mediaPath := "cloud://openlist/电视剧/欧美剧/The Show/The.Show.S01E01.mkv"
- if err := repos.DB.Create(&model.Media{LibraryID: oldAuto.ID, Title: "The Show", Path: mediaPath}).Error; err != nil {
- t.Fatal(err)
- }
- scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
- scanner.SetStorageConfig(storage)
-
- if _, err := scanner.ScanLibrary(t.Context(), root.ID); err != nil {
- t.Fatalf("scan root cloud: %v", err)
- }
- if old, err := repos.Library.FindByID(t.Context(), oldAuto.ID); err != nil || old != nil {
- t.Fatalf("old auto library = %#v, err=%v; want removed", old, err)
- }
- roots, err := repos.Library.ListRoots(t.Context(), local.ID)
- if err != nil {
- t.Fatal(err)
- }
- if len(roots) != 2 {
- t.Fatalf("roots = %#v, want local root plus migrated cloud root", roots)
- }
- var media model.Media
- if err := repos.DB.First(&media, "path = ?", mediaPath).Error; err != nil {
- t.Fatal(err)
- }
- if media.LibraryID != local.ID || media.LibraryRootID != roots[1].ID {
- t.Fatalf("migrated media placement = %s/%s, want %s/%s", media.LibraryID, media.LibraryRootID, local.ID, roots[1].ID)
- }
-}
-
-func TestScanCloudLibraryListsChildDirectoriesConcurrently(t *testing.T) {
- var active int32
- var maxActive int32
- var releaseOnce sync.Once
- release := make(chan struct{})
- upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) {
- switch path {
- case "/":
- return []openListTestEntry{
- {Name: "A", IsDir: true},
- {Name: "B", IsDir: true},
- }, 2
- case "/A", "/B":
- cur := atomic.AddInt32(&active, 1)
- defer atomic.AddInt32(&active, -1)
- for {
- prev := atomic.LoadInt32(&maxActive)
- if cur <= prev || atomic.CompareAndSwapInt32(&maxActive, prev, cur) {
- break
- }
- }
- if cur >= 2 {
- releaseOnce.Do(func() { close(release) })
- }
- select {
- case <-release:
- case <-time.After(1500 * time.Millisecond):
- t.Errorf("child directory requests were not concurrent")
- return nil, 0
- }
- id := strings.TrimPrefix(path, "/")
- return []openListTestEntry{{Name: fmt.Sprintf("Movie.%s.mkv", id), Size: 123}}, 1
- default:
- t.Errorf("unexpected openlist path %q", path)
- return nil, 0
- }
- })
- defer upstream.Close()
-
- db, err := gorm.Open(sqlite.Open("file:cloud_scan_concurrent?mode=memory&cache=shared"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}); err != nil {
- t.Fatal(err)
- }
- 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{
- "server": upstream.URL,
- "token": "openlist-token",
- },
- }); err != nil {
- t.Fatal(err)
- }
- lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true}
- if err := repos.Library.Create(t.Context(), &lib); err != nil {
- t.Fatal(err)
- }
- cfg := &config.Config{}
- cfg.App.CloudScanMaxConcurrent = 2
- scanner := NewScannerService(cfg, log, repos, NewHub(log), nil, nil)
- scanner.SetStorageConfig(storage)
- ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
- defer cancel()
-
- res, err := scanner.ScanLibrary(ctx, lib.ID)
- if err != nil {
- t.Fatalf("scan cloud: %v", err)
- }
- if got := atomic.LoadInt32(&maxActive); got < 2 {
- t.Fatalf("max concurrent child lists = %d, want >= 2", got)
- }
- if res.Visited != 2 || res.Added != 2 {
- t.Fatalf("scan result = %#v, want visited=2 added=2", res)
- }
-}
-
-func newOpenListStorageForTest(t *testing.T, repos *repository.Container, serverURL string) *StorageConfigService {
- t.Helper()
- log := zap.NewNop()
- storage := NewStorageConfigService(log, repos, NewCryptoService("", log))
- if _, err := storage.Save(t.Context(), StorageInput{
- Type: "openlist",
- Config: map[string]any{
- "server": serverURL,
- "token": "openlist-token",
- },
- }); err != nil {
- t.Fatal(err)
- }
- return storage
-}
diff --git a/internal/service/scanner_cloud_candidates.go b/internal/service/scanner_cloud_candidates.go
deleted file mode 100644
index 21b32e1..0000000
--- a/internal/service/scanner_cloud_candidates.go
+++ /dev/null
@@ -1,247 +0,0 @@
-package service
-
-import (
- "context"
- "path/filepath"
- "strings"
- "sync"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/service/cloud"
-)
-
-type cloudScanCandidateRequest struct {
- provider string
- rootDir string
- rootDisplayDir string
- autoCategoryRoot bool
- progress *cloudScanProgressState
- result *ScanResult
-}
-
-func (s *ScannerService) collectCloudScanCandidates(ctx context.Context, lib *model.Library, req cloudScanCandidateRequest) ([]cloudCandidate, error) {
- collector := newCloudScanCandidateCollector(s, ctx, lib, req)
- return collector.collect()
-}
-
-type cloudScanCandidateCollector struct {
- scanner *ScannerService
- ctx context.Context
- lib *model.Library
- req cloudScanCandidateRequest
-
- mu sync.Mutex
- seenRefs map[string]struct{}
- visitedDirs map[string]struct{}
- candidates []cloudCandidate
- candidateByKey map[string]int
-
- walkWG sync.WaitGroup
- walkErr error
- walkErrOnce sync.Once
- listSlots chan struct{}
-}
-
-func newCloudScanCandidateCollector(s *ScannerService, ctx context.Context, lib *model.Library, req cloudScanCandidateRequest) *cloudScanCandidateCollector {
- return &cloudScanCandidateCollector{
- scanner: s,
- ctx: ctx,
- lib: lib,
- req: req,
- seenRefs: make(map[string]struct{}),
- visitedDirs: map[string]struct{}{},
- candidates: make([]cloudCandidate, 0, 256),
- candidateByKey: make(map[string]int),
- listSlots: make(chan struct{}, s.cloudScanWorkerCount()),
- }
-}
-
-func (c *cloudScanCandidateCollector) collect() ([]cloudCandidate, error) {
- c.walkWG.Add(1)
- go func() {
- _ = c.walk(c.req.rootDir, c.req.rootDisplayDir, nil)
- }()
- c.walkWG.Wait()
- if c.walkErr != nil {
- return nil, c.walkErr
- }
- if err := c.ctx.Err(); err != nil {
- return nil, err
- }
- return c.candidates, nil
-}
-
-func (c *cloudScanCandidateCollector) walk(dirID, displayDir string, inheritedMeta *LocalMetadata) error {
- defer c.walkWG.Done()
- if err := c.ctx.Err(); err != nil {
- c.setWalkErr(err)
- return err
- }
- if !c.markDirectoryVisited(dirID) {
- return nil
- }
- release, err := c.acquireListSlot()
- if err != nil {
- c.setWalkErr(err)
- return err
- }
- defer release()
-
- entries, err := c.scanner.storage.CloudList(c.ctx, c.req.provider, dirID)
- if err != nil {
- return c.handleListError(dirID, err)
- }
- c.req.progress.publish(c.scanner, c.lib.ID, c.req.result, "listing", c.req.progress.markDirVisited())
- sidecars := newCloudSidecarSet(c.req.provider, entries)
- dirMeta := c.scanner.cloudDirectoryMetadata(c.ctx, c.req.provider, displayDir, sidecars, inheritedMeta)
- c.scanner.cacheCloudMetadataArtworkNow(c.ctx, dirMeta)
- for _, entry := range entries {
- if err := c.ctx.Err(); err != nil {
- c.setWalkErr(err)
- return err
- }
- if entry.IsDir {
- c.queueChildDirectory(displayDir, entry.Name, entry.ID, dirMeta)
- continue
- }
- c.addFileCandidate(displayDir, entry, sidecars, dirMeta)
- }
- return nil
-}
-
-func (c *cloudScanCandidateCollector) markDirectoryVisited(dirID string) bool {
- c.mu.Lock()
- defer c.mu.Unlock()
- if _, ok := c.visitedDirs[dirID]; ok {
- return false
- }
- c.visitedDirs[dirID] = struct{}{}
- return true
-}
-
-func (c *cloudScanCandidateCollector) acquireListSlot() (func(), error) {
- select {
- case c.listSlots <- struct{}{}:
- return func() { <-c.listSlots }, nil
- case <-c.ctx.Done():
- return nil, c.ctx.Err()
- }
-}
-
-func (c *cloudScanCandidateCollector) handleListError(dirID string, err error) error {
- if dirID != c.req.rootDir {
- c.req.progress.addSkipped(c.req.result)
- c.scanner.log.Warn("skip inaccessible cloud directory",
- zap.String("library_id", c.lib.ID),
- zap.String("provider", c.req.provider),
- zap.String("dir", dirID),
- zap.Error(err))
- return nil
- }
- c.setWalkErr(err)
- return err
-}
-
-func (c *cloudScanCandidateCollector) queueChildDirectory(displayDir, entryName, entryID string, dirMeta *LocalMetadata) {
- if strings.TrimSpace(entryID) == "" {
- return
- }
- c.walkWG.Add(1)
- go func(childID, childDisplay string, childMeta *LocalMetadata) {
- _ = c.walk(childID, childDisplay, childMeta)
- }(entryID, joinCloudDisplayPath(displayDir, entryName), dirMeta)
-}
-
-func (c *cloudScanCandidateCollector) addFileCandidate(displayDir string, entry cloud.FileEntry, sidecars cloudSidecarSet, dirMeta *LocalMetadata) {
- ext := strings.ToLower(filepath.Ext(entry.Name))
- if _, ok := videoExtensions[ext]; !ok {
- return
- }
- ref := cloudEntryRef(c.req.provider, entry.ID, entry.PickCode)
- if ref == "" {
- c.req.progress.addSkipped(c.req.result)
- return
- }
- if !c.markRefSeen(ref) {
- c.req.progress.addSkipped(c.req.result)
- return
- }
- c.req.progress.publish(c.scanner, c.lib.ID, c.req.result, "listing", c.req.progress.markFileDiscovered())
- displayPath := joinCloudDisplayPath(displayDir, entry.Name)
- path := cloudMediaPath(c.req.provider, displayPath)
- candidate := cloudCandidate{
- ref: ref,
- name: entry.Name,
- size: entry.Size,
- path: path,
- }
- if c.req.autoCategoryRoot {
- candidate.categoryDisplayDir, candidate.categoryScanDir = cloudAutoCategoryDirsForMediaPath(path)
- if candidate.categoryDisplayDir != "" {
- displayPath = canonicalCloudAutoCategoryMediaDisplayPath(displayPath, candidate.categoryDisplayDir, candidate.categoryScanDir)
- candidate.path = cloudMediaPath(c.req.provider, displayPath)
- }
- }
- localMeta := c.scanner.cloudFileMetadata(c.ctx, c.req.provider, displayPath, entry.Name, sidecars, dirMeta, librarySupportsSeasons(c.lib))
- localMeta = c.scanner.enrichCloudMetadataFromExternalIDs(c.ctx, c.lib, candidate.path, localMeta)
- if localMeta != nil {
- c.scanner.cacheCloudMetadataArtworkNow(c.ctx, localMeta)
- }
- candidate.localMeta = localMeta
- c.addCandidate(displayDir, entry, candidate)
-}
-
-func canonicalCloudAutoCategoryMediaDisplayPath(displayPath, categoryDisplayDir, categoryScanDir string) string {
- displayPath = strings.Trim(strings.TrimSpace(strings.ReplaceAll(displayPath, "\\", "/")), "/")
- categoryDisplayDir = strings.Trim(strings.TrimSpace(strings.ReplaceAll(categoryDisplayDir, "\\", "/")), "/")
- categoryScanDir = strings.Trim(strings.TrimSpace(strings.ReplaceAll(categoryScanDir, "\\", "/")), "/")
- if displayPath == "" || categoryDisplayDir == "" || categoryScanDir == "" || displayPath == categoryDisplayDir || categoryDisplayDir == categoryScanDir {
- return displayPath
- }
- if displayPath == categoryScanDir {
- return categoryDisplayDir
- }
- prefix := strings.TrimRight(categoryScanDir, "/") + "/"
- if strings.HasPrefix(displayPath, prefix) {
- return strings.TrimRight(categoryDisplayDir, "/") + "/" + strings.TrimPrefix(displayPath, prefix)
- }
- return displayPath
-}
-
-func (c *cloudScanCandidateCollector) markRefSeen(ref string) bool {
- c.mu.Lock()
- defer c.mu.Unlock()
- if _, ok := c.seenRefs[ref]; ok {
- return false
- }
- c.seenRefs[ref] = struct{}{}
- return true
-}
-
-func (c *cloudScanCandidateCollector) addCandidate(displayDir string, entry cloud.FileEntry, candidate cloudCandidate) {
- key := cloudMediaDedupeKey(c.lib, displayDir, entry.Name, entry.Size)
- c.mu.Lock()
- defer c.mu.Unlock()
- if key != "" {
- if prevIndex, ok := c.candidateByKey[key]; ok {
- if candidate.size > c.candidates[prevIndex].size {
- c.candidates[prevIndex] = candidate
- }
- c.req.progress.addSkipped(c.req.result)
- return
- }
- c.candidateByKey[key] = len(c.candidates)
- }
- c.candidates = append(c.candidates, candidate)
-}
-
-func (c *cloudScanCandidateCollector) setWalkErr(err error) {
- if err == nil {
- return
- }
- c.walkErrOnce.Do(func() {
- c.walkErr = err
- })
-}
diff --git a/internal/service/scanner_cloud_conflict.go b/internal/service/scanner_cloud_conflict.go
deleted file mode 100644
index f712b5d..0000000
--- a/internal/service/scanner_cloud_conflict.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package service
-
-import (
- "context"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *ScannerService) shadowedCloudLibrary(ctx context.Context, lib *model.Library) *CloudMountConflict {
- libs, err := s.repo.Library.List(ctx)
- if err != nil {
- s.log.Warn("list libraries for cloud shadow check failed", zap.String("library_id", lib.ID), zap.Error(err))
- return nil
- }
- visible := FilterScannableCloudLibraries(ctx, s.repo, libs)
- for _, kept := range visible {
- if kept.ID == lib.ID {
- return nil
- }
- }
- current, ok := ParseCloudLibraryMount(lib.Path)
- if ok {
- currentKey, _ := cloudLibraryDisplayKey(*lib)
- for _, kept := range visible {
- info, ok := ParseCloudLibraryMount(kept.Path)
- if !ok || info.Provider != current.Provider {
- continue
- }
- keptKey, _ := cloudLibraryDisplayKey(kept)
- exact := currentKey != "" && currentKey == keptKey
- return &CloudMountConflict{
- Library: kept,
- Exact: exact,
- Nested: !exact,
- ExistingIsAncestor: cloudMountAncestor(info.DisplayDir, current.DisplayDir),
- }
- }
- }
- return CloudLibraryShadowed(libs, *lib)
-}
diff --git a/internal/service/scanner_cloud_enrich.go b/internal/service/scanner_cloud_enrich.go
deleted file mode 100644
index 77a5d1e..0000000
--- a/internal/service/scanner_cloud_enrich.go
+++ /dev/null
@@ -1,155 +0,0 @@
-package service
-
-import (
- "context"
- "strings"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *ScannerService) enrichCloudMetadataFromExternalIDs(ctx context.Context, lib *model.Library, path string, meta *LocalMetadata) *LocalMetadata {
- if s == nil || s.scraper == nil || meta == nil || !cloudMetadataNeedsExternalEnrich(meta) {
- return meta
- }
- localPoster, localBackdrop := cloudLocalArtworkURLs(meta)
- media := &model.Media{
- LibraryID: "",
- Title: firstNonEmpty(meta.Title, pathBaseSlash(path)),
- Path: path,
- Year: meta.Year,
- TMDbID: meta.TMDbID,
- BangumiID: meta.BangumiID,
- DoubanID: meta.DoubanID,
- TheTVDBID: meta.TheTVDBID,
- SeasonNum: meta.SeasonNum,
- EpisodeNum: meta.EpisodeNum,
- PosterURL: meta.PosterURL,
- BackdropURL: meta.BackdropURL,
- }
- if lib != nil {
- media.LibraryID = lib.ID
- }
- enrichCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
- defer cancel()
- match := s.scraper.matchFromMediaExternalIDs(enrichCtx, media, lib)
- if match == nil {
- return meta
- }
- s.scraper.applyFanartArtwork(enrichCtx, match)
- mergeLocalMetadataIntoMatch(match, meta)
-
- enriched := cloneLocalMetadata(meta)
- if enriched == nil {
- enriched = &LocalMetadata{}
- }
- mergeMatchIntoLocalMetadata(enriched, match)
- if localPoster != "" {
- enriched.PosterURL = localPoster
- enriched.HasArtwork = true
- }
- if localBackdrop != "" {
- enriched.BackdropURL = localBackdrop
- enriched.HasArtwork = true
- }
- enriched.PathHint = false
- enriched.HasNFO = true
- if enriched.PosterURL != "" || enriched.BackdropURL != "" {
- enriched.HasArtwork = true
- }
- s.prefetchRemoteArtworkFromScan(ctx, enriched.PosterURL)
- s.prefetchRemoteArtworkFromScan(ctx, enriched.BackdropURL)
- return enriched
-}
-
-func cloudMetadataNeedsExternalEnrich(meta *LocalMetadata) bool {
- if meta == nil {
- return false
- }
- hasExternalID := meta.TMDbID > 0 || meta.BangumiID > 0 || strings.TrimSpace(meta.DoubanID) != "" || strings.TrimSpace(meta.TheTVDBID) != ""
- if !hasExternalID {
- return false
- }
- return meta.PosterURL == "" || meta.BackdropURL == "" || meta.Overview == "" || meta.Title == ""
-}
-
-func cloudLocalArtworkURLs(meta *LocalMetadata) (poster, backdrop string) {
- if meta == nil || !meta.HasArtwork {
- return "", ""
- }
- if _, _, ok := ParseCloudArtworkURL(meta.PosterURL); ok {
- poster = meta.PosterURL
- }
- if _, _, ok := ParseCloudArtworkURL(meta.BackdropURL); ok {
- backdrop = meta.BackdropURL
- }
- return poster, backdrop
-}
-
-func mergeMatchIntoLocalMetadata(meta *LocalMetadata, match *Match) {
- if meta == nil || match == nil {
- return
- }
- if match.Title != "" {
- meta.Title = match.Title
- }
- if match.OriginalName != "" {
- meta.OriginalName = match.OriginalName
- }
- if match.Year > 0 {
- meta.Year = match.Year
- }
- if match.ReleaseDate != "" {
- meta.ReleaseDate = match.ReleaseDate
- }
- if match.Overview != "" {
- meta.Overview = match.Overview
- }
- if match.Rating > 0 {
- meta.Rating = match.Rating
- }
- if match.PosterURL != "" {
- meta.PosterURL = match.PosterURL
- }
- if match.BackdropURL != "" {
- meta.BackdropURL = match.BackdropURL
- }
- if match.TMDbID > 0 {
- meta.TMDbID = match.TMDbID
- }
- if match.BangumiID > 0 {
- meta.BangumiID = match.BangumiID
- }
- if match.DoubanID != "" {
- meta.DoubanID = match.DoubanID
- }
- if match.TheTVDBID != "" {
- meta.TheTVDBID = match.TheTVDBID
- }
- if len(match.Genres) > 0 {
- meta.Genres = strings.Join(match.Genres, ",")
- }
- if len(match.Countries) > 0 {
- meta.Countries = strings.Join(match.Countries, ",")
- }
- if len(match.Languages) > 0 {
- meta.Languages = strings.Join(match.Languages, ",")
- }
- if match.NSFW {
- meta.NSFW = true
- }
-}
-
-func (s *ScannerService) prefetchRemoteArtworkFromScan(ctx context.Context, raw string) {
- if s == nil || s.imageProxy == nil || !isHTTPish(raw) {
- return
- }
- fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
- err := s.imageProxy.PrefetchRemote(fetchCtx, raw)
- cancel()
- if err != nil && s.log != nil {
- s.log.Debug("scan remote artwork prefetch failed", zap.String("url", raw), zap.Error(err))
- }
-}
diff --git a/internal/service/scanner_cloud_entry.go b/internal/service/scanner_cloud_entry.go
deleted file mode 100644
index 6507624..0000000
--- a/internal/service/scanner_cloud_entry.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *ScannerService) scanMountedCloudLibrary(ctx context.Context, lib *model.Library, mount CloudMountInfo, autoScrape bool) (*ScanResult, error) {
- if IsDeprecatedNativeCloudProvider(mount.Provider) {
- return &ScanResult{LibraryID: lib.ID, Skipped: 1}, nil
- }
- if CloudLibraryAutoCategory(*lib) {
- res := &ScanResult{LibraryID: lib.ID, Skipped: 1}
- s.log.Info("skip auto category cloud library scan",
- zap.String("library_id", lib.ID),
- zap.String("provider", mount.Provider))
- s.hub.Publish("scan", map[string]any{
- "library_id": lib.ID,
- "finished": true,
- "skipped": res.Skipped,
- "cloud": true,
- "auto_category": true,
- })
- return res, nil
- }
- if shadow := s.shadowedCloudLibrary(ctx, lib); shadow != nil {
- res := &ScanResult{LibraryID: lib.ID, Skipped: 1}
- s.log.Warn("skip shadowed cloud library scan",
- zap.String("library_id", lib.ID),
- zap.String("shadowed_by", shadow.Library.ID),
- zap.String("provider", mount.Provider))
- s.hub.Publish("scan", map[string]any{
- "library_id": lib.ID,
- "finished": true,
- "skipped": res.Skipped,
- "cloud": true,
- "shadowed": true,
- })
- return res, nil
- }
- scanCtx, finish, err := s.beginCloudScan(ctx, lib, mount)
- if err != nil {
- if errors.Is(err, ErrCloudScanAlreadyRunning) {
- return &ScanResult{LibraryID: lib.ID, Skipped: 1}, nil
- }
- return nil, err
- }
- release, err := s.acquireCloudScanSlot(scanCtx, lib.ID)
- if err != nil {
- res := &ScanResult{LibraryID: lib.ID}
- if finish != nil {
- finish(res, err)
- }
- return res, err
- }
- defer release()
- res, err := s.scanCloudLibrary(scanCtx, lib, mount, autoScrape)
- if finish != nil {
- finish(res, err)
- }
- return res, err
-}
diff --git a/internal/service/scanner_cloud_ingest.go b/internal/service/scanner_cloud_ingest.go
deleted file mode 100644
index 34bb7b2..0000000
--- a/internal/service/scanner_cloud_ingest.go
+++ /dev/null
@@ -1,125 +0,0 @@
-package service
-
-import (
- "context"
- "path/filepath"
- "strings"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library, rootID, typ, ref, path, name string, size int64, localMeta *LocalMetadata, existingMedia map[string]existingCloudMedia, writeBatch *localMediaWriteBatch, probeBudget *int, res *ScanResult) {
- res.Visited++
- ext := strings.ToLower(filepath.Ext(name))
- title, year := CleanQueryWithRecognition(ctx, s.repo, name)
- if title == "" {
- title = strings.TrimSuffix(filepath.Base(name), ext)
- }
- if title == "" {
- title = ref
- }
- title, year = preferISOParentScrapeIdentity(path, lib.Path, title, year)
- parsedSeason, parsedEpisode := ParseEpisode(path)
- if librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0 {
- if seriesTitle, seriesYear := cloudSeriesTitleFromMediaPath(path); seriesTitle != "" {
- title = seriesTitle
- if seriesYear > 0 {
- year = seriesYear
- }
- }
- }
- expectedSTRMURL := BuildRelativeCloudPlayURL(typ, ref)
- m := &model.Media{
- LibraryID: lib.ID,
- LibraryRootID: strings.TrimSpace(rootID),
- Title: title,
- Year: year,
- Path: path,
- SizeBytes: size,
- Container: strings.TrimPrefix(ext, "."),
- STRMURL: expectedSTRMURL,
- ScrapeStatus: "pending",
- SeasonNum: parsedSeason,
- EpisodeNum: parsedEpisode,
- }
- if ext == ".strm" {
- if targetURL, err := s.resolveCloudSTRMTarget(ctx, typ, ref); err == nil && targetURL != "" {
- m.STRMURL = targetURL
- } else if err != nil {
- s.log.Debug("read cloud strm failed", zap.String("ref", ref), zap.Error(err))
- }
- }
- if localMeta != nil {
- applyLocalMetadata(m, localMeta)
- s.queueCloudArtworkPrefetch(localMeta.PosterURL)
- s.queueCloudArtworkPrefetch(localMeta.BackdropURL)
- }
- if _, hints := pathHintMetadata(path, librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0); hints.useful() {
- if hints.TMDbID > 0 && m.TMDbID <= 0 {
- m.TMDbID = hints.TMDbID
- }
- if hints.BangumiID > 0 && m.BangumiID <= 0 {
- m.BangumiID = hints.BangumiID
- }
- if strings.TrimSpace(hints.DoubanID) != "" && strings.TrimSpace(m.DoubanID) == "" {
- m.DoubanID = strings.TrimSpace(hints.DoubanID)
- }
- if strings.TrimSpace(hints.TheTVDBID) != "" && strings.TrimSpace(m.TheTVDBID) == "" {
- m.TheTVDBID = strings.TrimSpace(hints.TheTVDBID)
- }
- }
- isNewMedia := false
- needsTrackProbe := true
- if existingMedia != nil {
- existing, exists := existingMedia[path]
- isNewMedia = !exists
- needsTrackProbe = !exists || cloudTrackMetadataMissing(existing)
- if exists && existing.LibraryID == lib.ID && existing.SizeBytes == size && existing.STRMURL == expectedSTRMURL && !cloudMetadataNeedsRefresh(existing, localMeta) && !cloudDerivedMetadataNeedsRefresh(existing, m) {
- if needsTrackProbe && mediaExtensionSupportsProbe(ext) {
- s.queueCloudMediaProbeWithBudget(typ, ref, path, probeBudget)
- }
- res.Skipped++
- return
- }
- } else {
- isNewMedia = !s.mediaPathExists(ctx, path)
- }
- if localMeta != nil {
- res.LocalMetadata++
- }
- if isNewMedia && writeBatch != nil {
- var after func()
- if needsTrackProbe && mediaExtensionSupportsProbe(ext) {
- after = func() {
- s.queueCloudMediaProbeWithBudget(typ, ref, path, probeBudget)
- }
- }
- writeBatch.AddWithAfter(path, m, after)
- return
- }
- if err := s.repo.Media.Upsert(ctx, m); err != nil {
- addScanError(res, path, err)
- s.log.Warn("upsert cloud media failed", zap.String("path", path), zap.Error(err))
- return
- }
- if needsTrackProbe && mediaExtensionSupportsProbe(ext) {
- s.queueCloudMediaProbeWithBudget(typ, ref, path, probeBudget)
- }
- if isNewMedia {
- res.Added++
- } else {
- res.Updated++
- }
- if s.hub != nil && (res.Visited == 1 || res.Visited%100 == 0) {
- s.hub.Publish("scan", map[string]any{
- "library_id": lib.ID,
- "path": path,
- "visited": res.Visited,
- "added": res.Added,
- "updated": res.Updated,
- "cloud": true,
- })
- }
-}
diff --git a/internal/service/scanner_cloud_jobs.go b/internal/service/scanner_cloud_jobs.go
deleted file mode 100644
index 8e99ce4..0000000
--- a/internal/service/scanner_cloud_jobs.go
+++ /dev/null
@@ -1,151 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "strings"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func (s *ScannerService) StartCloudLibraryScan(libraryID string, autoScrape bool) (CloudScanStatus, bool, error) {
- if s == nil {
- return CloudScanStatus{}, false, errors.New("scanner unavailable")
- }
- lib, err := s.repo.Library.FindByID(context.Background(), libraryID)
- if err != nil {
- return CloudScanStatus{}, false, err
- }
- if lib == nil {
- return CloudScanStatus{}, false, errors.New("library not found")
- }
- mount, ok := ParseCloudLibraryMount(lib.Path)
- if !ok {
- return CloudScanStatus{}, false, errors.New("library is not a cloud mount")
- }
- if IsDeprecatedNativeCloudProvider(mount.Provider) {
- return CloudScanStatus{}, false, fmt.Errorf("cloud provider %q is deprecated; use OpenList or CloudDrive2 bridge", mount.Provider)
- }
- s.cloudScanMu.Lock()
- if entry := s.cloudScans[libraryID]; cloudScanBlocksBegin(entry) {
- status := entry.status
- s.cloudScanMu.Unlock()
- return status, false, nil
- }
- s.cloudScanMu.Unlock()
-
- go func() {
- ctx, cancel := cloudScanContext(context.Background(), cloudScanTimeout(context.Background(), s.repo, 24*time.Hour))
- defer cancel()
- if autoScrape {
- _, err = s.ScanLibrary(ctx, libraryID)
- } else {
- _, err = s.ScanLibraryWithoutAutoScrape(ctx, libraryID)
- }
- if err != nil && !errors.Is(err, ErrCloudScanAlreadyRunning) && s.log != nil {
- s.log.Warn("cloud library background scan failed", zap.String("library_id", libraryID), zap.Error(err))
- }
- }()
- return newCloudScanEntry(libraryID, mount.Provider, nil).status.withQueuedState(), true, nil
-}
-
-func (status CloudScanStatus) withQueuedState() CloudScanStatus {
- status.Stage = "queued"
- status.State = "queued"
- return status
-}
-
-func cloudScanContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
- if timeout <= 0 {
- return context.WithCancel(parent)
- }
- return context.WithTimeout(parent, timeout)
-}
-
-func cloudScanTimeout(ctx context.Context, repo *repository.Container, fallback time.Duration) time.Duration {
- if repo == nil || repo.Setting == nil {
- return fallback
- }
- value, err := repo.Setting.Get(ctx, "cloud.scan_timeout_hours")
- if err != nil || strings.TrimSpace(value) == "" {
- return fallback
- }
- hours := parseIntSettingDefault(strings.TrimSpace(value), int(fallback/time.Hour))
- if hours <= 0 {
- return 0
- }
- return time.Duration(hours) * time.Hour
-}
-
-func (s *ScannerService) StartAllCloudLibraryScans() ([]CloudScanStatus, error) {
- if s == nil {
- return nil, errors.New("scanner unavailable")
- }
- libs, err := s.repo.Library.List(context.Background())
- if err != nil {
- return nil, err
- }
- libs = FilterScannableCloudLibraries(context.Background(), s.repo, libs)
- statuses := make([]CloudScanStatus, 0, len(libs))
- queue := make([]string, 0, len(libs))
- for _, lib := range libs {
- if !lib.Enabled {
- continue
- }
- mount, ok := ParseCloudLibraryMount(lib.Path)
- if !ok {
- continue
- }
- status, queued := s.queueCloudLibraryScan(lib, mount)
- if queued {
- queue = append(queue, lib.ID)
- }
- statuses = append(statuses, status)
- }
- if len(queue) > 0 {
- go s.runQueuedCloudLibraryScans(queue)
- }
- return statuses, nil
-}
-
-func (s *ScannerService) queueCloudLibraryScan(lib model.Library, mount CloudMountInfo) (CloudScanStatus, bool) {
- status := newCloudScanEntry(lib.ID, mount.Provider, nil).status.withQueuedState()
- s.cloudScanMu.Lock()
- defer s.cloudScanMu.Unlock()
- if s.cloudScans == nil {
- s.cloudScans = make(map[string]*cloudScanEntry)
- }
- if entry := s.cloudScans[lib.ID]; cloudScanActive(entry) {
- return entry.status, false
- }
- s.cloudScans[lib.ID] = &cloudScanEntry{status: status}
- return status, true
-}
-
-func (s *ScannerService) runQueuedCloudLibraryScans(libraryIDs []string) {
- ctx, cancel := cloudScanContext(context.Background(), cloudScanTimeout(context.Background(), s.repo, 24*time.Hour))
- defer cancel()
- for _, libraryID := range libraryIDs {
- if ctx.Err() != nil {
- return
- }
- if s.cloudScanWasCanceled(libraryID) {
- continue
- }
- if _, err := s.ScanLibrary(ctx, libraryID); err != nil && !errors.Is(err, ErrCloudScanAlreadyRunning) && !errors.Is(err, context.Canceled) && s.log != nil {
- s.log.Warn("cloud library queued scan failed", zap.String("library_id", libraryID), zap.Error(err))
- }
- }
-}
-
-func (s *ScannerService) cloudScanWasCanceled(libraryID string) bool {
- s.cloudScanMu.Lock()
- defer s.cloudScanMu.Unlock()
- entry := s.cloudScans[libraryID]
- return entry != nil && entry.status.State == "canceled"
-}
diff --git a/internal/service/scanner_cloud_metadata_test.go b/internal/service/scanner_cloud_metadata_test.go
deleted file mode 100644
index f1d7bdb..0000000
--- a/internal/service/scanner_cloud_metadata_test.go
+++ /dev/null
@@ -1,486 +0,0 @@
-package service
-
-import (
- "bytes"
- "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 TestScanCloudLibraryReadsRemoteSTRMTarget(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/Links" {
- t.Fatalf("unexpected propfind path %s", r.URL.Path)
- }
- w.Header().Set("Content-Type", "application/xml")
- w.WriteHeader(http.StatusMultiStatus)
- _, _ = w.Write([]byte(`
-
-
- /dav/Links/
-
-
-
- /dav/Links/Movie.strm
- Movie.strm32
-
-`))
- case http.MethodGet:
- if r.URL.Path != "/dav/Links/Movie.strm" {
- t.Fatalf("unexpected get path %s", r.URL.Path)
- }
- _, _ = w.Write([]byte("https://cdn.example.com/Movie.mkv\n"))
- 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 · Links", Path: "cloud://openlist/Links", 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)
-
- res, err := scanner.ScanLibrary(t.Context(), lib.ID)
- if err != nil {
- t.Fatalf("scan cloud: %v", err)
- }
- if res.Added != 1 {
- t.Fatalf("scan result = %#v, want added=1", res)
- }
- var media model.Media
- if err := repos.DB.First(&media).Error; err != nil {
- t.Fatal(err)
- }
- if media.Path != "cloud://openlist/Links/Movie.strm" {
- t.Fatalf("path = %q", media.Path)
- }
- if media.STRMURL != "https://cdn.example.com/Movie.mkv" {
- t.Fatalf("strm target = %q", media.STRMURL)
- }
-}
-
-func TestScanCloudLibraryReadsMovieDirectoryNFOAndCleanTitleArtwork(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/Movies":
- _, _ = w.Write([]byte(`
-
- /dav/Movies/
- /dav/Movies/Action Movie (2025) {tmdb-1197306}/Action Movie (2025) {tmdb-1197306}
-`))
- case "/dav/Movies/Action Movie (2025) {tmdb-1197306}":
- _, _ = w.Write([]byte(`
-
- /dav/Movies/Action%20Movie%20(2025)%20%7Btmdb-1197306%7D/
- /dav/Movies/Action%20Movie%20(2025)%20%7Btmdb-1197306%7D/Action%20Movie%20(2025)%20-%202160p.WEB-DL.mkvAction Movie (2025) - 2160p.WEB-DL.mkv4096
- /dav/Movies/Action%20Movie%20(2025)%20%7Btmdb-1197306%7D/movie.nfomovie.nfo128
- /dav/Movies/Action%20Movie%20(2025)%20%7Btmdb-1197306%7D/action%20movie%20(2025)-poster.jpgaction movie (2025)-poster.jpg1024
-`))
- default:
- t.Fatalf("unexpected propfind path %s", r.URL.Path)
- }
- case http.MethodGet:
- switch r.URL.Path {
- case "/dav/Movies/Action Movie (2025) {tmdb-1197306}/movie.nfo":
- _, _ = w.Write([]byte(`Action Movie20251197306`))
- case "/dav/Movies/Action Movie (2025) {tmdb-1197306}/action movie (2025)-poster.jpg":
- w.Header().Set("Content-Type", "image/jpeg")
- _, _ = w.Write(testJPEG)
- 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 != "Action Movie" || media.Year != 2025 || media.TMDbID != 1197306 {
- t.Fatalf("movie.nfo metadata not applied: %#v", media)
- }
- wantPoster := "/api/img/cloud/openlist?ref=%2FMovies%2FAction+Movie+%282025%29+%7Btmdb-1197306%7D%2Faction+movie+%282025%29-poster.jpg"
- if media.PosterURL != wantPoster {
- t.Fatalf("poster url = %q, want %q", media.PosterURL, wantPoster)
- }
- rec := httptest.NewRecorder()
- if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Action Movie (2025) {tmdb-1197306}/action movie (2025)-poster.jpg") {
- t.Fatal("clean-title cloud poster should be cached locally during scan")
- }
- if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) {
- t.Fatalf("cached poster body = %x", got)
- }
-}
-
-func TestScanCloudLibraryReadsRemoteJSONMetadataAndArtwork(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/Movies":
- _, _ = w.Write([]byte(`
-
- /dav/Movies/
- /dav/Movies/Sidecar%20Movie%20(2026)%20%7Btmdb-12345%7D/Sidecar Movie (2026) {tmdb-12345}
-`))
- case "/dav/Movies/Sidecar Movie (2026) {tmdb-12345}":
- _, _ = w.Write([]byte(`
-
- /dav/Movies/Sidecar%20Movie%20(2026)%20%7Btmdb-12345%7D/
- /dav/Movies/Sidecar%20Movie%20(2026)%20%7Btmdb-12345%7D/Sidecar%20Movie%20(2026).mkvSidecar Movie (2026).mkv4096
- /dav/Movies/Sidecar%20Movie%20(2026)%20%7Btmdb-12345%7D/Sidecar%20Movie%20(2026)-mediainfo.jsonSidecar Movie (2026)-mediainfo.json256
- /dav/Movies/Sidecar%20Movie%20(2026)%20%7Btmdb-12345%7D/poster.jpgposter.jpg1024
- /dav/Movies/Sidecar%20Movie%20(2026)%20%7Btmdb-12345%7D/backdrop.jpgbackdrop.jpg1024
-`))
- default:
- t.Fatalf("unexpected propfind path %s", r.URL.Path)
- }
- case http.MethodGet:
- switch r.URL.Path {
- case "/dav/Movies/Sidecar Movie (2026) {tmdb-12345}/Sidecar Movie (2026)-mediainfo.json":
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"title":"JSON Sidecar Movie","year":2026,"tmdb_id":12345,"overview":"metadata from cloud json","poster":"poster.jpg","backdrop":"backdrop.jpg","genres":["Action","Drama"]}`))
- case "/dav/Movies/Sidecar Movie (2026) {tmdb-12345}/poster.jpg":
- w.Header().Set("Content-Type", "image/jpeg")
- _, _ = w.Write(testJPEG)
- case "/dav/Movies/Sidecar Movie (2026) {tmdb-12345}/backdrop.jpg":
- w.Header().Set("Content-Type", "image/jpeg")
- _, _ = w.Write(testJPEG)
- 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 != "JSON Sidecar Movie" || media.Year != 2026 || media.TMDbID != 12345 || media.ScrapeStatus != "matched" {
- t.Fatalf("json metadata not applied: %#v", media)
- }
- wantPoster := "/api/img/cloud/openlist?ref=%2FMovies%2FSidecar+Movie+%282026%29+%7Btmdb-12345%7D%2Fposter.jpg"
- if media.PosterURL != wantPoster {
- t.Fatalf("poster url = %q, want %q", media.PosterURL, wantPoster)
- }
- rec := httptest.NewRecorder()
- if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Sidecar Movie (2026) {tmdb-12345}/poster.jpg") {
- t.Fatal("JSON cloud poster should be cached locally during scan")
- }
- if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) {
- t.Fatalf("cached poster body = %x", got)
- }
-}
-
-func TestCloudEpisodeJSONDoesNotPolluteSeriesIdentity(t *testing.T) {
- local, _ := metadataFromCloudJSON([]byte(`{
- "title": "九龙拉棺",
- "episode": 1,
- "tmdb_id": 4375419,
- "original_title": "Burial",
- "overview": "本集简介",
- "poster": "episode-poster.jpg",
- "genres": ["动画"]
- }`))
- if local == nil {
- t.Fatal("episode json metadata was not parsed")
- }
- dst := &LocalMetadata{Title: "遮天", Year: 2023, SeasonNum: 1}
- got := mergeCloudEpisodeMetadata(dst, local)
- if got.Title != "遮天" || got.OriginalName != "" || got.TMDbID != 0 {
- t.Fatalf("episode json polluted series identity: %+v", got)
- }
- if got.EpisodeTitle != "九龙拉棺" || got.Overview != "本集简介" || got.SeasonNum != 1 || got.EpisodeNum != 1 {
- t.Fatalf("episode json fields not preserved: %+v", got)
- }
- if got.Genres != "动画" {
- t.Fatalf("episode json taxonomy should fill empty series taxonomy, got %q", got.Genres)
- }
-}
-
-func TestScanCloudLibraryEnrichesPathHintTMDbArtwork(t *testing.T) {
- tmdb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/movie/755679" {
- t.Fatalf("unexpected tmdb path %s", r.URL.Path)
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{
- "id": 755679,
- "title": "速度与激情11",
- "original_title": "Fast X: Part 2",
- "overview": "Exact metadata by TMDb ID",
- "poster_path": "/poster-fast11.jpg",
- "backdrop_path": "/backdrop-fast11.jpg",
- "release_date": "2028-04-07",
- "vote_average": 7.2,
- "genres": [{"name":"Action"}],
- "production_countries": [{"iso_3166_1":"US"}],
- "spoken_languages": [{"iso_639_1":"en"}]
- }`))
- }))
- defer tmdb.Close()
-
- 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/Movies":
- _, _ = w.Write([]byte(`
-
- /dav/Movies/
- /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/速度与激情11 (2028) {tmdb-755679}
-`))
- case "/dav/Movies/速度与激情11 (2028) {tmdb-755679}":
- _, _ = w.Write([]byte(`
-
- /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/
- /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028).mkv速度与激情11 (2028).mkv4096
-`))
- default:
- t.Fatalf("unexpected propfind 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{}, &model.APIConfig{})
- 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)
- }
- cfg := &config.Config{}
- cfg.Secrets.TMDbAPIKey = "test-key"
- cfg.Secrets.TMDbAPIProxy = tmdb.URL
- cfg.Secrets.TMDbImageProxy = "https://image.tmdb.org/t/p"
- scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
- scanner := NewScannerService(cfg, log, repos, NewHub(log), nil, scraper)
- scanner.SetStorageConfig(storage)
-
- 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.ScrapeStatus != "matched" || media.TMDbID != 755679 || media.PosterURL == "" || media.BackdropURL == "" || media.Overview == "" {
- t.Fatalf("path-hint tmdb metadata not enriched: %#v", media)
- }
- if media.PosterURL != "https://image.tmdb.org/t/p/w500/poster-fast11.jpg" {
- t.Fatalf("poster url = %q", media.PosterURL)
- }
-}
-
-func TestScanCloudLibraryKeepsCloudArtworkWhenEnrichingPathHint(t *testing.T) {
- tmdb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/movie/755679" {
- t.Fatalf("unexpected tmdb path %s", r.URL.Path)
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{
- "id": 755679,
- "title": "速度与激情11",
- "overview": "Exact metadata by TMDb ID",
- "poster_path": "/remote-poster.jpg",
- "backdrop_path": "/remote-backdrop.jpg",
- "release_date": "2028-04-07"
- }`))
- }))
- defer tmdb.Close()
-
- 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/Movies":
- _, _ = w.Write([]byte(`
-
- /dav/Movies/
- /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/速度与激情11 (2028) {tmdb-755679}
-`))
- case "/dav/Movies/速度与激情11 (2028) {tmdb-755679}":
- _, _ = w.Write([]byte(`
-
- /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/
- /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028).mkv速度与激情11 (2028).mkv4096
- /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/poster.jpgposter.jpg1024
-`))
- default:
- t.Fatalf("unexpected propfind path %s", r.URL.Path)
- }
- case http.MethodGet:
- if r.URL.Path != "/dav/Movies/速度与激情11 (2028) {tmdb-755679}/poster.jpg" {
- t.Fatalf("unexpected get path %s", r.URL.Path)
- }
- w.Header().Set("Content-Type", "image/jpeg")
- _, _ = w.Write(testJPEG)
- default:
- t.Fatalf("unexpected method %s", r.Method)
- }
- }))
- defer upstream.Close()
-
- db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}, &model.APIConfig{})
- 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)
- }
- cfg := &config.Config{}
- cfg.Secrets.TMDbAPIKey = "test-key"
- cfg.Secrets.TMDbAPIProxy = tmdb.URL
- cfg.Secrets.TMDbImageProxy = "https://image.tmdb.org/t/p"
- scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
- scanner := NewScannerService(cfg, log, repos, NewHub(log), nil, scraper)
- 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)
- }
- wantPoster := "/api/img/cloud/openlist?ref=%2FMovies%2F%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511+%282028%29+%7Btmdb-755679%7D%2Fposter.jpg"
- if media.PosterURL != wantPoster {
- t.Fatalf("poster url = %q, want local cloud poster %q", media.PosterURL, wantPoster)
- }
- if media.BackdropURL != "https://image.tmdb.org/t/p/w1280/remote-backdrop.jpg" || media.Overview == "" {
- t.Fatalf("external enrichment should still fill missing fields: %#v", media)
- }
- rec := httptest.NewRecorder()
- if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/速度与激情11 (2028) {tmdb-755679}/poster.jpg") {
- t.Fatal("local cloud poster should be cached during enriched scan")
- }
- if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) {
- t.Fatalf("cached poster body = %x", got)
- }
-}
diff --git a/internal/service/scanner_cloud_openlist_test.go b/internal/service/scanner_cloud_openlist_test.go
deleted file mode 100644
index 71cc5b8..0000000
--- a/internal/service/scanner_cloud_openlist_test.go
+++ /dev/null
@@ -1,338 +0,0 @@
-package service
-
-import (
- "encoding/json"
- "fmt"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/config"
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestScanCloudLibraryImportsRecursivePlayableMedia(t *testing.T) {
- empty := false
- upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) {
- if empty {
- return nil, 0
- }
- switch path {
- case "/":
- return []openListTestEntry{
- {Name: "Movies", IsDir: true},
- {Name: "Root.Movie.2024.mkv", Size: 123},
- }, 2
- case "/Movies":
- return []openListTestEntry{
- {Name: "Nested.Show.S01E02.mp4", Size: 456},
- }, 1
- default:
- t.Fatalf("unexpected openlist path %q", path)
- return nil, 0
- }
- })
- 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{
- "server": upstream.URL,
- "token": "openlist-token",
- },
- }); err != nil {
- t.Fatal(err)
- }
- lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "tv", 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)
-
- res, err := scanner.ScanLibrary(t.Context(), lib.ID)
- if err != nil {
- t.Fatalf("scan cloud: %v", err)
- }
- if res.Visited != 2 || res.Added != 2 {
- t.Fatalf("scan result = %#v, want visited=2 added=2", res)
- }
- var rows []model.Media
- if err := repos.DB.Order("path").Find(&rows).Error; err != nil {
- t.Fatal(err)
- }
- if len(rows) != 2 {
- t.Fatalf("media rows = %d, want 2: %#v", len(rows), rows)
- }
- if rows[0].Path != "cloud://openlist/Movies/Nested.Show.S01E02.mp4" || !strings.Contains(rows[0].STRMURL, "ref=%2FMovies%2FNested.Show.S01E02.mp4") {
- t.Fatalf("nested media path/strm wrong: path=%q strm=%q", rows[0].Path, rows[0].STRMURL)
- }
- if rows[0].SeasonNum != 1 || rows[0].EpisodeNum != 2 {
- t.Fatalf("nested episode metadata wrong: %#v", rows[0])
- }
- if rows[1].Path != "cloud://openlist/Root.Movie.2024.mkv" || rows[1].STRMURL != "/api/cloud/play/openlist?ref=%2FRoot.Movie.2024.mkv" {
- t.Fatalf("root media path/strm wrong: path=%q strm=%q", rows[0].Path, rows[0].STRMURL)
- }
-
- res, err = scanner.ScanLibrary(t.Context(), lib.ID)
- if err != nil {
- t.Fatalf("rescan same cloud: %v", err)
- }
- if res.Added != 0 || res.Updated != 0 || res.Skipped != 2 {
- t.Fatalf("same cloud rescan should skip unchanged rows, got %#v", res)
- }
-
- empty = true
- res, err = scanner.ScanLibrary(t.Context(), lib.ID)
- if err != nil {
- t.Fatalf("rescan cloud: %v", err)
- }
- if res.Removed != 2 {
- t.Fatalf("removed = %d, want 2", res.Removed)
- }
- if got := countMedia(t, repos); got != 0 {
- t.Fatalf("media count after prune = %d, want 0", got)
- }
- var allRows int64
- if err := repos.DB.Unscoped().Model(&model.Media{}).Count(&allRows).Error; err != nil {
- t.Fatal(err)
- }
- if allRows != 0 {
- t.Fatalf("unscoped media count after cloud prune = %d, want 0", allRows)
- }
-}
-
-func TestScanOpenListCloudLibraryUsesAPIPaginationBeyondFirstPage(t *testing.T) {
- const totalFiles = 125
- requestedPages := map[int]bool{}
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/api/fs/list" {
- t.Fatalf("unexpected path %s", r.URL.Path)
- }
- if r.Method != http.MethodPost {
- t.Fatalf("unexpected method %s", r.Method)
- }
- if r.Header.Get("Authorization") != "openlist-token" {
- t.Fatalf("missing openlist token: %q", r.Header.Get("Authorization"))
- }
- var in struct {
- Path string `json:"path"`
- Page int `json:"page"`
- PerPage int `json:"per_page"`
- }
- if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
- t.Fatalf("decode request: %v", err)
- }
- if in.Path != "/Movies" {
- t.Fatalf("path = %q, want /Movies", in.Path)
- }
- if in.PerPage <= 100 {
- t.Fatalf("per_page = %d, want API pagination larger than legacy 100", in.PerPage)
- }
- requestedPages[in.Page] = true
- effectivePageSize := in.PerPage
- if effectivePageSize > 100 {
- effectivePageSize = 100
- }
- start := (in.Page - 1) * effectivePageSize
- content := []map[string]any{}
- for idx := start; idx < totalFiles && idx < start+effectivePageSize; idx++ {
- content = append(content, map[string]any{
- "name": fmt.Sprintf("Movie.%03d.mkv", idx+1),
- "size": int64(1024 + idx),
- "is_dir": false,
- })
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{
- "code": 200,
- "message": "success",
- "data": map[string]any{
- "content": content,
- "total": totalFiles,
- },
- })
- }))
- 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{
- "server": upstream.URL,
- "token": "openlist-token",
- },
- }); err != nil {
- t.Fatal(err)
- }
- lib := model.Library{Name: "OpenList · Movies", Path: BuildCloudLibraryPath("openlist", "/Movies", "/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)
-
- res, err := scanner.ScanLibrary(t.Context(), lib.ID)
- if err != nil {
- t.Fatalf("scan openlist: %v", err)
- }
- if res.Added != totalFiles {
- t.Fatalf("scan result = %#v, want added=%d", res, totalFiles)
- }
- if got := countMedia(t, repos); got != totalFiles {
- t.Fatalf("media count = %d, want %d", got, totalFiles)
- }
- if !requestedPages[1] || !requestedPages[2] {
- t.Fatalf("expected pagination beyond the first 100 entries, got pages %#v", requestedPages)
- }
-}
-
-func TestScanCloudLibraryQueuesMissingExistingTrackMetadataBeforeNewFiles(t *testing.T) {
- const newFiles = maxCloudMediaProbeQueuePerScan + 5
- upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) {
- if path != "/" {
- t.Fatalf("unexpected openlist path %q", path)
- }
- entries := make([]openListTestEntry, 0, newFiles+1)
- for i := 0; i < newFiles; i++ {
- entries = append(entries, openListTestEntry{Name: fmt.Sprintf("New.Movie.%02d.mkv", i), Size: int64(1000 + i)})
- }
- entries = append(entries, openListTestEntry{Name: "Existing.Show.S01E01.mkv", Size: 2048})
- return entries, len(entries)
- })
- 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{
- "server": upstream.URL,
- "token": "openlist-token",
- },
- }); err != nil {
- t.Fatal(err)
- }
- lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "tv", Enabled: true}
- if err := repos.Library.Create(t.Context(), &lib); err != nil {
- t.Fatal(err)
- }
- existingPath := "cloud://openlist/Existing.Show.S01E01.mkv"
- if err := repos.DB.Create(&model.Media{
- LibraryID: lib.ID,
- Title: "Existing Show",
- Path: existingPath,
- SizeBytes: 2048,
- Container: "mkv",
- STRMURL: "/api/cloud/play/openlist?ref=%2FExisting.Show.S01E01.mkv",
- SeasonNum: 1,
- EpisodeNum: 1,
- }).Error; err != nil {
- t.Fatal(err)
- }
- scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), NewFFprobeService(&config.Config{}, log), nil)
- scanner.storage = storage
-
- res, err := scanner.ScanLibrary(t.Context(), lib.ID)
- if err != nil {
- t.Fatalf("scan cloud: %v", err)
- }
- if res.Added != newFiles || res.Skipped != 1 {
- t.Fatalf("scan result = %#v, want new files added and existing skipped", res)
- }
- foundExistingProbe := false
- for {
- select {
- case task := <-scanner.cloudMediaProbeQueue:
- if task.path == existingPath {
- foundExistingProbe = true
- }
- default:
- if !foundExistingProbe {
- t.Fatal("existing media missing track metadata did not receive probe budget before new files")
- }
- return
- }
- }
-}
-
-func TestScanCloudLibraryRefreshesStaleNoMatchDerivedMetadata(t *testing.T) {
- const showDir = "Hntv Spring Festival Gala S01e (2026)"
- const seasonDir = "Season 1"
- const name = "Hntv Spring Festival Gala S01e - S01E202-DD5.QHstudIo.6.4K - 第 202 集.ts"
- upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) {
- switch path {
- case "/":
- return []openListTestEntry{{Name: showDir, IsDir: true}}, 1
- case "/" + showDir:
- return []openListTestEntry{{Name: seasonDir, IsDir: true}}, 1
- case "/" + showDir + "/" + seasonDir:
- return []openListTestEntry{{Name: name, Size: 1}}, 1
- default:
- t.Fatalf("unexpected openlist path %q", path)
- return nil, 0
- }
- })
- 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{
- "server": upstream.URL,
- "token": "openlist-token",
- },
- }); err != nil {
- t.Fatal(err)
- }
- lib := model.Library{Name: "OpenList · 综艺", Path: "cloud://openlist", Type: "tv", Enabled: true}
- if err := repos.Library.Create(t.Context(), &lib); err != nil {
- t.Fatal(err)
- }
- ref := "/" + showDir + "/" + seasonDir + "/" + name
- path := "cloud://openlist/" + showDir + "/" + seasonDir + "/" + name
- if err := repos.DB.Create(&model.Media{
- LibraryID: lib.ID,
- Title: "hntv spring festival gala s01e",
- Path: path,
- SizeBytes: 1,
- Container: "ts",
- STRMURL: BuildRelativeCloudPlayURL("openlist", ref),
- ScrapeStatus: "no_match",
- }).Error; err != nil {
- t.Fatal(err)
- }
- scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil)
- scanner.SetStorageConfig(storage)
-
- res, err := scanner.ScanLibrary(t.Context(), lib.ID)
- if err != nil {
- t.Fatalf("scan cloud: %v", err)
- }
- if res.Updated != 1 || res.Skipped != 0 {
- t.Fatalf("scan result updated=%d skipped=%d, want 1/0: %#v", res.Updated, res.Skipped, res)
- }
- var media model.Media
- if err := repos.DB.First(&media, "path = ?", path).Error; err != nil {
- t.Fatal(err)
- }
- if media.Title != "hntv spring festival gala" || media.SeasonNum != 1 || media.EpisodeNum != 202 || media.ScrapeStatus != "pending" {
- t.Fatalf("stale cloud row was not refreshed: title=%q s=%d e=%d status=%q", media.Title, media.SeasonNum, media.EpisodeNum, media.ScrapeStatus)
- }
-}
diff --git a/internal/service/scanner_cloud_paths.go b/internal/service/scanner_cloud_paths.go
deleted file mode 100644
index 556671f..0000000
--- a/internal/service/scanner_cloud_paths.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package service
-
-import (
- "fmt"
- "path/filepath"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func parseCloudLibraryPath(raw string) (typ, dirID string, ok bool) {
- info, ok := ParseCloudLibraryMount(raw)
- if !ok {
- return "", "", false
- }
- return info.Provider, info.ScanDir, true
-}
-
-func cloudEntryRef(typ, id, pickCode string) string {
- if typ == "cloud115" && strings.TrimSpace(pickCode) != "" {
- return strings.TrimSpace(pickCode)
- }
- return strings.TrimSpace(id)
-}
-
-func cloudMediaPath(typ, ref string) string {
- return "cloud://" + strings.TrimSpace(typ) + "/" + strings.TrimLeft(strings.TrimSpace(ref), "/")
-}
-
-func cloudMediaDedupeKey(lib *model.Library, dirID, name string, size int64) string {
- base := strings.TrimSpace(strings.TrimSuffix(filepath.Base(name), filepath.Ext(name)))
- if base == "" {
- return ""
- }
- season, episode := ParseEpisode(name)
- title, year := CleanQuery(name)
- title = normalizeCloudDedupeText(title)
- if (season > 0 || episode > 0) && title != "" {
- return fmt.Sprintf("episode:%s:%s:%d:%d:%d", strings.ToLower(strings.TrimSpace(lib.Type)), title, year, season, episode)
- }
- if (season > 0 || episode > 0) && title == "" {
- return fmt.Sprintf("episode-dir:%s:%s:%d:%d:%d", strings.ToLower(strings.TrimSpace(lib.Type)), normalizeCloudDedupeText(dirID), season, episode, size)
- }
- return fmt.Sprintf("file:%s:%d", normalizeCloudDedupeText(base), size)
-}
-
-func normalizeCloudDedupeText(value string) string {
- value = strings.ToLower(strings.TrimSpace(value))
- if value == "" {
- return ""
- }
- fields := strings.FieldsFunc(value, func(r rune) bool {
- switch r {
- case '.', '_', '-', ' ', '\t', '/', '\\', '[', ']', '(', ')':
- return true
- default:
- return false
- }
- })
- return strings.Join(fields, " ")
-}
diff --git a/internal/service/scanner_cloud_probe.go b/internal/service/scanner_cloud_probe.go
deleted file mode 100644
index df0f1c9..0000000
--- a/internal/service/scanner_cloud_probe.go
+++ /dev/null
@@ -1,122 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "strings"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *ScannerService) probeCloudMediaAsync(task cloudMediaProbeTask) {
- defer func() {
- s.cloudMediaProbeMu.Lock()
- delete(s.cloudMediaProbing, task.path)
- s.cloudMediaProbeMu.Unlock()
- }()
- ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
- defer cancel()
- probe, err := s.probeCloudFileMetadata(ctx, task.typ, task.ref)
- if err != nil {
- if s.log != nil {
- s.log.Debug("cloud media async probe failed", zap.String("provider", task.typ), zap.String("path", task.path), zap.Error(err))
- }
- s.cloudMediaProbeMu.Lock()
- if s.cloudMediaProbeBackoff == nil {
- s.cloudMediaProbeBackoff = make(map[string]time.Time)
- }
- s.cloudMediaProbeBackoff[task.path] = time.Now().Add(cloudMediaProbeFailureBackoff)
- s.cloudMediaProbeMu.Unlock()
- return
- }
- updates := probeResultUpdates(probe)
- if len(updates) == 0 {
- return
- }
- if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("path = ?", task.path).Updates(updates).Error; err != nil {
- if s.log != nil {
- s.log.Debug("update cloud media track metadata failed", zap.String("path", task.path), zap.Error(err))
- }
- return
- }
- s.cloudMediaProbeMu.Lock()
- delete(s.cloudMediaProbeBackoff, task.path)
- s.cloudMediaProbeMu.Unlock()
- if s.hub != nil {
- s.hub.Publish("scan", map[string]any{
- "path": task.path,
- "cloud": true,
- "track_probed": true,
- "duration_sec": probe.DurationSec,
- "video_codec": probe.VideoCodec,
- "audio_codec": probe.AudioCodec,
- "width": probe.Width,
- "height": probe.Height,
- "probe_message": "云盘媒体轨道元数据已后台补齐",
- })
- }
-}
-
-func (s *ScannerService) ffprobeWorkerCount() int {
- if s == nil || s.cfg == nil {
- return 1
- }
- return normalizeFFprobeMaxConcurrent(s.cfg.App.FFprobeMaxConcurrent)
-}
-
-func (s *ScannerService) cloudScanWorkerCount() int {
- if s == nil || s.cfg == nil {
- return 4
- }
- return normalizeCloudScanMaxConcurrent(s.cfg.App.CloudScanMaxConcurrent)
-}
-
-func normalizeCloudScanMaxConcurrent(n int) int {
- if n <= 0 {
- return 1
- }
- if n > 16 {
- return 16
- }
- return n
-}
-
-func (s *ScannerService) probeCloudFileMetadata(ctx context.Context, typ, ref string) (*ProbeResult, error) {
- if s == nil || s.probe == nil || s.storage == nil {
- return nil, errors.New("cloud probe unavailable")
- }
- link, err := s.storage.CloudResolve(ctx, typ, ref, "")
- if err != nil {
- return nil, err
- }
- return s.probe.ProbeHTTP(ctx, link.URL, link.Headers)
-}
-
-func probeResultUpdates(probe *ProbeResult) map[string]any {
- updates := map[string]any{}
- if probe == nil {
- return updates
- }
- if probe.DurationSec > 0 {
- updates["duration_sec"] = probe.DurationSec
- }
- if probe.Width > 0 {
- updates["width"] = probe.Width
- }
- if probe.Height > 0 {
- updates["height"] = probe.Height
- }
- if strings.TrimSpace(probe.VideoCodec) != "" {
- updates["video_codec"] = probe.VideoCodec
- }
- if strings.TrimSpace(probe.AudioCodec) != "" {
- updates["audio_codec"] = probe.AudioCodec
- }
- if probe.Container != "" {
- updates["container"] = probe.Container
- }
- return updates
-}
diff --git a/internal/service/scanner_cloud_scan.go b/internal/service/scanner_cloud_scan.go
deleted file mode 100644
index 45d2105..0000000
--- a/internal/service/scanner_cloud_scan.go
+++ /dev/null
@@ -1,184 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-type cloudScanImportRequest struct {
- provider string
- candidates []cloudCandidate
- existingMedia map[string]existingCloudMedia
- writeBatch *localMediaWriteBatch
- probeBudget *int
- defaultRootID string
- progress *cloudScanProgressState
- result *ScanResult
-}
-
-type cloudScanImportResult struct {
- seen map[string]struct{}
- touchedLibraryIDs []string
- scopeLibraryIDs []string
-}
-
-type cloudLibraryScanCompletion struct {
- libraryID string
- touchedLibraryIDs []string
- result *ScanResult
- progress *cloudScanProgressState
- autoScrape bool
-}
-
-func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Library, mount CloudMountInfo, autoScrape bool) (*ScanResult, error) {
- return s.scanCloudLibraryWithRoot(ctx, lib, mount, "", autoScrape)
-}
-
-func (s *ScannerService) scanCloudLibraryRoot(ctx context.Context, lib *model.Library, root *model.LibraryRoot, mount CloudMountInfo, autoScrape bool) (*ScanResult, error) {
- return s.scanCloudLibraryWithRoot(ctx, lib, mount, libraryRootID(root), autoScrape)
-}
-
-func (s *ScannerService) scanCloudLibraryWithRoot(ctx context.Context, lib *model.Library, mount CloudMountInfo, defaultRootID string, autoScrape bool) (*ScanResult, error) {
- res := &ScanResult{LibraryID: lib.ID}
- if s.storage == nil {
- return res, fmt.Errorf("cloud storage service unavailable")
- }
-
- cfg, err := s.repo.StorageConfig.Get(ctx, mount.Provider)
- if err != nil || cfg == nil {
- return res, fmt.Errorf("storage config not found: %s", mount.Provider)
- }
- if !cfg.Enabled {
- return res, fmt.Errorf("storage %s is disabled", mount.Provider)
- }
- typ := mount.Provider
- rootDir := mount.ScanDir
- rootDisplayDir := mount.DisplayDir
- autoCategoryRoot := cloudRootMountNeedsAutoCategory(mount)
- scopeIDs := s.cloudScanLibraryScopeIDs(ctx, lib, mount)
- progress := newCloudScanProgressState()
- progress.publish(s, lib.ID, res, "listing", true)
- candidates, err := s.collectCloudScanCandidates(ctx, lib, cloudScanCandidateRequest{
- provider: typ,
- rootDir: rootDir,
- rootDisplayDir: rootDisplayDir,
- autoCategoryRoot: autoCategoryRoot,
- progress: progress,
- result: res,
- })
- if err != nil {
- return res, err
- }
- existingMedia, err := s.existingCloudMediaSnapshotForLibraries(ctx, scopeIDs)
- if err != nil {
- s.log.Warn("load existing cloud media snapshot failed", zap.String("library_id", lib.ID), zap.Error(err))
- existingMedia = nil
- }
- sortCloudCandidatesByRefreshPriority(candidates, existingMedia)
- writeBatch := newLocalMediaWriteBatch(s, ctx, res, 100)
- probeBudget := maxCloudMediaProbeQueuePerScan
- imported, err := s.importCloudScanCandidates(ctx, lib, cloudScanImportRequest{
- provider: typ,
- candidates: candidates,
- existingMedia: existingMedia,
- writeBatch: writeBatch,
- probeBudget: &probeBudget,
- defaultRootID: defaultRootID,
- progress: progress,
- result: res,
- })
- if err != nil {
- return res, err
- }
- scopeIDs = appendUniqueLibraryIDs(scopeIDs, imported.scopeLibraryIDs...)
- writeBatch.Flush()
- var removed int64
- if defaultRootID != "" {
- removed, err = s.pruneMissingCloudMediaForRoot(ctx, lib.ID, defaultRootID, imported.seen)
- } else {
- removed, err = s.pruneMissingCloudMediaForLibraries(ctx, scopeIDs, imported.seen)
- }
- if err != nil {
- s.log.Warn("prune missing cloud media failed", zap.String("library_id", lib.ID), zap.Error(err))
- } else {
- res.Removed = removed
- }
- s.completeCloudLibraryScan(ctx, cloudLibraryScanCompletion{
- libraryID: lib.ID,
- touchedLibraryIDs: imported.touchedLibraryIDs,
- result: res,
- progress: progress,
- autoScrape: autoScrape,
- })
- return res, nil
-}
-
-type cloudScanTarget struct {
- lib *model.Library
- rootID string
-}
-
-func (s *ScannerService) importCloudScanCandidates(ctx context.Context, rootLib *model.Library, req cloudScanImportRequest) (cloudScanImportResult, error) {
- imported := cloudScanImportResult{
- seen: make(map[string]struct{}),
- touchedLibraryIDs: []string{},
- scopeLibraryIDs: []string{},
- }
- targetLibs := map[string]cloudScanTarget{"": {lib: rootLib, rootID: req.defaultRootID}}
- for _, candidate := range req.candidates {
- select {
- case <-ctx.Done():
- return imported, ctx.Err()
- default:
- }
- target := targetLibs[""]
- if candidate.categoryDisplayDir != "" {
- categoryKey := candidate.categoryDisplayDir + "\x00" + candidate.categoryScanDir
- if cached, ok := targetLibs[categoryKey]; ok {
- target = cached
- } else if categoryTarget, err := s.ensureCloudAutoCategoryTarget(ctx, rootLib, req.provider, candidate.categoryDisplayDir, candidate.categoryScanDir); err == nil && categoryTarget.Library != nil {
- target = cloudScanTarget{lib: categoryTarget.Library, rootID: categoryTarget.RootID}
- targetLibs[categoryKey] = target
- imported.scopeLibraryIDs = appendUniqueLibraryIDs(imported.scopeLibraryIDs, categoryTarget.Library.ID)
- } else if err != nil {
- s.log.Warn("ensure cloud auto category library failed",
- zap.String("library_id", rootLib.ID),
- zap.String("provider", req.provider),
- zap.String("category", candidate.categoryDisplayDir),
- zap.String("scan_dir", candidate.categoryScanDir),
- zap.Error(err))
- }
- }
- targetLib := target.lib
- if targetLib == nil {
- targetLib = rootLib
- }
- imported.touchedLibraryIDs = appendUniqueLibraryIDs(imported.touchedLibraryIDs, targetLib.ID)
- imported.seen[candidate.path] = struct{}{}
- s.ingestCloudFile(ctx, targetLib, target.rootID, req.provider, candidate.ref, candidate.path, candidate.name, candidate.size, candidate.localMeta, req.existingMedia, req.writeBatch, req.probeBudget, req.result)
- req.progress.publish(s, rootLib.ID, req.result, "importing", req.result.Visited == 1 || req.result.Visited%100 == 0)
- }
- return imported, nil
-}
-
-func (s *ScannerService) completeCloudLibraryScan(ctx context.Context, req cloudLibraryScanCompletion) {
- publishCloudScanFinished(s, req.libraryID, req.result, req.progress)
- s.invalidateMediaCache(ctx)
- targetIDs := appendUniqueLibraryIDs(req.touchedLibraryIDs, req.libraryID)
- for _, targetID := range targetIDs {
- s.maybeGenerateSTRMAfterScan(targetID)
- }
- if scanHasImportChanges(req.result) && req.autoScrape && s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) {
- for _, targetID := range targetIDs {
- s.startAutoScrape(ctx, targetID)
- }
- }
-}
-
-func scanHasImportChanges(res *ScanResult) bool {
- return res != nil && (res.Added > 0 || res.Updated > 0 || res.Removed > 0)
-}
diff --git a/internal/service/scanner_cloud_scan_progress.go b/internal/service/scanner_cloud_scan_progress.go
deleted file mode 100644
index 284671a..0000000
--- a/internal/service/scanner_cloud_scan_progress.go
+++ /dev/null
@@ -1,170 +0,0 @@
-package service
-
-import (
- "sort"
- "sync"
- "time"
-)
-
-type cloudCandidate struct {
- ref string
- name string
- size int64
- path string
- categoryDisplayDir string
- categoryScanDir string
- localMeta *LocalMetadata
-}
-
-type cloudScanProgressState struct {
- mu sync.Mutex
- startedAt time.Time
- lastProgress time.Time
- dirsVisited int
- filesDiscovered int
-}
-
-type cloudScanProgressSnapshot struct {
- dirsVisited int
- filesDiscovered int
- visited int
- added int
- updated int
- skipped int
- removed int64
- elapsed time.Duration
-}
-
-func newCloudScanProgressState() *cloudScanProgressState {
- return &cloudScanProgressState{startedAt: time.Now()}
-}
-
-func (p *cloudScanProgressState) markDirVisited() bool {
- p.mu.Lock()
- defer p.mu.Unlock()
- p.dirsVisited++
- return p.dirsVisited == 1 || p.dirsVisited%20 == 0
-}
-
-func (p *cloudScanProgressState) markFileDiscovered() bool {
- p.mu.Lock()
- defer p.mu.Unlock()
- p.filesDiscovered++
- return p.filesDiscovered%100 == 0
-}
-
-func (p *cloudScanProgressState) addSkipped(res *ScanResult) {
- p.mu.Lock()
- defer p.mu.Unlock()
- res.Skipped++
-}
-
-func (p *cloudScanProgressState) publish(s *ScannerService, libraryID string, res *ScanResult, stage string, force bool) {
- if s == nil || s.hub == nil {
- return
- }
- snap, ok := p.snapshotForProgress(res, force)
- if !ok {
- return
- }
- filesPerSecond := snap.filesPerSecond()
- s.updateCloudScanProgress(libraryID, stage, snap.dirsVisited, snap.filesDiscovered, snap.visited, snap.added, snap.updated, snap.skipped, snap.removed, filesPerSecond)
- s.hub.Publish("scan", map[string]any{
- "library_id": libraryID,
- "cloud": true,
- "stage": stage,
- "dirs": snap.dirsVisited,
- "discovered": snap.filesDiscovered,
- "visited": snap.visited,
- "added": snap.added,
- "updated": snap.updated,
- "skipped": snap.skipped,
- "elapsed_seconds": int(snap.elapsed.Seconds()),
- "files_per_second": filesPerSecond,
- "estimate_message": "云盘接口不提供总文件数,剩余时间会随目录大小和网盘响应速度变化",
- })
-}
-
-func (p *cloudScanProgressState) snapshotForProgress(res *ScanResult, force bool) (cloudScanProgressSnapshot, bool) {
- p.mu.Lock()
- defer p.mu.Unlock()
- if !force && time.Since(p.lastProgress) < 2*time.Second {
- return cloudScanProgressSnapshot{}, false
- }
- p.lastProgress = time.Now()
- return p.snapshotLocked(res), true
-}
-
-func (p *cloudScanProgressState) finalSnapshot(res *ScanResult) cloudScanProgressSnapshot {
- p.mu.Lock()
- defer p.mu.Unlock()
- return p.snapshotLocked(res)
-}
-
-func (p *cloudScanProgressState) snapshotLocked(res *ScanResult) cloudScanProgressSnapshot {
- snap := cloudScanProgressSnapshot{
- dirsVisited: p.dirsVisited,
- filesDiscovered: p.filesDiscovered,
- elapsed: time.Since(p.startedAt),
- }
- if res != nil {
- snap.visited = res.Visited
- snap.added = res.Added
- snap.updated = res.Updated
- snap.skipped = res.Skipped
- snap.removed = res.Removed
- }
- return snap
-}
-
-func (s cloudScanProgressSnapshot) filesPerSecond() float64 {
- processed := s.filesDiscovered
- if s.visited > processed {
- processed = s.visited
- }
- if s.elapsed.Seconds() <= 0 {
- return 0
- }
- return float64(processed) / s.elapsed.Seconds()
-}
-
-func publishCloudScanFinished(s *ScannerService, libraryID string, res *ScanResult, progress *cloudScanProgressState) {
- if s == nil || s.hub == nil || progress == nil {
- return
- }
- snap := progress.finalSnapshot(res)
- s.hub.Publish("scan", map[string]any{
- "library_id": libraryID,
- "finished": true,
- "visited": res.Visited,
- "added": res.Added,
- "updated": res.Updated,
- "skipped": res.Skipped,
- "removed": res.Removed,
- "error_count": res.ErrorCount,
- "errors": res.Errors,
- "discovered": snap.filesDiscovered,
- "dirs": snap.dirsVisited,
- "elapsed_seconds": int(snap.elapsed.Seconds()),
- "cloud": true,
- })
-}
-
-func sortCloudCandidatesByRefreshPriority(candidates []cloudCandidate, existingMedia map[string]existingCloudMedia) {
- if existingMedia == nil {
- return
- }
- priority := func(candidate cloudCandidate) int {
- existing, ok := existingMedia[candidate.path]
- if !ok {
- return 2
- }
- if cloudTrackMetadataMissing(existing) || cloudMetadataNeedsRefresh(existing, candidate.localMeta) {
- return 0
- }
- return 1
- }
- sort.SliceStable(candidates, func(i, j int) bool {
- return priority(candidates[i]) < priority(candidates[j])
- })
-}
diff --git a/internal/service/scanner_cloud_status.go b/internal/service/scanner_cloud_status.go
deleted file mode 100644
index 32ff11e..0000000
--- a/internal/service/scanner_cloud_status.go
+++ /dev/null
@@ -1,265 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "strings"
- "time"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *ScannerService) beginCloudScan(ctx context.Context, lib *model.Library, mount CloudMountInfo) (context.Context, func(*ScanResult, error), error) {
- if s == nil || lib == nil {
- return ctx, func(*ScanResult, error) {}, nil
- }
- s.cloudScanMu.Lock()
- if s.cloudScans == nil {
- s.cloudScans = make(map[string]*cloudScanEntry)
- }
- if entry := s.cloudScans[lib.ID]; cloudScanBlocksBegin(entry) {
- s.cloudScanMu.Unlock()
- return ctx, nil, ErrCloudScanAlreadyRunning
- }
- runCtx, cancel := context.WithCancel(ctx)
- s.cloudScans[lib.ID] = newCloudScanEntry(lib.ID, mount.Provider, cancel)
- s.cloudScanMu.Unlock()
-
- finish := func(res *ScanResult, err error) {
- s.finishCloudScan(lib, mount, res, err)
- }
- return runCtx, finish, nil
-}
-
-func newCloudScanEntry(libraryID, provider string, cancel context.CancelFunc) *cloudScanEntry {
- now := time.Now()
- return &cloudScanEntry{
- status: CloudScanStatus{
- LibraryID: libraryID,
- Provider: provider,
- Stage: "listing",
- State: "running",
- StartedAt: now,
- UpdatedAt: now,
- ResumeHint: "中断后再次点击扫描会从头遍历,但已入库媒体会去重更新,只补齐缺失项。",
- Estimate: "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度。",
- },
- cancel: cancel,
- }
-}
-
-func (s *ScannerService) finishCloudScan(lib *model.Library, mount CloudMountInfo, res *ScanResult, err error) {
- s.cloudScanMu.Lock()
- defer s.cloudScanMu.Unlock()
- current := s.cloudScans[lib.ID]
- if current == nil {
- return
- }
- applyCloudScanResult(¤t.status, res)
- current.status.UpdatedAt = time.Now()
- current.status.FinishedAt = current.status.UpdatedAt
- current.cancel = nil
- applyCloudScanCompletion(¤t.status, err)
- s.publishCloudScanFinished(lib.ID, mount.Provider, current.status)
- s.notifyScanFinished(lib, res, err, true)
-}
-
-func applyCloudScanResult(status *CloudScanStatus, res *ScanResult) {
- if status == nil || res == nil {
- return
- }
- status.Visited = res.Visited
- status.Added = res.Added
- status.Updated = res.Updated
- status.Skipped = res.Skipped
- status.Removed = res.Removed
- status.ErrorCount = res.ErrorCount
- status.Errors = append([]string(nil), res.Errors...)
-}
-
-func applyCloudScanCompletion(status *CloudScanStatus, err error) {
- if status == nil {
- return
- }
- switch {
- case errors.Is(err, context.Canceled):
- status.State = "canceled"
- status.Stage = "canceled"
- status.Error = ""
- case errors.Is(err, context.DeadlineExceeded):
- status.State = "error"
- status.Stage = "error"
- status.Error = "扫描超时:" + err.Error()
- case err != nil:
- status.State = "error"
- status.Stage = "error"
- status.Error = err.Error()
- default:
- status.State = "finished"
- status.Stage = "finished"
- if status.ErrorCount > 0 {
- status.Error = fmt.Sprintf("部分文件入库失败:%d 个,详情见 errors", status.ErrorCount)
- } else {
- status.Error = ""
- }
- }
-}
-
-func (s *ScannerService) publishCloudScanFinished(libraryID, provider string, status CloudScanStatus) {
- if s == nil || s.hub == nil {
- return
- }
- s.hub.Publish("scan", map[string]any{
- "library_id": libraryID,
- "provider": provider,
- "cloud": true,
- "finished": true,
- "state": status.State,
- "stage": status.Stage,
- "error": status.Error,
- "visited": status.Visited,
- "added": status.Added,
- "updated": status.Updated,
- "skipped": status.Skipped,
- "removed": status.Removed,
- "error_count": status.ErrorCount,
- "errors": status.Errors,
- })
-}
-
-func (s *ScannerService) updateCloudScanProgress(libraryID, stage string, dirs, discovered, visited, added, updated, skipped int, removed int64, filesPerSecond float64) {
- if s == nil {
- return
- }
- s.cloudScanMu.Lock()
- defer s.cloudScanMu.Unlock()
- entry := s.cloudScans[libraryID]
- if entry == nil {
- return
- }
- entry.status.Stage = stage
- entry.status.UpdatedAt = time.Now()
- entry.status.Dirs = dirs
- entry.status.Discovered = discovered
- entry.status.Visited = visited
- entry.status.Added = added
- entry.status.Updated = updated
- entry.status.Skipped = skipped
- entry.status.Removed = removed
- entry.status.FilesPerSecond = filesPerSecond
-}
-
-func (s *ScannerService) acquireCloudScanSlot(ctx context.Context, libraryID string) (func(), error) {
- if s == nil {
- return func() {}, nil
- }
- s.cloudScanMu.Lock()
- if s.cloudSlots == nil {
- s.cloudSlots = make(chan struct{}, 1)
- }
- slots := s.cloudSlots
- if entry := s.cloudScans[libraryID]; entry != nil {
- entry.status.Stage = "queued"
- entry.status.UpdatedAt = time.Now()
- }
- s.cloudScanMu.Unlock()
-
- select {
- case slots <- struct{}{}:
- s.cloudScanMu.Lock()
- if entry := s.cloudScans[libraryID]; entry != nil && entry.status.State == "running" {
- entry.status.Stage = "listing"
- entry.status.UpdatedAt = time.Now()
- }
- s.cloudScanMu.Unlock()
- return func() { <-slots }, nil
- case <-ctx.Done():
- return nil, ctx.Err()
- }
-}
-
-// CloudScanStatuses returns the current or most recent status per cloud library.
-func (s *ScannerService) CloudScanStatuses() []CloudScanStatus {
- if s == nil {
- return nil
- }
- s.cloudScanMu.Lock()
- defer s.cloudScanMu.Unlock()
- out := make([]CloudScanStatus, 0, len(s.cloudScans))
- for _, entry := range s.cloudScans {
- out = append(out, entry.status)
- }
- return out
-}
-
-func (s *ScannerService) CancelCloudScan(libraryID string) bool {
- if s == nil || strings.TrimSpace(libraryID) == "" {
- return false
- }
- s.cloudScanMu.Lock()
- defer s.cloudScanMu.Unlock()
- return cancelCloudScanEntry(s.cloudScans[libraryID])
-}
-
-func (s *ScannerService) CancelAllCloudScans() int {
- if s == nil {
- return 0
- }
- s.cloudScanMu.Lock()
- defer s.cloudScanMu.Unlock()
- cancelled := 0
- for _, entry := range s.cloudScans {
- if cancelCloudScanEntry(entry) {
- cancelled++
- }
- }
- return cancelled
-}
-
-func (s *ScannerService) CancelCloudScansForProvider(provider string) int {
- if s == nil {
- return 0
- }
- provider = strings.TrimSpace(provider)
- if provider == "" {
- return 0
- }
- s.cloudScanMu.Lock()
- defer s.cloudScanMu.Unlock()
- cancelled := 0
- for _, entry := range s.cloudScans {
- if entry == nil || entry.status.Provider != provider {
- continue
- }
- if cancelCloudScanEntry(entry) {
- cancelled++
- }
- }
- return cancelled
-}
-
-func cancelCloudScanEntry(entry *cloudScanEntry) bool {
- if !cloudScanActive(entry) {
- return false
- }
- entry.status.State = "canceling"
- entry.status.Stage = "canceling"
- entry.status.UpdatedAt = time.Now()
- if entry.cancel != nil {
- entry.cancel()
- return true
- }
- entry.status.State = "canceled"
- entry.status.Stage = "canceled"
- entry.status.FinishedAt = time.Now()
- return true
-}
-
-func cloudScanActive(entry *cloudScanEntry) bool {
- return entry != nil && (entry.status.State == "running" || entry.status.State == "queued" || entry.status.State == "canceling")
-}
-
-func cloudScanBlocksBegin(entry *cloudScanEntry) bool {
- return entry != nil && (entry.status.State == "running" || entry.status.State == "canceling")
-}
diff --git a/internal/service/scanner_cloud_status_test.go b/internal/service/scanner_cloud_status_test.go
deleted file mode 100644
index 5468e71..0000000
--- a/internal/service/scanner_cloud_status_test.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "testing"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func TestBeginCloudScanAllowsQueuedEntryToStart(t *testing.T) {
- scanner := &ScannerService{
- cloudScans: map[string]*cloudScanEntry{
- "lib-1": {status: CloudScanStatus{LibraryID: "lib-1", Provider: "openlist", State: "queued", Stage: "queued"}},
- },
- }
- lib := &model.Library{Base: model.Base{ID: "lib-1"}, Name: "Movies"}
- mount := CloudMountInfo{Provider: "openlist"}
-
- _, finish, err := scanner.beginCloudScan(context.Background(), lib, mount)
- if err != nil {
- t.Fatalf("queued scan should be allowed to start, got %v", err)
- }
- if finish == nil {
- t.Fatal("finish callback should not be nil")
- }
- statuses := scanner.CloudScanStatuses()
- if len(statuses) != 1 || statuses[0].State != "running" || statuses[0].Stage != "listing" {
- t.Fatalf("status after begin = %#v, want running/listing", statuses)
- }
-
- finish(&ScanResult{Visited: 5, Added: 2, Updated: 1, ErrorCount: 1, Errors: []string{"bad file"}}, nil)
- statuses = scanner.CloudScanStatuses()
- if len(statuses) != 1 || statuses[0].State != "finished" || statuses[0].Visited != 5 || statuses[0].Added != 2 || statuses[0].ErrorCount != 1 {
- t.Fatalf("status after finish = %#v", statuses)
- }
- if statuses[0].Error == "" {
- t.Fatal("finished scan with error_count should keep summary error text")
- }
-}
-
-func TestBeginCloudScanRejectsRunningEntry(t *testing.T) {
- scanner := &ScannerService{
- cloudScans: map[string]*cloudScanEntry{
- "lib-1": {status: CloudScanStatus{LibraryID: "lib-1", Provider: "openlist", State: "running", Stage: "listing"}},
- },
- }
- lib := &model.Library{Base: model.Base{ID: "lib-1"}, Name: "Movies"}
-
- _, finish, err := scanner.beginCloudScan(context.Background(), lib, CloudMountInfo{Provider: "openlist"})
- if !errors.Is(err, ErrCloudScanAlreadyRunning) {
- t.Fatalf("err = %v, want ErrCloudScanAlreadyRunning", err)
- }
- if finish != nil {
- t.Fatal("finish callback should be nil when begin is rejected")
- }
-}
diff --git a/internal/service/scanner_cloud_test.go b/internal/service/scanner_cloud_test.go
deleted file mode 100644
index 38fb4dd..0000000
--- a/internal/service/scanner_cloud_test.go
+++ /dev/null
@@ -1,152 +0,0 @@
-package service
-
-import (
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/config"
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestCloudLibraryPathParsing(t *testing.T) {
- typ, dir, ok := parseCloudLibraryPath("cloud://cloud115/abc%20123?ignored=1")
- if !ok || typ != "cloud115" || dir != "abc 123" {
- t.Fatalf("parse path got typ=%q dir=%q ok=%v", typ, dir, ok)
- }
- typ, dir, ok = parseCloudLibraryPath("cloud://openlist/Movies?dir=%2FMovies")
- if !ok || typ != "openlist" || dir != "Movies" {
- t.Fatalf("parse query got typ=%q dir=%q ok=%v", typ, dir, ok)
- }
- if ref := cloudEntryRef("cloud115", "fid", "pick"); ref != "pick" {
- t.Fatalf("115 ref = %q, want pick", ref)
- }
-}
-
-func TestCloudMountConflictDetectsNestedMounts(t *testing.T) {
- root := model.Library{Base: model.Base{ID: "root"}, Name: "115", Path: "cloud://cloud115", Enabled: true}
- childPath := BuildCloudLibraryPath("cloud115", "child-id", "parent-id/child-id")
- info, ok := ParseCloudLibraryMount(childPath)
- if !ok || info.ScanDir != "child-id" || info.DisplayDir != "parent-id/child-id" {
- t.Fatalf("parse child mount = %#v ok=%v", info, ok)
- }
-
- conflict := FindCloudMountConflict([]model.Library{root}, "cloud115", "child-id", "parent-id/child-id")
- if conflict != nil {
- t.Fatalf("child mount under existing root should be allowed, got conflict %#v", conflict)
- }
-
- sibling := model.Library{Base: model.Base{ID: "sibling"}, Name: "Sibling", Path: BuildCloudLibraryPath("cloud115", "sibling-id", "parent-id/sibling-id"), Enabled: true}
- conflict = FindCloudMountConflict([]model.Library{sibling}, "cloud115", "child-id", "parent-id/child-id")
- if conflict != nil {
- t.Fatalf("sibling conflict = %#v, want nil", conflict)
- }
-
- conflict = FindCloudMountConflict([]model.Library{sibling}, "cloud115", "parent-id", "parent-id")
- if conflict == nil || !conflict.Nested {
- t.Fatalf("parent mount over existing child = %#v, want nested conflict", conflict)
- }
- oldIDPath := BuildCloudLibraryPath("cloud115", "child-id", "old-parent-id/child-id")
- conflict = FindCloudMountConflict([]model.Library{{Base: model.Base{ID: "old"}, Name: "Old", Path: oldIDPath, Enabled: true}}, "cloud115", "child-id", "父目录/子目录")
- if conflict == nil || !conflict.Exact {
- t.Fatalf("same scan dir with renamed display path = %#v, want exact conflict", conflict)
- }
-
- root.CreatedAt = root.CreatedAt.Add(-1)
- child := model.Library{Base: model.Base{ID: "child"}, Name: "Child", Path: childPath, Enabled: true}
- if shadow := CloudLibraryShadowed([]model.Library{root, child}, child); shadow != nil {
- t.Fatalf("child should not be shadowed by root: %#v", shadow)
- }
- if shadow := CloudLibraryShadowed([]model.Library{root, child}, root); shadow == nil || !shadow.Nested {
- t.Fatalf("root should be shadowed by child, got %#v", shadow)
- }
-}
-
-func TestCancelCloudScansForProviderSignalsRunningScan(t *testing.T) {
- scanner := NewScannerService(&config.Config{}, zap.NewNop(), repository.New(nil), NewHub(zap.NewNop()), nil, nil)
- cancelled := false
- scanner.cloudScans["lib-1"] = &cloudScanEntry{
- status: CloudScanStatus{LibraryID: "lib-1", Provider: "openlist", State: "running"},
- cancel: func() {
- cancelled = true
- },
- }
-
- if got := scanner.CancelCloudScansForProvider("openlist"); got != 1 {
- t.Fatalf("cancelled = %d, want 1", got)
- }
- if !cancelled {
- t.Fatal("cancel func was not called")
- }
- if state := scanner.cloudScans["lib-1"].status.State; state != "canceling" {
- t.Fatalf("state = %q, want canceling", state)
- }
-}
-
-func TestInferCloudMountMediaType(t *testing.T) {
- cases := map[string]string{
- "/日漫": "anime",
- "/国漫": "anime",
- "/美漫": "anime",
- "/电视剧/国产剧": "tv",
- "/电视剧/欧美剧": "tv",
- "/电视剧/日韩剧": "tv",
- "/电影/动画电影": "movie",
- "/电影/华语电影": "movie",
- "/电影/欧美电影": "movie",
- "/综艺": "variety",
- }
- for dir, want := range cases {
- if got := InferCloudMountMediaType(dir, "OpenList · "+dir); got != want {
- t.Fatalf("%s type = %s, want %s", dir, got, want)
- }
- }
-}
-
-func TestCloudSeriesTitlePrefersShowFolder(t *testing.T) {
- title, year := cloudSeriesTitleFromMediaPath("cloud://openlist/国产剧/紫川 (2024) {tmdb-247590}/Season 2/紫川.2024.S02E24.第24集.2160p.WEB-DL.H.265-ColorTV.mkv")
- if title != "紫川" || year != 2024 {
- t.Fatalf("cloud series title = %q/%d, want 紫川/2024", title, year)
- }
- title, year = cloudSeriesTitleFromMediaPath("cloud://openlist/国产剧/紫川.2024.S02E24.mkv")
- if title != "" || year != 0 {
- t.Fatalf("single category folder should not override title, got %q/%d", title, year)
- }
-}
-
-func TestCloudMetadataNeedsRefreshWhenPathHintConflicts(t *testing.T) {
- existing := existingCloudMedia{
- Year: 2025,
- TMDbID: 220269,
- }
- local := &LocalMetadata{
- Year: 2025,
- TMDbID: 296753,
- PathHint: true,
- }
- if !cloudMetadataNeedsRefresh(existing, local) {
- t.Fatal("conflicting explicit cloud path hint should refresh existing media")
- }
-}
-
-func TestParseCloudArtworkURL(t *testing.T) {
- typ, ref, ok := ParseCloudArtworkURL("http://nas.local/api/cloud/play/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg")
- if !ok || typ != "openlist" || ref != "/Anime/JianLai/poster.jpg" {
- t.Fatalf("parse cloud image url = typ=%q ref=%q ok=%v", typ, ref, ok)
- }
- typ, ref, ok = ParseCloudArtworkURL("/api/img/cloud/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg")
- if !ok || typ != "openlist" || ref != "/Anime/JianLai/poster.jpg" {
- t.Fatalf("parse cached cloud artwork url = typ=%q ref=%q ok=%v", typ, ref, ok)
- }
- typ, ref, ok = ParseCloudArtworkURL("/api/img/cloud/openlist?ref=%2FMovies%2FMovie.tbn")
- if !ok || typ != "openlist" || ref != "/Movies/Movie.tbn" {
- t.Fatalf("parse tbn cloud artwork url = typ=%q ref=%q ok=%v", typ, ref, ok)
- }
- if _, _, ok := ParseCloudArtworkURL("/api/cloud/play/openlist?ref=%2FAnime%2FJianLai%2Fmovie.mkv"); ok {
- t.Fatal("video cloud url should not be treated as artwork")
- }
- if _, _, ok := ParseCloudArtworkURL("https://image.tmdb.org/t/p/w500/poster.jpg"); ok {
- t.Fatal("remote HTTP poster should not be treated as cloud artwork")
- }
-}
diff --git a/internal/service/scanner_cloud_test_helpers_test.go b/internal/service/scanner_cloud_test_helpers_test.go
deleted file mode 100644
index 33504ba..0000000
--- a/internal/service/scanner_cloud_test_helpers_test.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package service
-
-import (
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-)
-
-type openListTestEntry struct {
- Name string
- Size int64
- IsDir bool
-}
-
-func newOpenListAPIServer(t *testing.T, list func(path string, page, perPage int) ([]openListTestEntry, int)) *httptest.Server {
- t.Helper()
- return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/api/fs/list" {
- t.Fatalf("unexpected openlist api request %s", r.URL.Path)
- }
- var in struct {
- Path string `json:"path"`
- Page int `json:"page"`
- PerPage int `json:"per_page"`
- }
- if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
- t.Fatalf("decode openlist list request: %v", err)
- }
- if in.Path == "" {
- in.Path = "/"
- }
- if in.Page <= 0 {
- in.Page = 1
- }
- if in.PerPage <= 0 {
- in.PerPage = 500
- }
- entries, total := list(in.Path, in.Page, in.PerPage)
- content := make([]map[string]any, 0, len(entries))
- for _, entry := range entries {
- content = append(content, map[string]any{
- "name": entry.Name,
- "size": entry.Size,
- "is_dir": entry.IsDir,
- })
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{
- "code": 200,
- "message": "success",
- "data": map[string]any{
- "content": content,
- "total": total,
- },
- })
- }))
-}
diff --git a/internal/service/scanner_existing_media.go b/internal/service/scanner_existing_media.go
index b41abd7..15f224c 100644
--- a/internal/service/scanner_existing_media.go
+++ b/internal/service/scanner_existing_media.go
@@ -8,62 +8,6 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/model"
)
-func (s *ScannerService) existingCloudMediaSnapshot(ctx context.Context, libraryID string) (map[string]existingCloudMedia, error) {
- return s.existingCloudMediaSnapshotForLibraries(ctx, []string{libraryID})
-}
-
-func (s *ScannerService) existingCloudMediaSnapshotForLibraries(ctx context.Context, libraryIDs []string) (map[string]existingCloudMedia, error) {
- if len(libraryIDs) == 0 {
- return map[string]existingCloudMedia{}, nil
- }
- var rows []model.Media
- if err := s.repo.DB.WithContext(ctx).
- Model(&model.Media{}).
- Select("library_id", "path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "poster_url", "backdrop_url", "strm_url", "overview", "year", "release_date", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "genres", "countries", "languages", "nsfw", "scrape_status").
- Where("library_id IN ? AND path LIKE ?", libraryIDs, "cloud://%").
- Find(&rows).Error; err != nil {
- return nil, err
- }
- snapshot := make(map[string]existingCloudMedia, len(rows))
- for _, row := range rows {
- if strings.TrimSpace(row.Path) == "" {
- continue
- }
- snapshot[row.Path] = existingCloudMedia{
- LibraryID: row.LibraryID,
- Title: row.Title,
- OriginalName: row.OriginalName,
- EpisodeTitle: row.EpisodeTitle,
- SizeBytes: row.SizeBytes,
- DurationSec: row.DurationSec,
- Width: row.Width,
- Height: row.Height,
- VideoCodec: row.VideoCodec,
- AudioCodec: row.AudioCodec,
- Container: row.Container,
- PosterURL: row.PosterURL,
- BackdropURL: row.BackdropURL,
- STRMURL: row.STRMURL,
- Overview: row.Overview,
- Year: row.Year,
- ReleaseDate: row.ReleaseDate,
- Rating: row.Rating,
- TMDbID: row.TMDbID,
- BangumiID: row.BangumiID,
- DoubanID: row.DoubanID,
- TheTVDBID: row.TheTVDBID,
- SeasonNum: row.SeasonNum,
- EpisodeNum: row.EpisodeNum,
- Genres: row.Genres,
- Countries: row.Countries,
- Languages: row.Languages,
- NSFW: row.NSFW,
- ScrapeStatus: row.ScrapeStatus,
- }
- }
- return snapshot, nil
-}
-
func (s *ScannerService) existingLocalMediaSnapshot(ctx context.Context, libraryID string) (map[string]existingLocalMedia, error) {
var rows []model.Media
if err := s.repo.DB.WithContext(ctx).
diff --git a/internal/service/scanner_existing_media_test.go b/internal/service/scanner_existing_media_test.go
index ed49328..8d1aa7e 100644
--- a/internal/service/scanner_existing_media_test.go
+++ b/internal/service/scanner_existing_media_test.go
@@ -10,60 +10,6 @@ import (
"go.uber.org/zap"
)
-func TestExistingCloudMediaSnapshotFiltersCloudRows(t *testing.T) {
- db := newServiceTestDB(t, &model.Media{})
- repos := repository.New(db)
- scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
-
- if err := db.Create(&[]model.Media{
- {
- LibraryID: "lib-1",
- Path: "cloud://openlist/Movie.mkv",
- SizeBytes: 2048,
- DurationSec: 120,
- Width: 1920,
- Height: 1080,
- VideoCodec: "h264",
- AudioCodec: "aac",
- Container: "mkv",
- PosterURL: "/poster.jpg",
- BackdropURL: "/backdrop.jpg",
- STRMURL: "/api/cloud/play/openlist?ref=movie",
- Year: 2026,
- TMDbID: 123,
- BangumiID: 456,
- DoubanID: "douban-1",
- TheTVDBID: "tvdb-1",
- ScrapeStatus: "matched",
- },
- {LibraryID: "lib-1", Path: "/media/local.mkv", SizeBytes: 99},
- {LibraryID: "lib-2", Path: "cloud://openlist/Other.mkv", SizeBytes: 88},
- }).Error; err != nil {
- t.Fatal(err)
- }
-
- got, err := scanner.existingCloudMediaSnapshot(t.Context(), "lib-1")
- if err != nil {
- t.Fatal(err)
- }
- if len(got) != 1 {
- t.Fatalf("snapshot len = %d, want 1: %#v", len(got), got)
- }
- row := got["cloud://openlist/Movie.mkv"]
- if row.SizeBytes != 2048 || row.DurationSec != 120 || row.Width != 1920 || row.Height != 1080 {
- t.Fatalf("track fields not preserved: %#v", row)
- }
- if row.VideoCodec != "h264" || row.AudioCodec != "aac" || row.Container != "mkv" {
- t.Fatalf("codec fields not preserved: %#v", row)
- }
- if row.PosterURL != "/poster.jpg" || row.BackdropURL != "/backdrop.jpg" || row.STRMURL == "" {
- t.Fatalf("artwork/strm fields not preserved: %#v", row)
- }
- if row.Year != 2026 || row.TMDbID != 123 || row.BangumiID != 456 || row.DoubanID != "douban-1" || row.TheTVDBID != "tvdb-1" {
- t.Fatalf("scraper ids not preserved: %#v", row)
- }
-}
-
func TestExistingLocalMediaSnapshotFiltersAndCleansLocalRows(t *testing.T) {
db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
diff --git a/internal/service/scanner_local_probe_queue.go b/internal/service/scanner_local_probe_queue.go
index 752f155..fd6d9e9 100644
--- a/internal/service/scanner_local_probe_queue.go
+++ b/internal/service/scanner_local_probe_queue.go
@@ -50,6 +50,39 @@ func (s *ScannerService) startLocalMediaProbeWorkers() {
})
}
+func (s *ScannerService) ffprobeWorkerCount() int {
+ if s == nil || s.cfg == nil {
+ return 1
+ }
+ return normalizeFFprobeMaxConcurrent(s.cfg.App.FFprobeMaxConcurrent)
+}
+
+func probeResultUpdates(probe *ProbeResult) map[string]any {
+ updates := map[string]any{}
+ if probe == nil {
+ return updates
+ }
+ if probe.DurationSec > 0 {
+ updates["duration_sec"] = probe.DurationSec
+ }
+ if probe.Width > 0 {
+ updates["width"] = probe.Width
+ }
+ if probe.Height > 0 {
+ updates["height"] = probe.Height
+ }
+ if strings.TrimSpace(probe.VideoCodec) != "" {
+ updates["video_codec"] = probe.VideoCodec
+ }
+ if strings.TrimSpace(probe.AudioCodec) != "" {
+ updates["audio_codec"] = probe.AudioCodec
+ }
+ if probe.Container != "" {
+ updates["container"] = probe.Container
+ }
+ return updates
+}
+
func (s *ScannerService) reserveLocalMediaProbe(path string) bool {
s.localMediaProbeMu.Lock()
defer s.localMediaProbeMu.Unlock()
diff --git a/internal/service/scanner_metadata_refresh.go b/internal/service/scanner_metadata_refresh.go
index 1bd4d60..c46a079 100644
--- a/internal/service/scanner_metadata_refresh.go
+++ b/internal/service/scanner_metadata_refresh.go
@@ -19,100 +19,6 @@ type scanDerivedMetadata struct {
EpisodeNum int
}
-func cloudMetadataNeedsRefresh(existing existingCloudMedia, localMeta *LocalMetadata) bool {
- if localMeta == nil {
- return false
- }
- if localMeta.PathHint && !localMeta.HasNFO && !localMeta.HasArtwork {
- return cloudPathHintNeedsRefresh(existing, localMeta)
- }
- if localMetadataMarksMatched(localMeta) && strings.TrimSpace(existing.ScrapeStatus) != "matched" {
- return true
- }
- if localMeta.Title != "" && strings.TrimSpace(existing.Title) != strings.TrimSpace(localMeta.Title) {
- return true
- }
- if localMeta.OriginalName != "" && strings.TrimSpace(existing.OriginalName) != strings.TrimSpace(localMeta.OriginalName) {
- return true
- }
- if localMeta.EpisodeTitle != "" && strings.TrimSpace(existing.EpisodeTitle) != strings.TrimSpace(localMeta.EpisodeTitle) {
- return true
- }
- if localMeta.AdultCode != "" && !strings.EqualFold(strings.TrimSpace(existing.OriginalName), strings.TrimSpace(localMeta.AdultCode)) {
- return true
- }
- if localMeta.Year > 0 && existing.Year != localMeta.Year {
- return true
- }
- if localMeta.ReleaseDate != "" && strings.TrimSpace(existing.ReleaseDate) != strings.TrimSpace(localMeta.ReleaseDate) {
- return true
- }
- if localMeta.Overview != "" && strings.TrimSpace(existing.Overview) != strings.TrimSpace(localMeta.Overview) {
- return true
- }
- if localMeta.Rating > 0 && existing.Rating != localMeta.Rating {
- return true
- }
- if localMeta.TMDbID > 0 && existing.TMDbID != localMeta.TMDbID {
- return true
- }
- if localMeta.BangumiID > 0 && existing.BangumiID != localMeta.BangumiID {
- return true
- }
- if strings.TrimSpace(localMeta.DoubanID) != "" && strings.TrimSpace(existing.DoubanID) != strings.TrimSpace(localMeta.DoubanID) {
- return true
- }
- if strings.TrimSpace(localMeta.TheTVDBID) != "" && strings.TrimSpace(existing.TheTVDBID) != strings.TrimSpace(localMeta.TheTVDBID) {
- return true
- }
- if strings.TrimSpace(localMeta.PosterURL) != "" && strings.TrimSpace(existing.PosterURL) != strings.TrimSpace(localMeta.PosterURL) {
- return true
- }
- if strings.TrimSpace(localMeta.BackdropURL) != "" && strings.TrimSpace(existing.BackdropURL) != strings.TrimSpace(localMeta.BackdropURL) {
- return true
- }
- if (localMeta.SeasonNum > 0 || localMeta.EpisodeNum > 0) && existing.SeasonNum != localMeta.SeasonNum {
- return true
- }
- if localMeta.EpisodeNum > 0 && existing.EpisodeNum != localMeta.EpisodeNum {
- return true
- }
- if localMeta.Genres != "" && strings.TrimSpace(existing.Genres) != strings.TrimSpace(localMeta.Genres) {
- return true
- }
- if localMeta.Countries != "" && strings.TrimSpace(existing.Countries) != strings.TrimSpace(localMeta.Countries) {
- return true
- }
- if localMeta.Languages != "" && strings.TrimSpace(existing.Languages) != strings.TrimSpace(localMeta.Languages) {
- return true
- }
- if localMeta.NSFW && !existing.NSFW {
- return true
- }
- return false
-}
-
-func cloudPathHintNeedsRefresh(existing existingCloudMedia, localMeta *LocalMetadata) bool {
- if localMeta.TMDbID > 0 && existing.TMDbID != localMeta.TMDbID {
- return true
- }
- if localMeta.BangumiID > 0 && existing.BangumiID != localMeta.BangumiID {
- return true
- }
- if strings.TrimSpace(localMeta.DoubanID) != "" && strings.TrimSpace(existing.DoubanID) != strings.TrimSpace(localMeta.DoubanID) {
- return true
- }
- return strings.TrimSpace(localMeta.TheTVDBID) != "" && strings.TrimSpace(existing.TheTVDBID) != strings.TrimSpace(localMeta.TheTVDBID)
-}
-
-func cloudTrackMetadataMissing(existing existingCloudMedia) bool {
- return existing.DurationSec <= 0 ||
- existing.Width <= 0 ||
- existing.Height <= 0 ||
- strings.TrimSpace(existing.VideoCodec) == "" ||
- strings.TrimSpace(existing.AudioCodec) == ""
-}
-
func localMetadataNeedsRefresh(existing existingLocalMedia, local *LocalMetadata) bool {
if local == nil {
return false
@@ -180,24 +86,6 @@ func localMetadataNeedsRefresh(existing existingLocalMedia, local *LocalMetadata
return local.NSFW && !existing.NSFW
}
-func cloudDerivedMetadataNeedsRefresh(existing existingCloudMedia, incoming *model.Media) bool {
- if incoming == nil {
- return false
- }
- return scanDerivedMetadataNeedsRefresh(scanDerivedMetadata{
- Title: existing.Title,
- ScrapeStatus: existing.ScrapeStatus,
- Year: existing.Year,
- ReleaseDate: existing.ReleaseDate,
- TMDbID: existing.TMDbID,
- BangumiID: existing.BangumiID,
- DoubanID: existing.DoubanID,
- TheTVDBID: existing.TheTVDBID,
- SeasonNum: existing.SeasonNum,
- EpisodeNum: existing.EpisodeNum,
- }, incoming)
-}
-
func localDerivedMetadataNeedsRefresh(existing existingLocalMedia, incoming *model.Media) bool {
if incoming == nil {
return false
diff --git a/internal/service/scanner_notifications.go b/internal/service/scanner_notifications.go
deleted file mode 100644
index 5d652d6..0000000
--- a/internal/service/scanner_notifications.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "time"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *ScannerService) notifyScanFinished(lib *model.Library, res *ScanResult, err error, cloud bool) {
- if s == nil || s.notify == nil || lib == nil || res == nil {
- return
- }
- if err != nil {
- go func() {
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- s.notify.Broadcast(ctx, "MediaStationGo 扫描异常", fmt.Sprintf("媒体库:%s\n错误:%s", lib.Name, err.Error()), EventSystemAlert)
- }()
- return
- }
- if res.Added+res.Updated <= 0 {
- return
- }
- source := "本地媒体库"
- if cloud {
- source = "网盘媒体库"
- }
- body := fmt.Sprintf("%s:%s\n新增:%d\n更新:%d\n跳过:%d\n移除:%d", source, lib.Name, res.Added, res.Updated, res.Skipped, res.Removed)
- go func() {
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- s.notify.Broadcast(ctx, "MediaStationGo 入库完成", body, EventLibraryIngest)
- }()
-}
diff --git a/internal/service/scanner_probe_queue.go b/internal/service/scanner_probe_queue.go
deleted file mode 100644
index c9e48e8..0000000
--- a/internal/service/scanner_probe_queue.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package service
-
-import (
- "strings"
- "time"
-
- "go.uber.org/zap"
-)
-
-func (s *ScannerService) cloudMediaProbeWorker() {
- for task := range s.cloudMediaProbeQueue {
- s.probeCloudMediaAsync(task)
- }
-}
-
-func (s *ScannerService) queueCloudMediaProbe(typ, ref, path string) bool {
- task, ok := s.newCloudMediaProbeTask(typ, ref, path)
- if !ok || !s.reserveCloudMediaProbe(task, time.Now()) {
- return false
- }
- select {
- case s.cloudMediaProbeQueue <- task:
- return true
- default:
- s.deferCloudMediaProbe(task, cloudMediaProbeQueueFullBackoff)
- s.logCloudMediaProbeQueueFull(task)
- return false
- }
-}
-
-func (s *ScannerService) newCloudMediaProbeTask(typ, ref, path string) (cloudMediaProbeTask, bool) {
- if s == nil || s.storage == nil || s.probe == nil {
- return cloudMediaProbeTask{}, false
- }
- task := cloudMediaProbeTask{
- typ: strings.TrimSpace(typ),
- ref: strings.TrimSpace(ref),
- path: strings.TrimSpace(path),
- }
- return task, task.typ != "" && task.ref != "" && task.path != ""
-}
-
-func (s *ScannerService) reserveCloudMediaProbe(task cloudMediaProbeTask, now time.Time) bool {
- s.cloudMediaProbeMu.Lock()
- defer s.cloudMediaProbeMu.Unlock()
- if until, ok := s.cloudMediaProbeBackoff[task.path]; ok {
- if now.Before(until) {
- return false
- }
- delete(s.cloudMediaProbeBackoff, task.path)
- }
- if _, ok := s.cloudMediaProbing[task.path]; ok {
- return false
- }
- s.cloudMediaProbing[task.path] = struct{}{}
- return true
-}
-
-func (s *ScannerService) deferCloudMediaProbe(task cloudMediaProbeTask, backoff time.Duration) {
- s.cloudMediaProbeMu.Lock()
- defer s.cloudMediaProbeMu.Unlock()
- delete(s.cloudMediaProbing, task.path)
- if s.cloudMediaProbeBackoff == nil {
- s.cloudMediaProbeBackoff = make(map[string]time.Time)
- }
- s.cloudMediaProbeBackoff[task.path] = time.Now().Add(backoff)
-}
-
-func (s *ScannerService) logCloudMediaProbeQueueFull(task cloudMediaProbeTask) {
- if s == nil || s.log == nil {
- return
- }
- now := time.Now()
- s.cloudMediaProbeWarnMu.Lock()
- shouldWarn := now.Sub(s.cloudMediaProbeLastWarn) >= time.Minute
- if shouldWarn {
- s.cloudMediaProbeLastWarn = now
- }
- s.cloudMediaProbeWarnMu.Unlock()
- if shouldWarn {
- s.log.Warn("cloud media probe queue full; deferring remaining probes (logged at most once per minute)",
- zap.String("provider", task.typ), zap.String("path", task.path))
- return
- }
- s.log.Debug("cloud media probe queue full", zap.String("provider", task.typ), zap.String("path", task.path))
-}
-
-func (s *ScannerService) queueCloudMediaProbeWithBudget(typ, ref, path string, budget *int) bool {
- if budget != nil {
- if *budget <= 0 {
- return false
- }
- // Budget is consumed per attempt, not only per successful enqueue, so a
- // full probe queue cannot generate unbounded repeated attempts/logging.
- *budget--
- }
- return s.queueCloudMediaProbe(typ, ref, path)
-}
diff --git a/internal/service/scanner_probe_queue_test.go b/internal/service/scanner_probe_queue_test.go
deleted file mode 100644
index 467f62f..0000000
--- a/internal/service/scanner_probe_queue_test.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package service
-
-import (
- "testing"
- "time"
-
- "go.uber.org/zap"
-)
-
-func newProbeQueueTestScanner(capacity int) *ScannerService {
- return &ScannerService{
- log: zap.NewNop(),
- storage: &StorageConfigService{},
- probe: &FFprobeService{},
- cloudMediaProbeQueue: make(chan cloudMediaProbeTask, capacity),
- cloudMediaProbing: make(map[string]struct{}),
- cloudMediaProbeBackoff: make(map[string]time.Time),
- }
-}
-
-func TestQueueCloudMediaProbeTrimsTaskAndRejectsDuplicate(t *testing.T) {
- scanner := newProbeQueueTestScanner(1)
- if !scanner.queueCloudMediaProbe(" openlist ", " /Movies/a.mkv ", " cloud://openlist/Movies/a.mkv ") {
- t.Fatal("first cloud probe should enqueue")
- }
- if scanner.queueCloudMediaProbe("openlist", "/Movies/a.mkv", "cloud://openlist/Movies/a.mkv") {
- t.Fatal("duplicate cloud probe should be rejected while in flight")
- }
-
- task := <-scanner.cloudMediaProbeQueue
- if task.typ != "openlist" || task.ref != "/Movies/a.mkv" || task.path != "cloud://openlist/Movies/a.mkv" {
- t.Fatalf("task was not normalized: %#v", task)
- }
-}
-
-func TestQueueCloudMediaProbeFullQueueBacksOffAndReleases(t *testing.T) {
- scanner := newProbeQueueTestScanner(0)
- if scanner.queueCloudMediaProbe("openlist", "/Movies/a.mkv", "cloud://openlist/Movies/a.mkv") {
- t.Fatal("unbuffered queue without receiver should reject enqueue")
- }
-
- scanner.cloudMediaProbeMu.Lock()
- _, probing := scanner.cloudMediaProbing["cloud://openlist/Movies/a.mkv"]
- until, backedOff := scanner.cloudMediaProbeBackoff["cloud://openlist/Movies/a.mkv"]
- scanner.cloudMediaProbeMu.Unlock()
- if probing {
- t.Fatal("queue-full path should release in-flight marker")
- }
- if !backedOff || !until.After(time.Now()) {
- t.Fatalf("queue-full path should receive future backoff, got %v", until)
- }
- if scanner.queueCloudMediaProbe("openlist", "/Movies/a.mkv", "cloud://openlist/Movies/a.mkv") {
- t.Fatal("backed-off path should not be retried immediately")
- }
-}
-
-func TestQueueCloudMediaProbeBudgetConsumesAttempts(t *testing.T) {
- scanner := newProbeQueueTestScanner(0)
- budget := 1
- if scanner.queueCloudMediaProbeWithBudget("openlist", "/Movies/a.mkv", "cloud://openlist/Movies/a.mkv", &budget) {
- t.Fatal("unbuffered queue without receiver should reject enqueue")
- }
- if budget != 0 {
- t.Fatalf("budget = %d, want 0 after attempted enqueue", budget)
- }
- if scanner.queueCloudMediaProbeWithBudget("openlist", "/Movies/b.mkv", "cloud://openlist/Movies/b.mkv", &budget) {
- t.Fatal("zero budget should prevent enqueue")
- }
-}
diff --git a/internal/service/scanner_scan.go b/internal/service/scanner_scan.go
index 84ec7e3..010bee9 100644
--- a/internal/service/scanner_scan.go
+++ b/internal/service/scanner_scan.go
@@ -32,9 +32,6 @@ func (s *ScannerService) ScanLibraryRoot(ctx context.Context, libraryID, rootID
if root == nil {
return nil, errors.New("library root not found")
}
- if mount, ok := ParseCloudLibraryMount(root.Path); ok {
- return s.scanCloudLibraryRoot(ctx, lib, root, mount, true)
- }
return s.scanLocalLibraryRoot(ctx, lib, root, true)
}
@@ -74,9 +71,6 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
if lib == nil {
return nil, errors.New("library not found")
}
- if mount, ok := ParseCloudLibraryMount(lib.Path); ok {
- return s.scanMountedCloudLibrary(ctx, lib, mount, autoScrape)
- }
res := &ScanResult{LibraryID: lib.ID}
writeBatch := newLocalMediaWriteBatch(s, ctx, res, 100)
existingMedia, err := s.existingLocalMediaSnapshot(ctx, lib.ID)
@@ -216,9 +210,7 @@ func (s *ScannerService) finishLocalLibraryScan(ctx context.Context, lib *model.
"error_count": res.ErrorCount,
"errors": res.Errors,
})
- s.notifyScanFinished(lib, res, nil, false)
s.invalidateMediaCache(ctx)
- s.maybeGenerateSTRMAfterScan(lib.ID)
if scanHasImportChanges(res) && autoScrape && s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) {
s.startAutoScrape(ctx, lib.ID)
diff --git a/internal/service/scanner_strm.go b/internal/service/scanner_strm.go
index 186b980..89371f1 100644
--- a/internal/service/scanner_strm.go
+++ b/internal/service/scanner_strm.go
@@ -1,40 +1,11 @@
package service
import (
- "context"
"net/url"
"os"
- "path/filepath"
"strings"
-
- "go.uber.org/zap"
)
-func (s *ScannerService) resolveCloudSTRMTarget(ctx context.Context, typ, ref string) (string, error) {
- if s.storage == nil {
- return "", nil
- }
- content, err := s.storage.CloudReadText(ctx, typ, ref, 64<<10)
- if err != nil {
- return "", err
- }
- for _, line := range strings.Split(content, "\n") {
- candidate := strings.TrimSpace(strings.TrimPrefix(line, "\ufeff"))
- if candidate == "" || strings.HasPrefix(candidate, "#") {
- continue
- }
- u, err := url.Parse(candidate)
- if err != nil {
- continue
- }
- switch strings.ToLower(u.Scheme) {
- case "http", "https", "webdav", "davs", "alist", "alists", "openlist", "openlists":
- return candidate, nil
- }
- }
- return "", nil
-}
-
func readLocalSTRMTarget(path string) (string, error) {
data, err := os.ReadFile(path) // #nosec G304 -- path is a discovered .strm file under the configured library root.
if err != nil {
@@ -59,56 +30,3 @@ func readLocalSTRMTarget(path string) (string, error) {
}
return "", nil
}
-
-func (s *ScannerService) maybeGenerateSTRMAfterScan(libraryID string) {
- if s == nil || s.repo == nil || s.repo.Setting == nil {
- return
- }
- value, err := s.repo.Setting.Get(context.Background(), "strm.auto_generate_enabled")
- if err != nil || !parseBoolSetting(value, false) {
- return
- }
- go func() {
- ctx := context.Background()
- strmSvc := NewSTRMService(s.log, s.repo, s.cfg)
- opts := GenerateSTRMOptions{
- LibraryID: libraryID,
- Enabled: true,
- IncludeLocal: true,
- Overwrite: true,
- PreserveTree: s.autoSTRMPreserveTree(ctx),
- SkipSettingsSave: true,
- }
- if outDir, scope := s.autoSTRMOutputDir(ctx); outDir != "" {
- opts.OutputDir = outDir
- if scope == "all" {
- if lib, err := s.repo.Library.FindByID(ctx, libraryID); err == nil && lib != nil {
- opts.OutputDir = filepath.Join(outDir, strmLibraryOutputSubdir(*lib))
- }
- }
- }
- if _, err := strmSvc.GenerateForLibrary(ctx, opts); err != nil && s.log != nil {
- s.log.Warn("auto generate strm failed", zap.String("library_id", libraryID), zap.Error(err))
- }
- }()
-}
-
-func (s *ScannerService) autoSTRMOutputDir(ctx context.Context) (string, string) {
- if s == nil || s.repo == nil || s.repo.Setting == nil {
- return "", ""
- }
- outDir, err := s.repo.Setting.Get(ctx, "strm.output_dir")
- if err != nil {
- return "", ""
- }
- scope, _ := s.repo.Setting.Get(ctx, "strm.output_scope")
- return resolveMappedDestinationPath(strings.TrimSpace(outDir)), strings.ToLower(strings.TrimSpace(scope))
-}
-
-func (s *ScannerService) autoSTRMPreserveTree(ctx context.Context) bool {
- if s == nil || s.repo == nil || s.repo.Setting == nil {
- return false
- }
- value, err := s.repo.Setting.Get(ctx, "strm.preserve_tree")
- return err == nil && parseBoolSetting(value, false)
-}
diff --git a/internal/service/scanner_strm_test.go b/internal/service/scanner_strm_test.go
deleted file mode 100644
index 2ccc9a4..0000000
--- a/internal/service/scanner_strm_test.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package service
-
-import (
- "os"
- "path/filepath"
- "testing"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/config"
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestAutoGenerateSTRMAfterScanUsesAllScopeRoot(t *testing.T) {
- db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{})
- repos := repository.New(db)
- outDir := t.TempDir()
- if err := repos.Setting.Set(t.Context(), "strm.auto_generate_enabled", "true"); err != nil {
- t.Fatal(err)
- }
- if err := repos.Setting.Set(t.Context(), "strm.output_dir", outDir); err != nil {
- t.Fatal(err)
- }
- if err := repos.Setting.Set(t.Context(), "strm.output_scope", "all"); err != nil {
- t.Fatal(err)
- }
- lib := model.Library{
- Base: model.Base{ID: "tv-lib"},
- Name: "OpenList 欧美剧",
- Path: BuildCloudLibraryPath("openlist", "/电视剧/欧美剧", "/电视剧/欧美剧"),
- Type: "tv",
- Enabled: true,
- }
- if err := repos.Library.Create(t.Context(), &lib); err != nil {
- t.Fatal(err)
- }
- media := model.Media{
- Base: model.Base{ID: "show-1"},
- LibraryID: lib.ID,
- Title: "第一集",
- Path: "cloud://openlist/电视剧/欧美剧/Show/S01E01.mkv",
- STRMURL: "/api/cloud/play/openlist?ref=show",
- SeasonNum: 1,
- EpisodeNum: 1,
- }
- if err := repos.DB.Create(&media).Error; err != nil {
- t.Fatal(err)
- }
-
- scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
- scanner.maybeGenerateSTRMAfterScan(lib.ID)
-
- want := filepath.Join(outDir, "电视剧", "欧美剧", "Show", "Season 01", "Show - S01E01.strm")
- waitForFile(t, want)
- assertFileContains(t, want, "/api/stream/show-1")
- if _, err := os.Stat(filepath.Join(outDir, "电视剧", "欧美剧", "电视剧", "欧美剧")); !os.IsNotExist(err) {
- t.Fatalf("auto STRM output was nested twice")
- }
-}
-
-func waitForFile(t *testing.T, path string) {
- t.Helper()
- deadline := time.Now().Add(3 * time.Second)
- for {
- if _, err := os.Stat(path); err == nil {
- return
- }
- if time.Now().After(deadline) {
- t.Fatalf("timed out waiting for %s", path)
- }
- time.Sleep(20 * time.Millisecond)
- }
-}
diff --git a/internal/service/scheduler.go b/internal/service/scheduler.go
index 1ee11fa..9c2f66c 100644
--- a/internal/service/scheduler.go
+++ b/internal/service/scheduler.go
@@ -5,8 +5,6 @@
//
// library_scan every 24 h — optional full re-scan for local libraries;
// filesystem watchers handle normal changes.
-// cloud_sync low frequency — optional cloud library sync.
-// cloud_upload low frequency — optional local-to-cloud metadata upload.
// organize_source opt-in — organize the configured staging folder.
// transcode_cleanup every 24 h — purge HLS transcode artefacts
// older than 24 h.
@@ -38,7 +36,6 @@ type SchedulerService struct {
transcoder *TranscoderService
organizer *OrganizerService
organizePipeline *OrganizePipelineService
- storageCfg *StorageConfigService
hub *Hub
tasks *TaskTrackerService
cacheDir string
@@ -75,10 +72,7 @@ type scheduledJob struct {
type schedulerManualRunKey struct{}
-const (
- localLastPeriodicScanDateKey = "scan.last_periodic_date"
- cloudAutoSyncCompletedDateForm = "2006-01-02"
-)
+const localLastPeriodicScanDateKey = "scan.last_periodic_date"
// NewSchedulerService is the constructor.
func NewSchedulerService(
@@ -87,7 +81,6 @@ func NewSchedulerService(
scanner *ScannerService,
transcoder *TranscoderService,
organizer *OrganizerService,
- storageCfg *StorageConfigService,
hub *Hub,
cacheDir string,
) *SchedulerService {
@@ -97,7 +90,6 @@ func NewSchedulerService(
scanner: scanner,
transcoder: transcoder,
organizer: organizer,
- storageCfg: storageCfg,
hub: hub,
cacheDir: cacheDir,
now: time.Now,
@@ -113,16 +105,6 @@ func (s *SchedulerService) Start(ctx context.Context) {
interval: 24 * time.Hour,
run: s.jobScanLibraries,
},
- {
- name: "cloud_sync",
- interval: s.cloudSyncInterval(ctx),
- run: s.jobSyncCloudLibraries,
- },
- {
- name: "cloud_upload",
- interval: s.cloudUploadInterval(ctx),
- run: s.jobUploadLocalToCloud,
- },
{
name: "organize_source",
interval: s.organizeSourceInterval(ctx),
diff --git a/internal/service/scheduler_cloud.go b/internal/service/scheduler_cloud.go
deleted file mode 100644
index 2bd643b..0000000
--- a/internal/service/scheduler_cloud.go
+++ /dev/null
@@ -1,201 +0,0 @@
-package service
-
-import (
- "context"
- "strconv"
- "strings"
- "time"
-
- "go.uber.org/zap"
-)
-
-const (
- cloudAutoSyncEnabledKey = "cloud.auto_sync_enabled"
- cloudSyncIntervalSecondsKey = "cloud.sync_interval_seconds"
- cloudLastAutoSyncDateKey = "cloud.last_auto_sync_date"
- cloudAutoSyncWindowStartHour = 23
- cloudAutoSyncWindowEndHour = 5
-)
-
-// jobUploadLocalToCloud copies local media files into the configured external
-// storage backend. It is opt-in and never deletes the local source files.
-func (s *SchedulerService) jobUploadLocalToCloud(ctx context.Context) error {
- manual, _ := ctx.Value(schedulerManualRunKey{}).(bool)
- if s.storageCfg == nil || (!manual && !s.autoCloudUploadEnabled(ctx)) {
- return nil
- }
- input := s.cloudUploadInput(ctx)
- if strings.TrimSpace(input.Type) == "" || strings.TrimSpace(input.SourcePath) == "" {
- return nil
- }
- res, err := s.storageCfg.UploadLocal(ctx, input)
- if s.log != nil && res != nil {
- s.log.Info("cloud upload finished",
- zap.String("type", input.Type),
- zap.String("source", res.SourcePath),
- zap.String("dest", res.DestPath),
- zap.Int("uploaded", res.Uploaded),
- zap.Int("skipped", res.Skipped),
- zap.Int64("bytes", res.Bytes),
- zap.Int("errors", len(res.Errors)),
- )
- }
- return err
-}
-
-func (s *SchedulerService) cloudUploadInput(ctx context.Context) CloudUploadInput {
- get := func(key string) string {
- if s.repo == nil || s.repo.Setting == nil {
- return ""
- }
- v, _ := s.repo.Setting.Get(ctx, key)
- return strings.TrimSpace(v)
- }
- return CloudUploadInput{
- Type: get(CloudUploadProviderKey),
- SourcePath: get(CloudUploadSourceDirKey),
- DestPath: get(CloudUploadDestPathKey),
- Recursive: parseBoolSetting(get(CloudUploadRecursiveKey), true),
- IncludeSidecars: parseBoolSetting(get(CloudUploadSidecarsKey), true),
- Overwrite: parseBoolSetting(get(CloudUploadOverwriteKey), false),
- TransferMode: get(CloudUploadTransferModeKey),
- }
-}
-
-func (s *SchedulerService) autoCloudUploadEnabled(ctx context.Context) bool {
- if s.repo == nil || s.repo.Setting == nil {
- return false
- }
- v, err := s.repo.Setting.Get(ctx, CloudUploadAutoEnabledKey)
- if err != nil {
- return false
- }
- return parseBoolSetting(v, false)
-}
-
-func (s *SchedulerService) cloudUploadInterval(ctx context.Context) time.Duration {
- const fallback = time.Hour
- if s.repo == nil || s.repo.Setting == nil {
- return fallback
- }
- v, err := s.repo.Setting.Get(ctx, CloudUploadIntervalSecondsKey)
- if err != nil {
- return fallback
- }
- seconds, err := strconv.Atoi(strings.TrimSpace(v))
- if err != nil || seconds <= 0 {
- return fallback
- }
- if seconds < 300 {
- seconds = 300
- }
- return time.Duration(seconds) * time.Second
-}
-
-// jobSyncCloudLibraries keeps mounted cloud:// libraries refreshed without
-// enabling full disk scans. It imports remote cloud files as STRM-backed media
-// rows; the actual bytes stay on the provider and playback continues through
-// /api/cloud/play 302/proxy.
-func (s *SchedulerService) jobSyncCloudLibraries(ctx context.Context) error {
- manual, _ := ctx.Value(schedulerManualRunKey{}).(bool)
- if s.scanner == nil || (!manual && !s.autoCloudSyncDue(ctx, s.currentTime())) {
- return nil
- }
- libs, err := s.repo.Library.List(ctx)
- if err != nil {
- return err
- }
- libs = FilterScannableCloudLibraries(ctx, s.repo, libs)
- var firstErr error
- for _, l := range libs {
- if !l.Enabled {
- continue
- }
- if _, ok := ParseCloudLibraryMount(l.Path); !ok {
- continue
- }
- if _, err := s.scanner.ScanLibraryWithoutAutoScrape(ctx, l.ID); err != nil {
- s.log.Warn("cloud sync failed", zap.String("library", l.ID), zap.Error(err))
- if firstErr == nil {
- firstErr = err
- }
- }
- }
- if firstErr != nil {
- return firstErr
- }
- if !manual {
- _ = s.markCloudAutoSyncCompleted(ctx, s.currentTime())
- }
- return nil
-}
-
-func (s *SchedulerService) autoCloudSyncEnabled(ctx context.Context) bool {
- if s.repo == nil || s.repo.Setting == nil {
- return false
- }
- v, err := s.repo.Setting.Get(ctx, cloudAutoSyncEnabledKey)
- if err != nil {
- return false
- }
- return parseBoolSetting(v, false)
-}
-
-func (s *SchedulerService) autoCloudSyncDue(ctx context.Context, now time.Time) bool {
- if !s.autoCloudSyncEnabled(ctx) || !cloudAutoSyncInWindow(now) {
- return false
- }
- if s.repo == nil || s.repo.Setting == nil {
- return true
- }
- last, err := s.repo.Setting.Get(ctx, cloudLastAutoSyncDateKey)
- if err != nil {
- return true
- }
- return strings.TrimSpace(last) != cloudAutoSyncWindowDate(now)
-}
-
-func cloudAutoSyncInWindow(now time.Time) bool {
- hour := now.In(time.Local).Hour()
- if cloudAutoSyncWindowStartHour == cloudAutoSyncWindowEndHour {
- return true
- }
- if cloudAutoSyncWindowStartHour < cloudAutoSyncWindowEndHour {
- return hour >= cloudAutoSyncWindowStartHour && hour < cloudAutoSyncWindowEndHour
- }
- return hour >= cloudAutoSyncWindowStartHour || hour < cloudAutoSyncWindowEndHour
-}
-
-func cloudAutoSyncWindowDate(now time.Time) string {
- local := now.In(time.Local)
- if cloudAutoSyncWindowStartHour > cloudAutoSyncWindowEndHour && local.Hour() < cloudAutoSyncWindowEndHour {
- local = local.AddDate(0, 0, -1)
- }
- return local.Format(cloudAutoSyncCompletedDateForm)
-}
-
-func (s *SchedulerService) markCloudAutoSyncCompleted(ctx context.Context, now time.Time) error {
- if s.repo == nil || s.repo.Setting == nil {
- return nil
- }
- return s.repo.Setting.Set(ctx, cloudLastAutoSyncDateKey, cloudAutoSyncWindowDate(now))
-}
-
-func (s *SchedulerService) cloudSyncInterval(ctx context.Context) time.Duration {
- const fallback = 30 * time.Minute
- if s.repo == nil || s.repo.Setting == nil {
- return fallback
- }
- v, err := s.repo.Setting.Get(ctx, cloudSyncIntervalSecondsKey)
- if err != nil {
- return fallback
- }
- seconds, err := strconv.Atoi(strings.TrimSpace(v))
- if err != nil || seconds <= 0 {
- return fallback
- }
- if seconds < 300 {
- seconds = 300
- }
- return time.Duration(seconds) * time.Second
-}
diff --git a/internal/service/scheduler_cloud_sync_test.go b/internal/service/scheduler_cloud_sync_test.go
deleted file mode 100644
index ef5ecca..0000000
--- a/internal/service/scheduler_cloud_sync_test.go
+++ /dev/null
@@ -1,232 +0,0 @@
-package service
-
-import (
- "sync/atomic"
- "testing"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/config"
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestSchedulerCloudSyncImportsMountedCloudLibrary(t *testing.T) {
- upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) {
- if path != "/" {
- t.Fatalf("unexpected openlist path %q", path)
- }
- return []openListTestEntry{{Name: "Cloud.Movie.2026.mkv", Size: 1024}}, 1
- })
- 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{
- "server": upstream.URL,
- "token": "openlist-token",
- },
- }); err != nil {
- t.Fatal(err)
- }
- local := model.Library{Name: "电影", Path: "/media/电影", Type: "movie", Enabled: true}
- if err := repos.Library.Create(t.Context(), &local); err != nil {
- t.Fatal(err)
- }
- lib := model.Library{Name: "OpenList · 电影", Path: "cloud://openlist", Type: "movie", Enabled: true}
- if err := repos.Library.Create(t.Context(), &lib); err != nil {
- t.Fatal(err)
- }
- if err := repos.Setting.Set(t.Context(), "cloud.auto_sync_enabled", "true"); err != nil {
- t.Fatal(err)
- }
- scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil)
- scanner.SetStorageConfig(storage)
- scheduler := NewSchedulerService(log, repos, scanner, nil, nil, storage, NewHub(log), "")
- scheduler.now = func() time.Time { return fixedNightlySyncTime() }
-
- if err := scheduler.jobSyncCloudLibraries(t.Context()); err != nil {
- t.Fatalf("cloud sync: %v", err)
- }
- var media model.Media
- if err := repos.DB.First(&media, "path = ?", "cloud://openlist/Cloud.Movie.2026.mkv").Error; err != nil {
- t.Fatalf("cloud media not imported: %v", err)
- }
- if media.STRMURL != "/api/cloud/play/openlist?ref=%2FCloud.Movie.2026.mkv" {
- t.Fatalf("strm url = %q", media.STRMURL)
- }
-}
-
-func TestSchedulerCloudSyncRunsOnlyOnceInsideNightlyWindow(t *testing.T) {
- var requests atomic.Int32
- upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) {
- requests.Add(1)
- if path != "/" {
- t.Fatalf("unexpected openlist path %q", path)
- }
- return []openListTestEntry{{Name: "Nightly.Cloud.Movie.2026.mkv", Size: 1024}}, 1
- })
- 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{
- "server": upstream.URL,
- "token": "openlist-token",
- },
- }); err != nil {
- t.Fatal(err)
- }
- lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true}
- if err := repos.Library.Create(t.Context(), &lib); err != nil {
- t.Fatal(err)
- }
- if err := repos.Setting.Set(t.Context(), cloudAutoSyncEnabledKey, "true"); err != nil {
- t.Fatal(err)
- }
- scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil)
- scanner.SetStorageConfig(storage)
- scheduler := NewSchedulerService(log, repos, scanner, nil, nil, storage, NewHub(log), "")
-
- scheduler.now = func() time.Time {
- return time.Date(2026, 6, 11, 22, 30, 0, 0, time.Local)
- }
- if err := scheduler.jobSyncCloudLibraries(t.Context()); err != nil {
- t.Fatalf("cloud sync outside window: %v", err)
- }
- if got := requests.Load(); got != 0 {
- t.Fatalf("outside nightly window made %d requests, want 0", got)
- }
-
- scheduler.now = func() time.Time { return fixedNightlySyncTime() }
- if err := scheduler.jobSyncCloudLibraries(t.Context()); err != nil {
- t.Fatalf("cloud sync inside window: %v", err)
- }
- if got := requests.Load(); got != 1 {
- t.Fatalf("inside nightly window requests = %d, want 1", got)
- }
- if got := countMedia(t, repos); got != 1 {
- t.Fatalf("media count = %d, want 1", got)
- }
-
- scheduler.now = func() time.Time {
- return time.Date(2026, 6, 12, 4, 15, 0, 0, time.Local)
- }
- if !cloudAutoSyncInWindow(scheduler.now()) {
- t.Fatalf("04:15 should still be inside overnight cloud sync window")
- }
- if got := cloudAutoSyncWindowDate(scheduler.now()); got != "2026-06-11" {
- t.Fatalf("04:15 should belong to previous nightly window, got %s", got)
- }
- if err := scheduler.jobSyncCloudLibraries(t.Context()); err != nil {
- t.Fatalf("second cloud sync same overnight window: %v", err)
- }
- if got := requests.Load(); got != 1 {
- t.Fatalf("same overnight auto sync should not rerun, requests = %d", got)
- }
-
- scheduler.now = func() time.Time {
- return time.Date(2026, 6, 12, 5, 0, 0, 0, time.Local)
- }
- if cloudAutoSyncInWindow(scheduler.now()) {
- t.Fatalf("05:00 should be outside overnight cloud sync window")
- }
-}
-
-func TestSchedulerRunNowCloudSyncBypassesNightlyWindow(t *testing.T) {
- var requests atomic.Int32
- upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) {
- requests.Add(1)
- return []openListTestEntry{{Name: "Manual.Cloud.Movie.2026.mkv", Size: 1024}}, 1
- })
- 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{
- "server": upstream.URL,
- "token": "openlist-token",
- },
- }); err != nil {
- t.Fatal(err)
- }
- lib := model.Library{Name: "OpenList", Path: "cloud://openlist", 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)
- scheduler := NewSchedulerService(log, repos, scanner, nil, nil, storage, NewHub(log), "")
- scheduler.now = func() time.Time {
- return time.Date(2026, 6, 11, 10, 0, 0, 0, time.Local)
- }
- scheduler.jobs = []*scheduledJob{{
- name: "cloud_sync",
- interval: time.Minute,
- run: scheduler.jobSyncCloudLibraries,
- }}
-
- if err := scheduler.RunNow(t.Context(), "cloud_sync"); err != nil {
- t.Fatalf("manual cloud sync: %v", err)
- }
- if got := requests.Load(); got != 1 {
- t.Fatalf("manual cloud sync requests = %d, want 1", got)
- }
-}
-
-func TestSchedulerCloudSyncDisabledByDefault(t *testing.T) {
- var requests atomic.Int32
- upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) {
- requests.Add(1)
- return []openListTestEntry{{Name: "Cloud.Movie.2026.mkv", Size: 1024}}, 1
- })
- 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{
- "server": upstream.URL,
- "token": "openlist-token",
- },
- }); err != nil {
- t.Fatal(err)
- }
- lib := model.Library{Name: "OpenList", Path: "cloud://openlist", 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)
- scheduler := NewSchedulerService(log, repos, scanner, nil, nil, storage, NewHub(log), "")
-
- if err := scheduler.jobSyncCloudLibraries(t.Context()); err != nil {
- t.Fatalf("disabled cloud sync should be a no-op: %v", err)
- }
- if got := requests.Load(); got != 0 {
- t.Fatalf("cloud sync made %d upstream requests while disabled by default", got)
- }
- if got := countMedia(t, repos); got != 0 {
- t.Fatalf("media count = %d, want 0 while cloud sync disabled by default", got)
- }
-}
-
-func fixedNightlySyncTime() time.Time {
- return time.Date(2026, 6, 11, 23, 30, 0, 0, time.Local)
-}
diff --git a/internal/service/scheduler_local_jobs.go b/internal/service/scheduler_local_jobs.go
index 5f17a3a..e93af0a 100644
--- a/internal/service/scheduler_local_jobs.go
+++ b/internal/service/scheduler_local_jobs.go
@@ -31,13 +31,6 @@ func (s *SchedulerService) jobScanLibraries(ctx context.Context) error {
if !l.Enabled {
continue
}
- if _, ok := ParseCloudLibraryMount(l.Path); ok {
- // 云盘库由 cloud_sync 任务在夜间窗口低频同步;周期性整库
- // 重扫只面向本地磁盘库。否则十几个云盘库每小时全量遍历
- // 会把 CPU/网络长期吃满,还会占住唯一的云扫描槽位,让
- // 手动扫描看起来一直"卡死"在排队。
- continue
- }
if _, err := s.scanner.ScanLibrary(ctx, l.ID); err != nil {
s.log.Warn("scheduled scan failed",
zap.String("library", l.ID), zap.Error(err))
@@ -74,14 +67,14 @@ func (s *SchedulerService) periodicScanDue(ctx context.Context, now time.Time) b
if err != nil {
return true
}
- return strings.TrimSpace(last) != now.In(time.Local).Format(cloudAutoSyncCompletedDateForm)
+ return strings.TrimSpace(last) != now.In(time.Local).Format("2006-01-02")
}
func (s *SchedulerService) markPeriodicScanCompleted(ctx context.Context, now time.Time) error {
if s.repo == nil || s.repo.Setting == nil {
return nil
}
- return s.repo.Setting.Set(ctx, localLastPeriodicScanDateKey, now.In(time.Local).Format(cloudAutoSyncCompletedDateForm))
+ return s.repo.Setting.Set(ctx, localLastPeriodicScanDateKey, now.In(time.Local).Format("2006-01-02"))
}
// jobOrganizeSource periodically organizes the configured staging/download
diff --git a/internal/service/scheduler_organize_test.go b/internal/service/scheduler_organize_test.go
index cf39a56..1c1fea9 100644
--- a/internal/service/scheduler_organize_test.go
+++ b/internal/service/scheduler_organize_test.go
@@ -31,7 +31,7 @@ func TestSchedulerOrganizeSourceDisabledByDefault(t *testing.T) {
}
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
- scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, nil, NewHub(zap.NewNop()), "")
+ scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, NewHub(zap.NewNop()), "")
if err := scheduler.jobOrganizeSource(t.Context()); err != nil {
t.Fatalf("disabled organize source job should be a no-op: %v", err)
}
@@ -61,7 +61,7 @@ func TestSchedulerOrganizeSourceUsesConfiguredSourceAndDestination(t *testing.T)
}
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
- scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, nil, NewHub(zap.NewNop()), "")
+ scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, NewHub(zap.NewNop()), "")
if err := scheduler.jobOrganizeSource(t.Context()); err != nil {
t.Fatalf("organize source job: %v", err)
}
@@ -90,7 +90,7 @@ func TestSchedulerRunNowOrganizeSourceBypassesDisabledSwitch(t *testing.T) {
}
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
- scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, nil, NewHub(zap.NewNop()), "")
+ scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, NewHub(zap.NewNop()), "")
scheduler.jobs = []*scheduledJob{{
name: "organize_source",
interval: time.Minute,
@@ -136,7 +136,7 @@ func TestSchedulerOrganizeSourceSyncsVisibilityWhenTargetAlreadyExists(t *testin
t.Fatalf("seed organize destination: %v", err)
}
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
- scheduler := NewSchedulerService(zap.NewNop(), repos, scanner, nil, organizer, nil, NewHub(zap.NewNop()), "")
+ scheduler := NewSchedulerService(zap.NewNop(), repos, scanner, nil, organizer, NewHub(zap.NewNop()), "")
scheduler.jobs = []*scheduledJob{{
name: "organize_source",
interval: time.Minute,
diff --git a/internal/service/scheduler_scan_test.go b/internal/service/scheduler_scan_test.go
index 4f65113..d8686dd 100644
--- a/internal/service/scheduler_scan_test.go
+++ b/internal/service/scheduler_scan_test.go
@@ -28,7 +28,7 @@ func TestSchedulerPeriodicLocalScanRunsAtMostOncePerDay(t *testing.T) {
}
log := zap.NewNop()
scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil)
- scheduler := NewSchedulerService(log, repos, scanner, nil, nil, nil, NewHub(log), "")
+ scheduler := NewSchedulerService(log, repos, scanner, nil, nil, NewHub(log), "")
scheduler.now = func() time.Time {
return time.Date(2026, 6, 20, 10, 0, 0, 0, time.Local)
}
@@ -75,7 +75,7 @@ func TestSchedulerManualLocalScanBypassesDailyPeriodicLimit(t *testing.T) {
}
log := zap.NewNop()
scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil)
- scheduler := NewSchedulerService(log, repos, scanner, nil, nil, nil, NewHub(log), "")
+ scheduler := NewSchedulerService(log, repos, scanner, nil, nil, NewHub(log), "")
scheduler.now = func() time.Time {
return time.Date(2026, 6, 20, 10, 0, 0, 0, time.Local)
}
diff --git a/internal/service/scheduler_test.go b/internal/service/scheduler_test.go
index 66f2106..6c5de3c 100644
--- a/internal/service/scheduler_test.go
+++ b/internal/service/scheduler_test.go
@@ -12,7 +12,7 @@ import (
)
func TestSchedulerRunNowAsyncSurvivesCallerCancellation(t *testing.T) {
- scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, nil, "")
+ scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, "")
started := make(chan struct{})
release := make(chan struct{})
finished := make(chan struct{})
@@ -66,7 +66,7 @@ func TestSchedulerRunNowAsyncSurvivesCallerCancellation(t *testing.T) {
}
func TestSchedulerRunNowAsyncRejectsDuplicateRun(t *testing.T) {
- scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, nil, "")
+ scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, "")
started := make(chan struct{})
release := make(chan struct{})
scheduler.jobs = []*scheduledJob{{
@@ -90,7 +90,7 @@ func TestSchedulerRunNowAsyncRejectsDuplicateRun(t *testing.T) {
}
func TestSchedulerStartDoesNotRegisterSubscriptionPullJob(t *testing.T) {
- scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, nil, "")
+ scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, "")
ctx, cancel := context.WithCancel(t.Context())
cancel()
scheduler.Start(ctx)
@@ -104,7 +104,7 @@ func TestSchedulerStartDoesNotRegisterSubscriptionPullJob(t *testing.T) {
}
func TestSchedulerLoopWaitsIntervalAfterSlowRun(t *testing.T) {
- scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, nil, "")
+ scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, "")
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
diff --git a/internal/service/scraper_library.go b/internal/service/scraper_library.go
index 5d71af3..2b4bab3 100644
--- a/internal/service/scraper_library.go
+++ b/internal/service/scraper_library.go
@@ -109,7 +109,6 @@ func (s *ScraperService) EnrichLibraryDetailedWithOptions(ctx context.Context, l
}
if err := s.EnrichOneWithOptions(ctx, &rows[i], runOptions); err != nil {
s.log.Warn("enrich failed", zap.String("media", rows[i].ID), zap.Error(err))
- s.notifyScrapeFailed(rows[i], err)
result.Failed++
continue
}
@@ -194,22 +193,6 @@ func shouldScrapeCandidateRow(media model.Media) bool {
return organizeMediaTitleLooksLikeRelease(media.Title)
}
-func (s *ScraperService) notifyScrapeFailed(m model.Media, err error) {
- if s == nil || s.notify == nil || err == nil {
- return
- }
- body := strings.TrimSpace(m.Title)
- if body == "" {
- body = m.Path
- }
- body = "媒体:" + body + "\n错误:" + err.Error()
- go func() {
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- s.notify.Broadcast(ctx, "MediaStationGo 刮削失败", body, EventScrapeFailed)
- }()
-}
-
func (s *ScraperService) scrapeDelay(ctx context.Context) time.Duration {
minMS := s.scrapeDelaySetting(ctx, "scrape.delay_min_ms", defaultScrapeDelayMinMS)
maxMS := s.scrapeDelaySetting(ctx, "scrape.delay_max_ms", defaultScrapeDelayMaxMS)
diff --git a/internal/service/scraper_library_test.go b/internal/service/scraper_library_test.go
index 4089dc3..9f7afd4 100644
--- a/internal/service/scraper_library_test.go
+++ b/internal/service/scraper_library_test.go
@@ -116,47 +116,3 @@ func TestScrapeCandidateRowsPrioritizeLibraryArtworkBeforeEpisodes(t *testing.T)
t.Fatalf("scrape order = [%s, %s], want series-level row before episode row", got[0].ID, got[1].ID)
}
}
-
-func TestEnrichLibraryIncludesMergedCloudLibraryMedia(t *testing.T) {
- scraper, repos, closeServer := newTestScraper(t)
- defer closeServer()
-
- local := model.Library{Name: "番剧", Path: t.TempDir(), Type: "tv", Enabled: true}
- cloud := model.Library{
- Name: "OpenList · 番剧",
- Path: BuildCloudLibraryPath("openlist", "/番剧", "/番剧"),
- Type: "tv",
- Enabled: true,
- }
- if err := repos.DB.Create(&local).Error; err != nil {
- t.Fatal(err)
- }
- if err := repos.DB.Create(&cloud).Error; err != nil {
- t.Fatal(err)
- }
- if err := repos.DB.Create(&model.Media{
- LibraryID: cloud.ID,
- Title: "间谍过家家",
- Path: "cloud://openlist/番剧/间谍过家家 - S02E02.mkv",
- SeasonNum: 2,
- EpisodeNum: 2,
- ScrapeStatus: "pending",
- }).Error; err != nil {
- t.Fatal(err)
- }
-
- result, err := scraper.EnrichLibraryDetailed(t.Context(), local.ID, true)
- if err != nil {
- t.Fatal(err)
- }
- if result.Matched != 1 || result.Processed != 1 || result.Candidates != 1 || result.Failed != 0 {
- t.Fatalf("result=%+v, want merged cloud media to be scraped once", result)
- }
- var got model.Media
- if err := repos.DB.First(&got, "library_id = ?", cloud.ID).Error; err != nil {
- t.Fatal(err)
- }
- if got.ScrapeStatus != "matched" || got.TMDbID != 12345 {
- t.Fatalf("merged cloud media was not enriched: status=%q tmdb=%d", got.ScrapeStatus, got.TMDbID)
- }
-}
diff --git a/internal/service/scraper_query_paths.go b/internal/service/scraper_query_paths.go
index f28ccda..236a6ef 100644
--- a/internal/service/scraper_query_paths.go
+++ b/internal/service/scraper_query_paths.go
@@ -67,6 +67,15 @@ func cleanSlashPath(value string) string {
return strings.TrimRight(value, "/")
}
+func pathBaseSlash(value string) string {
+ value = strings.Trim(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"), "/")
+ if value == "" {
+ return ""
+ }
+ parts := strings.Split(value, "/")
+ return parts[len(parts)-1]
+}
+
func comparableLibraryRoot(libraryRoot string) string {
if info, ok := ParseCloudLibraryMount(libraryRoot); ok {
if strings.TrimSpace(info.DisplayDir) == "" {
diff --git a/internal/service/scraper_service.go b/internal/service/scraper_service.go
index d898a87..e3150d2 100644
--- a/internal/service/scraper_service.go
+++ b/internal/service/scraper_service.go
@@ -22,7 +22,6 @@ type ScraperService struct {
fanart *FanartProvider
adult *AdultProvider
hub *Hub
- notify *NotifyChannelService
cache *RuntimeCacheService
images *ImageProxy
}
@@ -53,12 +52,6 @@ func (s *ScraperService) SetDouban(douban *DoubanProvider) {
s.douban = douban
}
-func (s *ScraperService) SetNotifyChannels(notify *NotifyChannelService) {
- if s != nil {
- s.notify = notify
- }
-}
-
func (s *ScraperService) SetRuntimeCache(cache *RuntimeCacheService) *ScraperService {
if s != nil {
s.cache = cache
diff --git a/internal/service/security_safety_test.go b/internal/service/security_safety_test.go
index 921a36e..c5dede3 100644
--- a/internal/service/security_safety_test.go
+++ b/internal/service/security_safety_test.go
@@ -7,28 +7,6 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/config"
)
-func TestValidateSTRMProxyURLBlocksPrivateTargets(t *testing.T) {
- blocked := []string{
- "http://127.0.0.1/video.mkv",
- "http://192.168.1.2/video.mkv",
- "http://169.254.169.254/latest/meta-data",
- "file:///etc/passwd",
- }
- for _, raw := range blocked {
- if _, err := validateSTRMProxyURL(raw); err == nil {
- t.Fatalf("validateSTRMProxyURL(%q) allowed unsafe target", raw)
- }
- }
-}
-
-func TestValidateSTRMProxyURLAllowsPublicHTTP(t *testing.T) {
- for _, raw := range []string{"https://example.com/video.mkv", "http://8.8.8.8/video.mkv"} {
- if _, err := validateSTRMProxyURL(raw); err != nil {
- t.Fatalf("validateSTRMProxyURL(%q) = %v, want nil", raw, err)
- }
- }
-}
-
func TestBackupFilePathRejectsTraversal(t *testing.T) {
svc := &BackupService{cfg: &config.Config{}}
svc.cfg.App.DataDir = t.TempDir()
diff --git a/internal/service/service.go b/internal/service/service.go
index 9a298f2..c89cfff 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -31,46 +31,30 @@ type Container struct {
Bangumi *BangumiProvider
TheTVDB *TheTVDBProvider
Fanart *FanartProvider
- Scraper *ScraperService
- Discover *DiscoverService
- Playback *PlaybackService
+ Scraper *ScraperService
+ Playback *PlaybackService
ImageProxy *ImageProxy
Watcher *WatcherService
- Downloads *DownloadService
- Subscription *SubscriptionService
Subtitle *SubtitleService
- Stats *StatsService
Profile *ProfileService
Audit *AuditService
NFO *NFOService
- AI *AIService
APIConfig *APIConfigService
Crypto *CryptoService
- Duplicate *DuplicateService
FileManager *FileManagerService
DLNA *DLNAService
Scheduler *SchedulerService
Storage *StorageService
Emby *EmbyService
Backup *BackupService
- Notifier *NotifierService
- NotifyChannels *NotifyChannelService
- TelegramBot *TelegramBotService
PlayProfiles *PlayProfileService
Permissions *PermissionService
- StorageCfg *StorageConfigService
- STRM *STRMService
SystemUpdate *SystemUpdateService
- DownloadClients *DownloadClientService
- Assistant *AssistantService
Organizer *OrganizerService
OrganizePipeline *OrganizePipelineService
Douban *DoubanProvider
Token *TokenService
ApiConfig *ApiConfigService
- DownloadMgr *DownloadManager
- Notify *NotifyService
- Site *SiteService
Device *DeviceService
Cache *RuntimeCacheService
Sessions *SessionTrackerService
@@ -96,14 +80,9 @@ func (c *Container) Boot() {
if err := c.NormalizeLocalLibraryPaths(c.stopCtx); err != nil {
c.Log.Warn("normalize local library paths failed", zap.Error(err))
}
- if err := c.NormalizeCloudLibraryTypes(c.stopCtx); err != nil {
- c.Log.Warn("normalize cloud library types failed", zap.Error(err))
- }
if err := c.Watcher.Start(c.stopCtx); err != nil {
c.Log.Warn("watcher start failed", zap.Error(err))
}
- c.Downloads.Start(c.stopCtx)
- c.Subscription.Start(c.stopCtx)
if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil {
c.Log.Warn("api config seed failed", zap.Error(err))
}
@@ -112,12 +91,6 @@ func (c *Container) Boot() {
// 启动调度器定时任务
c.Scheduler.Start(c.stopCtx)
- // 云盘存储健康检查
- c.BootCloudStorageHealthCheck(c.stopCtx)
-
- // 自动扫描云盘媒体库,使内容对所有用户立即可见
- c.BootCloudLibraries(c.stopCtx)
-
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
// 每天触发一次评估;规则里的窗口可随机,不固定。
if c.Device != nil {
@@ -163,12 +136,6 @@ func (c *Container) Close() {
if c.Watcher != nil {
c.Watcher.Stop()
}
- if c.Subscription != nil {
- c.Subscription.Stop()
- }
- if c.Downloads != nil {
- c.Downloads.Stop()
- }
if c.Transcoder != nil {
c.Transcoder.StopAll()
}
diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go
index d3dbbe7..8abdb30 100644
--- a/internal/service/service_builder.go
+++ b/internal/service/service_builder.go
@@ -37,7 +37,6 @@ func newServiceContainer(cfg *config.Config, log *zap.Logger, repos *repository.
builder.initContentServices()
builder.initAccessAndStorageServices()
builder.initIdentityServices()
- builder.initSiteDownloadServices()
builder.initImageProxy()
builder.attachRuntimeContext()
return builder.c
@@ -92,7 +91,6 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.Organizer = NewOrganizerService(b.cfg, b.log, b.repos)
b.c.Organizer.SetProbe(b.c.FFprobe)
b.c.Organizer.SetScraper(b.c.Scraper)
- b.c.Discover = NewDiscoverService(b.log, b.c.TMDb)
b.c.Transcoder = NewTranscoderService(b.cfg, b.log, b.repos, b.c.WSHub)
b.c.Scan = NewScannerService(b.cfg, b.log, b.repos, b.c.WSHub, b.c.FFprobe, b.c.Scraper)
b.c.Scan.SetOrganizer(b.c.Organizer)
@@ -100,22 +98,15 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.OrganizePipeline = NewOrganizePipelineService(b.log, b.repos, b.c.Organizer, b.c.Scan, b.c.Tasks)
b.c.Watcher = NewWatcherService(b.log, b.repos, b.c.Scan)
b.c.NFO = NewNFOService(b.log, b.repos)
- b.c.AI = NewAIService(b.cfg, b.log, b.c.APIConfig)
- b.c.Duplicate = NewDuplicateService(b.log, b.repos, b.c.WSHub)
b.c.FileManager = NewFileManagerService(b.cfg, b.log, b.repos)
b.c.DLNA = NewDLNAService(b.log)
b.c.Storage = NewStorageService(b.log, b.repos)
b.c.Emby = NewEmbyService(b.cfg, b.log, b.repos)
b.c.Backup = NewBackupService(b.cfg, b.log, b.repos.DB)
- b.c.Notifier = NewNotifierService(b.log, b.repos)
- b.c.NotifyChannels = NewNotifyChannelService(b.log, b.repos)
- b.c.Scan.SetNotifyChannels(b.c.NotifyChannels)
- b.c.Scraper.SetNotifyChannels(b.c.NotifyChannels)
b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache)
b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder)
b.c.Playback = NewPlaybackService(b.log, b.repos)
b.c.Subtitle = NewSubtitleService(b.log, b.repos)
- b.c.Stats = NewStatsService(b.log, b.repos).SetRuntimeCache(b.c.Cache)
b.c.Profile = NewProfileService(b.log, b.repos)
b.c.Audit = NewAuditService(b.log, b.repos)
}
@@ -123,18 +114,11 @@ func (b *serviceContainerBuilder) initContentServices() {
func (b *serviceContainerBuilder) initAccessAndStorageServices() {
b.c.PlayProfiles = NewPlayProfileService(b.log, b.repos)
b.c.Permissions = NewPermissionService(b.log, b.repos)
- b.c.StorageCfg = NewStorageConfigService(b.log, b.repos, b.c.Crypto)
- b.c.STRM = NewSTRMService(b.log, b.repos, b.cfg)
- b.c.Scan.SetStorageConfig(b.c.StorageCfg)
- b.c.Subtitle.SetStorageConfig(b.c.StorageCfg)
b.c.Emby.SetRuntimeCache(b.c.Cache)
- b.c.Emby.SetCloudProbe(b.c.StorageCfg, b.c.FFprobe)
b.c.Emby.SetSubtitleService(b.c.Subtitle)
- b.c.DownloadClients = NewDownloadClientService(b.log, b.repos)
- b.c.Assistant = NewAssistantService(b.log, b.repos, b.c.AI)
b.c.Scheduler = NewSchedulerService(
b.log, b.repos, b.c.Scan, b.c.Transcoder,
- b.c.Organizer, b.c.StorageCfg, b.c.WSHub, b.cfg.Cache.CacheDir,
+ b.c.Organizer, b.c.WSHub, b.cfg.Cache.CacheDir,
)
b.c.Scheduler.SetTaskTracker(b.c.Tasks)
b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline)
@@ -146,28 +130,7 @@ func (b *serviceContainerBuilder) initIdentityServices() {
b.c.Sessions = NewSessionTrackerService(b.log)
b.c.Device = NewDeviceService(b.log, b.repos)
b.c.Device.SetSessionTracker(b.c.Sessions)
- b.c.TelegramBot = NewTelegramBotService(b.log, b.repos, b.c.Crypto, b.c.Auth)
- b.c.TelegramBot.SetDeviceService(b.c.Device)
- b.c.TelegramBot.SetBackupService(b.c.Backup)
- // Device enforcement notifies users through their Telegram binding before destructive actions.
- b.c.Device.SetNotifier(b.c.TelegramBot.NotifyUserByID)
b.c.ApiConfig = NewApiConfigService(b.cfg, b.log, b.repos, b.c.Crypto)
- b.c.DownloadMgr = NewDownloadManager(b.log, b.repos, b.c.Crypto)
- b.c.Notify = NewNotifyService(b.log, b.repos, b.c.Crypto)
-}
-
-func (b *serviceContainerBuilder) initSiteDownloadServices() {
- b.c.Site = NewSiteService(b.log, b.repos, b.flareSolverrURL())
- b.c.Downloads = NewDownloadService(b.log, b.repos, b.c.WSHub, b.c.Organizer, b.c.Site)
- b.c.Downloads.SetDownloadManager(b.c.DownloadMgr)
- b.c.Organizer.SetActiveDownloadPathProvider(b.c.Downloads.ActiveDownloadPaths)
- b.c.Downloads.SetScanner(b.c.Scan)
- b.c.Downloads.SetTaskTracker(b.c.Tasks)
- b.c.Downloads.SetOrganizePipeline(b.c.OrganizePipeline)
- b.c.Downloads.SetNotifyChannels(b.c.NotifyChannels)
- b.c.Subscription = NewSubscriptionService(b.cfg, b.log, b.repos, b.c.Downloads, b.c.Site, b.c.WSHub)
- b.c.Subscription.SetScraper(b.c.Scraper)
- b.c.Subscription.SetNotifyChannels(b.c.NotifyChannels)
}
func (b *serviceContainerBuilder) initImageProxy() {
@@ -175,7 +138,6 @@ func (b *serviceContainerBuilder) initImageProxy() {
b.c.ImageProxy.SetLibraryRootsProvider(b.libraryRoots)
b.c.Scan.SetImageProxy(b.c.ImageProxy)
b.c.Scraper.SetImageProxy(b.c.ImageProxy)
- b.c.Discover.SetImageProxy(b.c.ImageProxy)
}
func (b *serviceContainerBuilder) libraryRoots() []string {
@@ -201,13 +163,6 @@ func (b *serviceContainerBuilder) libraryRoots() []string {
return roots
}
-func (b *serviceContainerBuilder) flareSolverrURL() string {
- if b.cfg.FlareSolverr.Enabled && b.cfg.FlareSolverr.URL != "" {
- return b.cfg.FlareSolverr.URL
- }
- return ""
-}
-
func (b *serviceContainerBuilder) attachRuntimeContext() {
b.c.stopCtx, b.c.stopCancel = context.WithCancel(context.Background())
}
diff --git a/internal/service/service_library_normalize.go b/internal/service/service_library_normalize.go
index 7c44f9a..d33bedd 100644
--- a/internal/service/service_library_normalize.go
+++ b/internal/service/service_library_normalize.go
@@ -79,28 +79,6 @@ func normalizePersistedLocalLibraryPath(pathValue string) string {
}
func (c *Container) NormalizeCloudLibraryTypes(ctx context.Context) error {
- if c == nil || c.Repo == nil || c.Repo.Library == nil || c.Repo.DB == nil {
- return nil
- }
- libs, err := c.Repo.Library.List(ctx)
- if err != nil {
- return err
- }
- for _, lib := range libs {
- info, ok := ParseCloudLibraryMount(lib.Path)
- if !ok {
- continue
- }
- want := InferCloudMountMediaType(info.DisplayDir, lib.Name)
- if want == "" || want == lib.Type {
- continue
- }
- if err := c.Repo.DB.WithContext(ctx).
- Model(&model.Library{}).
- Where("id = ?", lib.ID).
- Update("type", want).Error; err != nil {
- return err
- }
- }
+ // 网盘后端已移除,不再存在 cloud:// 挂载库类型需修正。
return nil
}
diff --git a/internal/service/session_tracker_test.go b/internal/service/session_tracker_test.go
index f42530d..04a431a 100644
--- a/internal/service/session_tracker_test.go
+++ b/internal/service/session_tracker_test.go
@@ -1,7 +1,6 @@
package service
import (
- "strings"
"testing"
"time"
@@ -135,61 +134,6 @@ func TestLogoutKeepsRealtimeLastActivityWithoutOnlineSession(t *testing.T) {
}
}
-func TestBotDevicesIncludesRealtimeSessionOnlyDevices(t *testing.T) {
- repos, bot := newBotTestService(t)
- user := model.User{Base: model.Base{ID: "u1"}, Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true}
- if err := repos.User.Create(t.Context(), &user); err != nil {
- t.Fatal(err)
- }
- if err := repos.DB.Create(&model.TelegramBinding{TelegramUserID: 9103, ChatID: 9103, UserID: user.ID}).Error; err != nil {
- t.Fatal(err)
- }
- tracker := NewSessionTrackerService(zap.NewNop())
- now := time.Date(2026, 6, 21, 11, 0, 0, 0, time.UTC)
- tracker.now = func() time.Time { return now }
- tracker.RecordActivity(t.Context(), user.ID, user.Username, "dev-1", "Apple TV", "Yamby", "10.0.0.8")
- device := NewDeviceService(zap.NewNop(), repos)
- device.SetSessionTracker(tracker)
- bot.SetDeviceService(device)
-
- reply := bot.replyDevices(t.Context(), &TelegramMessage{
- From: TelegramUser{ID: 9103, Username: "viewer"},
- Chat: TelegramChat{ID: 9103, Type: "private"},
- })
-
- if !strings.Contains(reply.Text, "Apple TV / Yamby") || !strings.Contains(reply.Text, "在线") {
- t.Fatalf("reply should include realtime online device, got %q", reply.Text)
- }
- if !strings.Contains(reply.Text, "06-21 11:00") {
- t.Fatalf("reply should use realtime last seen time, got %q", reply.Text)
- }
-}
-
-func TestBotUserInfoUsesRealtimeLastLogin(t *testing.T) {
- repos, bot := newBotTestService(t)
- now := time.Date(2026, 6, 21, 13, 45, 0, 0, time.UTC)
- old := now.Add(-6 * time.Hour)
- user := model.User{Base: model.Base{ID: "u1"}, Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true, LastLoginAt: &old}
- if err := repos.User.Create(t.Context(), &user); err != nil {
- t.Fatal(err)
- }
- tracker := NewSessionTrackerService(zap.NewNop())
- tracker.now = func() time.Time { return now }
- tracker.RecordActivity(t.Context(), user.ID, user.Username, "dev-1", "Apple TV", "Yamby", "10.0.0.8")
- device := NewDeviceService(zap.NewNop(), repos)
- device.SetSessionTracker(tracker)
- bot.SetDeviceService(device)
-
- reply := bot.cmdMgoUserInfo(t.Context(), []string{"viewer"})
-
- if !strings.Contains(reply.Text, "最后登录:2026-06-21 13:45") {
- t.Fatalf("reply should use realtime last login, got %q", reply.Text)
- }
- if !strings.Contains(reply.Text, "设备:1") {
- t.Fatalf("reply should count realtime device, got %q", reply.Text)
- }
-}
-
func TestRealtimeRecentLoginProtectsCleanupCandidate(t *testing.T) {
repos := newSessionTrackerTestRepos(t)
now := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
diff --git a/internal/service/site.go b/internal/service/site.go
deleted file mode 100644
index 40a38f1..0000000
--- a/internal/service/site.go
+++ /dev/null
@@ -1,30 +0,0 @@
-// Package service — site management (PT/BT tracker CRUD + connection test).
-//
-// SiteService owns the lifecycle of Site rows and exposes a cross-site
-// search dispatcher that fans out a keyword query to every enabled site's
-// adapter, collects results and returns them merged + sorted.
-package service
-
-import (
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-// SiteService manages PT/BT site configurations.
-type SiteService struct {
- log *zap.Logger
- repo *repository.Container
- flareSolverrURL string
- apiRateLimiter siteAPIRateLimiter
-}
-
-// NewSiteService is the constructor.
-func NewSiteService(log *zap.Logger, repo *repository.Container, flareSolverrURL string) *SiteService {
- return &SiteService{
- log: log,
- repo: repo,
- flareSolverrURL: flareSolverrURL,
- apiRateLimiter: newPersistentSiteAPIRateLimiter(repo),
- }
-}
diff --git a/internal/service/site_adapter.go b/internal/service/site_adapter.go
deleted file mode 100644
index 298962a..0000000
--- a/internal/service/site_adapter.go
+++ /dev/null
@@ -1,233 +0,0 @@
-// Package service — PT 站点适配器接口及 6 种适配器实现。
-package service
-
-import (
- "context"
- "fmt"
- "io"
- "net/http"
- "strings"
- "time"
-
- "github.com/ShukeBta/MediaStationGo/internal/helper"
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-// SiteConfig 站点配置(从 model.Site 解密后的纯文本)。
-type SiteConfig struct {
- SiteID string
- Name string
- Type string
- URL string
- AuthType string
- Cookie string
- APIKey string
- AuthHeader string
- UserAgent string // 自定义 User-Agent
- Timeout time.Duration // 请求超时
- Extra map[string]string // JSON 扩展配置
- FlareSolverrURL string // FlareSolverr 服务地址(用于浏览器模拟绕过 Cloudflare/WAF)
- UseProxy bool // 通过 HTTP(S)_PROXY 环境变量出站
- RateLimit bool
- rateLimiter siteAPIRateLimiter
-}
-
-// SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。
-type SiteSearchResult struct {
- SiteName string `json:"site_name"`
- Items []TorrentItem `json:"items"`
- Total int `json:"total"`
- Page int `json:"page"`
-}
-
-// TorrentItem 种子条目。
-type TorrentItem struct {
- ID string `json:"id"`
- Title string `json:"title"`
- Subtitle string `json:"subtitle"`
- Labels string `json:"labels,omitempty"`
- Category string `json:"category"`
- Size int64 `json:"size"`
- Seeders int `json:"seeders"`
- Leechers int `json:"leechers"`
- Snatched int `json:"snatched"`
- Free bool `json:"free"`
- FreeEndAt *time.Time `json:"free_end_at"`
- UploadTime time.Time `json:"upload_time"`
- DetailURL string `json:"detail_url"`
- DownloadURL string `json:"download_url"`
-}
-
-// TorrentDetail 种子详情。
-type TorrentDetail struct {
- ID string `json:"id"`
- Title string `json:"title"`
- Subtitle string `json:"subtitle"`
- Category string `json:"category"`
- Size int64 `json:"size"`
- Seeders int `json:"seeders"`
- Leechers int `json:"leechers"`
- Snatched int `json:"snatched"`
- Free bool `json:"free"`
- FreeEndAt *time.Time `json:"free_end_at"`
- UploadTime time.Time `json:"upload_time"`
- DetailURL string `json:"detail_url"`
- DownloadURL string `json:"download_url"`
- InfoHash string `json:"info_hash,omitempty"`
- ImdbID string `json:"imdb_id,omitempty"`
- Description string `json:"description,omitempty"`
- Files []string `json:"files,omitempty"`
-}
-
-// SiteAdapter 站点适配器接口。
-type SiteAdapter interface {
- // Authenticate 测试站点认证是否有效。
- Authenticate(ctx context.Context, cfg SiteConfig) error
-
- // Search 搜索种子。
- Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error)
-
- // Browse 浏览种子列表。
- Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error)
-
- // GetDetail 获取种子详情。
- GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error)
-
- // GetDownloadURL 获取下载链接。
- GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error)
-}
-
-// newHTTPClient 创建带有认证头的 HTTP 客户端。
-// 当 cfg.UseProxy 为 true 时,会读取 HTTP(S)_PROXY 环境变量;
-// 否则忽略环境变量直连。
-func newHTTPClient(cfg SiteConfig, timeout time.Duration) *http.Client {
- secs := int(timeout.Seconds())
- if secs <= 0 {
- secs = 30
- }
- return helper.NewSiteHTTPClient(secs, cfg.UseProxy)
-}
-
-func siteRequestHTTPClient(client *http.Client, cfg SiteConfig) *http.Client {
- timeout := cfg.Timeout
- if timeout <= 0 {
- timeout = 30 * time.Second
- }
- if client == nil || cfg.UseProxy || client.Timeout != timeout {
- return newHTTPClient(cfg, timeout)
- }
- return client
-}
-
-// buildRequest 构建带认证的 HTTP 请求。
-func buildRequest(ctx context.Context, method, rawURL string, cfg SiteConfig, body io.Reader) (*http.Request, error) {
- req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
- if err != nil {
- return nil, err
- }
-
- switch cfg.AuthType {
- case "cookie":
- if cfg.Cookie != "" {
- req.Header.Set("Cookie", cfg.Cookie)
- }
- case "api_key":
- if cfg.APIKey != "" {
- if isYemaPTConfig(cfg) {
- req.Header.Set("Authorization", cfg.APIKey)
- } else {
- // M-Team / UNIT3D 等开放 API 的 PT 站点使用 `x-api-key`。
- req.Header.Set("x-api-key", cfg.APIKey)
- }
- }
- case "auth_header":
- if cfg.AuthHeader != "" {
- parts := strings.SplitN(cfg.AuthHeader, ":", 2)
- if len(parts) == 2 {
- req.Header.Set(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
- } else {
- req.Header.Set("Authorization", "Bearer "+cfg.AuthHeader)
- }
- }
- }
-
- // 使用 SiteConfig 中的 UserAgent(如果提供),否则使用默认值
- userAgent := cfg.UserAgent
- if userAgent == "" {
- userAgent = model.DefaultUserAgent
- }
- req.Header.Set("User-Agent", userAgent)
- return req, nil
-}
-
-// doRequest 执行 HTTP 请求并返回响应体。
-// 当 cfg.FlareSolverrURL 已配置且方法为 GET 时,通过 FlareSolverr 代理请求
-// 以绕过 Cloudflare/WAF 挑战验证。
-func doRequest(ctx context.Context, client *http.Client, method, rawURL string, cfg SiteConfig, body io.Reader) ([]byte, int, error) {
- // ── FlareSolverr 浏览器模拟路径(仅 GET) ──────────────────────────
- if cfg.FlareSolverrURL != "" && method == "GET" {
- timeout := int(cfg.Timeout.Seconds())
- if timeout <= 0 {
- timeout = 30
- }
- pageBody, err := helper.FetchURLWithFlareSolverr(
- cfg.FlareSolverrURL, rawURL, cfg.Cookie, timeout, "", nil)
- if err != nil {
- return nil, 0, fmt.Errorf("flareSolverr: %w", err)
- }
- return []byte(pageBody), http.StatusOK, nil
- }
-
- // ── 直接 HTTP 请求路径 ─────────────────────────────────────────────
- req, err := buildRequest(ctx, method, rawURL, cfg, body)
- if err != nil {
- return nil, 0, err
- }
- if body != nil {
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- }
-
- // 当站点开启了「使用代理」开关时,使用本次请求专用的、读取 HTTP(S)_PROXY
- // 的 client;否则沿用适配器持有的全局 client。这与前端勾选行为对齐。
- httpClient := siteRequestHTTPClient(client, cfg)
-
- resp, err := httpClient.Do(req)
- if err != nil {
- return nil, 0, err
- }
- defer resp.Body.Close()
-
- data, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, resp.StatusCode, err
- }
- return data, resp.StatusCode, nil
-}
-
-// ─── 辅助函数 ────────────────────────────────────────────────────────────────
-
-// doRequestJSON 执行 JSON 请求。
-func doRequestJSON(ctx context.Context, client *http.Client, method, rawURL string, cfg SiteConfig, body []byte) ([]byte, int, error) {
- req, err := buildRequest(ctx, method, rawURL, cfg, nil)
- if err != nil {
- return nil, 0, err
- }
- req.Header.Set("Content-Type", "application/json")
- if body != nil {
- req.Body = io.NopCloser(strings.NewReader(string(body)))
- req.ContentLength = int64(len(body))
- }
-
- httpClient := siteRequestHTTPClient(client, cfg)
- resp, err := httpClient.Do(req)
- if err != nil {
- return nil, 0, err
- }
- defer resp.Body.Close()
-
- data, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, resp.StatusCode, err
- }
- return data, resp.StatusCode, nil
-}
diff --git a/internal/service/site_adapter_custom_rss.go b/internal/service/site_adapter_custom_rss.go
deleted file mode 100644
index f988a4a..0000000
--- a/internal/service/site_adapter_custom_rss.go
+++ /dev/null
@@ -1,172 +0,0 @@
-// Package service — custom RSS site adapter.
-package service
-
-import (
- "context"
- "fmt"
- "net/http"
- "net/url"
- "regexp"
- "strconv"
- "strings"
- "time"
-)
-
-// ─── Custom RSS 适配器 ───────────────────────────────────────────────────────
-
-// CustomRSSAdapter 自定义 RSS 源适配器。
-type CustomRSSAdapter struct {
- client *http.Client
-}
-
-// NewCustomRSSAdapter 创建 Custom RSS 适配器。
-func NewCustomRSSAdapter() *CustomRSSAdapter {
- return &CustomRSSAdapter{
- client: &http.Client{Timeout: 30 * time.Second},
- }
-}
-
-func (a *CustomRSSAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
- // RSS 源通常不需要认证,或者认证通过 URL 参数
- if cfg.URL == "" {
- return fmt.Errorf("RSS URL is required")
- }
- _, status, err := doRequest(ctx, a.client, "GET", cfg.URL, cfg, nil)
- if err != nil {
- return fmt.Errorf("authenticate: %w", err)
- }
- if status != http.StatusOK {
- return fmt.Errorf("authenticate failed: status %d", status)
- }
- return nil
-}
-
-func (a *CustomRSSAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
- searchURL := cfg.URL
- // If extra has search URL template, use it
- if searchTpl, ok := cfg.Extra["search_url"]; ok && searchTpl != "" {
- searchURL = strings.ReplaceAll(searchTpl, "{keyword}", url.QueryEscape(keyword))
- searchURL = strings.ReplaceAll(searchURL, "{page}", strconv.Itoa(page))
- }
-
- data, status, err := doRequest(ctx, a.client, "GET", searchURL, cfg, nil)
- if err != nil {
- return nil, fmt.Errorf("search: %w", err)
- }
- if status != http.StatusOK {
- return nil, fmt.Errorf("search failed: status %d", status)
- }
-
- result, err := parseRSSXML(data, cfg.Name, keyword)
- if err != nil {
- return nil, fmt.Errorf("parse RSS: %w", err)
- }
-
- if page > 1 {
- // Simple pagination for RSS: skip items already seen
- start := (page - 1) * 50
- if start < len(result.Items) {
- result.Items = result.Items[start:]
- } else {
- result.Items = []TorrentItem{}
- }
- }
- result.Page = page
-
- return result, nil
-}
-
-func (a *CustomRSSAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
- // RSS browse is essentially the same as search with empty keyword
- return a.Search(ctx, cfg, "", page)
-}
-
-func (a *CustomRSSAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
- // RSS typically doesn't support detail page; return basic info
- return &TorrentDetail{
- ID: id,
- Title: id,
- }, nil
-}
-
-func (a *CustomRSSAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
- return id, nil // In RSS, the ID is often the download URL
-}
-
-// parseRSSXML 解析 RSS XML 内容。
-func parseRSSXML(data []byte, siteName, keyword string) (*SiteSearchResult, error) {
- result := &SiteSearchResult{
- SiteName: siteName,
- Items: []TorrentItem{},
- }
-
- html := string(data)
- // Simple regex-based XML parsing for RSS items
- itemRegex := regexp.MustCompile(`- (.*?)
`)
- items := itemRegex.FindAllStringSubmatch(html, -1)
-
- for i, item := range items {
- ri := TorrentItem{}
-
- // Title
- titleRegex := regexp.MustCompile(`(?:)?`)
- if m := titleRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
- ri.Title = strings.TrimSpace(m[1])
- }
-
- // Filter by keyword
- if keyword != "" && !strings.Contains(strings.ToLower(ri.Title), strings.ToLower(keyword)) {
- continue
- }
-
- ri.ID = strconv.Itoa(i)
-
- // Link
- linkRegex := regexp.MustCompile(`(?:)?`)
- if m := linkRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
- ri.DetailURL = strings.TrimSpace(m[1])
- ri.DownloadURL = strings.TrimSpace(m[1])
- }
-
- // Description
- descRegex := regexp.MustCompile(`(?:)?`)
- if m := descRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
- desc := stripHTML(m[1])
- ri.Subtitle = desc
- }
-
- // Size from description
- sizeRegex := regexp.MustCompile(`(\d+\.?\d*)\s*(GB|MB|TB|KB)`)
- if m := sizeRegex.FindStringSubmatch(item[1]); len(m) >= 3 {
- ri.Size = parseSizeString(m[1], m[2])
- }
-
- // Category
- catRegex := regexp.MustCompile(`(?:)?`)
- if m := catRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
- ri.Category = strings.TrimSpace(m[1])
- }
-
- // Date
- dateRegex := regexp.MustCompile(`(?:)?`)
- if m := dateRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
- for _, layout := range []string{
- time.RFC1123, time.RFC1123Z, time.RFC3339,
- "2006-01-02 15:04:05", "2006-01-02T15:04:05-07:00",
- } {
- if t, err := time.Parse(layout, strings.TrimSpace(m[1])); err == nil {
- ri.UploadTime = t
- break
- }
- }
- }
-
- result.Items = append(result.Items, ri)
- }
-
- result.Total = len(result.Items)
- return result, nil
-}
-
-// TorrentDetail has a Description field used by RSS adapter.
-// (Already defined above)
diff --git a/internal/service/site_adapter_discuz.go b/internal/service/site_adapter_discuz.go
deleted file mode 100644
index 3c66f2d..0000000
--- a/internal/service/site_adapter_discuz.go
+++ /dev/null
@@ -1,167 +0,0 @@
-// Package service — Discuz site adapter.
-package service
-
-import (
- "context"
- "fmt"
- "net/http"
- "net/url"
- "regexp"
- "strconv"
- "strings"
- "time"
-)
-
-// ─── Discuz 适配器 ───────────────────────────────────────────────────────────
-
-// DiscuzAdapter 基于 Discuz! X 的站点适配器。
-type DiscuzAdapter struct {
- client *http.Client
-}
-
-// NewDiscuzAdapter 创建 Discuz 适配器。
-func NewDiscuzAdapter() *DiscuzAdapter {
- return &DiscuzAdapter{
- client: &http.Client{Timeout: 30 * time.Second},
- }
-}
-
-func (a *DiscuzAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
- u := cfg.URL + "/home.php?mod=space"
- data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
- if err != nil {
- return fmt.Errorf("authenticate: %w", err)
- }
- if status == http.StatusFound {
- return fmt.Errorf("authentication failed: redirected to login")
- }
- if status != http.StatusOK {
- return fmt.Errorf("authenticate failed: status %d", status)
- }
- body := string(data)
- if strings.Contains(body, "login") && !strings.Contains(body, "我的空间") {
- return fmt.Errorf("authentication failed: not logged in")
- }
- return nil
-}
-
-func (a *DiscuzAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
- params := url.Values{}
- params.Set("mod", "forum")
- params.Set("srchtxt", keyword)
- params.Set("searchsubmit", "true")
- params.Set("page", strconv.Itoa(page))
-
- u := cfg.URL + "/search.php?" + params.Encode()
- data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
- if err != nil {
- return nil, fmt.Errorf("search: %w", err)
- }
- if status != http.StatusOK {
- return nil, fmt.Errorf("search failed: status %d", status)
- }
-
- return parseDiscuzHTML(string(data), cfg.Name, cfg.URL)
-}
-
-func (a *DiscuzAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
- params := url.Values{}
- if category != "" {
- params.Set("fid", category)
- }
- params.Set("page", strconv.Itoa(page))
-
- u := cfg.URL + "/forum.php?" + params.Encode()
- data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
- if err != nil {
- return nil, fmt.Errorf("browse: %w", err)
- }
- if status != http.StatusOK {
- return nil, fmt.Errorf("browse failed: status %d", status)
- }
-
- return parseDiscuzHTML(string(data), cfg.Name, cfg.URL)
-}
-
-func (a *DiscuzAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
- u := cfg.URL + "/forum.php?mod=viewthread&tid=" + id
- data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
- if err != nil {
- return nil, fmt.Errorf("detail: %w", err)
- }
- if status != http.StatusOK {
- return nil, fmt.Errorf("detail failed: status %d", status)
- }
-
- html := string(data)
- detail := &TorrentDetail{
- ID: id,
- DetailURL: cfg.URL + "/forum.php?mod=viewthread&tid=" + id,
- }
-
- // Title
- titleRegex := regexp.MustCompile(`]*id="thread_subject"[^>]*>([^<]+)`)
- if m := titleRegex.FindStringSubmatch(html); len(m) >= 2 {
- detail.Title = strings.TrimSpace(m[1])
- }
-
- // Extract magnet/torrent links
- magnetRegex := regexp.MustCompile(`magnet:\?[^\s"'<>]+`)
- if m := magnetRegex.FindString(html); m != "" {
- detail.DownloadURL = m
- }
- torrentRegex := regexp.MustCompile(`(attachment\.php\?aid=\d+)`)
- if m := torrentRegex.FindString(html); m != "" && detail.DownloadURL == "" {
- detail.DownloadURL = cfg.URL + "/" + m
- }
-
- // Description
- descRegex := regexp.MustCompile(`]*class="t_fsz"[^>]*>(.*?)
`)
- if m := descRegex.FindStringSubmatch(html); len(m) >= 2 {
- detail.Description = stripHTML(m[1])
- }
-
- return detail, nil
-}
-
-func (a *DiscuzAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
- return cfg.URL + "/forum.php?mod=viewthread&tid=" + id, nil
-}
-
-// parseDiscuzHTML 解析 Discuz HTML 响应。
-func parseDiscuzHTML(html, siteName, baseURL string) (*SiteSearchResult, error) {
- result := &SiteSearchResult{
- SiteName: siteName,
- Items: []TorrentItem{},
- Page: 1,
- }
-
- // Extract thread links
- threadRegex := regexp.MustCompile(`]*href="(?:forum\.php\?mod=viewthread&tid=|thread-(\d+)-1-1)\.html"[^>]*>([^<]+)`)
- matches := threadRegex.FindAllStringSubmatch(html, -1)
-
- for _, m := range matches {
- item := TorrentItem{}
- if m[1] != "" {
- item.ID = m[1]
- } else {
- // Extract tid from URL
- tidRegex := regexp.MustCompile(`tid=(\d+)`)
- if tidM := tidRegex.FindStringSubmatch(m[0]); len(tidM) >= 2 {
- item.ID = tidM[1]
- }
- }
- if item.ID == "" {
- continue
- }
-
- item.Title = strings.TrimSpace(m[2])
- item.DetailURL = baseURL + "/forum.php?mod=viewthread&tid=" + item.ID
- item.UploadTime = time.Now()
-
- result.Items = append(result.Items, item)
- }
-
- result.Total = len(result.Items)
- return result, nil
-}
diff --git a/internal/service/site_adapter_factory.go b/internal/service/site_adapter_factory.go
deleted file mode 100644
index 3e80682..0000000
--- a/internal/service/site_adapter_factory.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package service
-
-import (
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-// GetAdapterForType 根据站点类型返回对应的适配器实例。
-func GetAdapterForType(siteType string) SiteAdapter {
- switch strings.ToLower(siteType) {
- case "nexusphp":
- return NewNexusPHPAdapter()
- case "gazelle":
- return NewGazelleAdapter()
- case "unit3d":
- return NewUNIT3DAdapter()
- case "mteam":
- return NewMTeamAdapter()
- case "yemapt":
- return NewYemaPTAdapter()
- case "discuz":
- return NewDiscuzAdapter()
- case "custom_rss":
- return NewCustomRSSAdapter()
- default:
- return NewNexusPHPAdapter()
- }
-}
-
-// NewSiteAdapter 根据站点模型创建对应的适配器。
-func NewSiteAdapter(site *model.Site) SiteAdapter {
- if site == nil {
- return nil
- }
- if isYemaPTURL(site.URL) {
- return NewYemaPTAdapter()
- }
- return GetAdapterForType(site.Type)
-}
diff --git a/internal/service/site_adapter_gazelle.go b/internal/service/site_adapter_gazelle.go
deleted file mode 100644
index 9972561..0000000
--- a/internal/service/site_adapter_gazelle.go
+++ /dev/null
@@ -1,227 +0,0 @@
-// Package service — Gazelle site adapter.
-package service
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "net/url"
- "strconv"
- "time"
-)
-
-// ─── Gazelle 适配器 ──────────────────────────────────────────────────────────
-
-// GazelleAdapter Gazelle 框架适配器(What.cd 开源)。
-type GazelleAdapter struct {
- client *http.Client
-}
-
-// NewGazelleAdapter 创建 Gazelle 适配器。
-func NewGazelleAdapter() *GazelleAdapter {
- return &GazelleAdapter{
- client: &http.Client{Timeout: 30 * time.Second},
- }
-}
-
-func (a *GazelleAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
- u := cfg.URL + "/ajax.php?action=index"
- data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
- if err != nil {
- return fmt.Errorf("authenticate: %w", err)
- }
- if status != http.StatusOK {
- return fmt.Errorf("authenticate failed: status %d", status)
- }
- var result map[string]interface{}
- if err := json.Unmarshal(data, &result); err != nil {
- return fmt.Errorf("parse response: %w", err)
- }
- if statusMsg, ok := result["status"].(string); ok && statusMsg == "failure" {
- return fmt.Errorf("authentication failed: %v", result["error"])
- }
- return nil
-}
-
-func (a *GazelleAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
- params := url.Values{}
- params.Set("action", "browse")
- params.Set("searchstr", keyword)
- params.Set("page", strconv.Itoa(page))
-
- u := cfg.URL + "/ajax.php?" + params.Encode()
- data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
- if err != nil {
- return nil, fmt.Errorf("search: %w", err)
- }
- if status != http.StatusOK {
- return nil, fmt.Errorf("search failed: status %d", status)
- }
-
- return parseGazelleJSON(data, cfg.Name, cfg.URL)
-}
-
-func (a *GazelleAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
- params := url.Values{}
- params.Set("action", "browse")
- if category != "" {
- params.Set("filter_cat["+category+"]", "1")
- }
- params.Set("page", strconv.Itoa(page))
-
- u := cfg.URL + "/ajax.php?" + params.Encode()
- data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
- if err != nil {
- return nil, fmt.Errorf("browse: %w", err)
- }
- if status != http.StatusOK {
- return nil, fmt.Errorf("browse failed: status %d", status)
- }
-
- return parseGazelleJSON(data, cfg.Name, cfg.URL)
-}
-
-func (a *GazelleAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
- params := url.Values{}
- params.Set("action", "torrent")
- params.Set("id", id)
-
- u := cfg.URL + "/ajax.php?" + params.Encode()
- data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
- if err != nil {
- return nil, fmt.Errorf("detail: %w", err)
- }
- if status != http.StatusOK {
- return nil, fmt.Errorf("detail failed: status %d", status)
- }
-
- var resp map[string]interface{}
- if err := json.Unmarshal(data, &resp); err != nil {
- return nil, fmt.Errorf("parse response: %w", err)
- }
-
- torrent, ok := resp["torrent"].(map[string]interface{})
- if !ok {
- return nil, fmt.Errorf("torrent not found")
- }
-
- detail := &TorrentDetail{
- ID: id,
- DetailURL: cfg.URL + "/torrents.php?torrentid=" + id,
- DownloadURL: cfg.URL + "/torrents.php?action=download&id=" + id,
- }
-
- if v, ok := torrent["groupName"].(string); ok {
- detail.Title = v
- }
- if v, ok := torrent["subName"].(string); ok {
- detail.Subtitle = v
- }
- if v, ok := torrent["size"].(float64); ok {
- detail.Size = int64(v)
- }
- if v, ok := torrent["seeders"].(float64); ok {
- detail.Seeders = int(v)
- }
- if v, ok := torrent["leechers"].(float64); ok {
- detail.Leechers = int(v)
- }
- if v, ok := torrent["snatched"].(float64); ok {
- detail.Snatched = int(v)
- }
- if v, ok := torrent["freeTorrent"].(string); ok && v == "1" {
- detail.Free = true
- }
- if v, ok := torrent["freeTorrent"].(bool); ok {
- detail.Free = v
- }
- if v, ok := torrent["infoHash"].(string); ok {
- detail.InfoHash = v
- }
- if v, ok := torrent["groupDesc"].(string); ok {
- detail.Description = stripHTML(v)
- }
-
- return detail, nil
-}
-
-func (a *GazelleAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
- return cfg.URL + "/torrents.php?action=download&id=" + id, nil
-}
-
-// parseGazelleJSON 解析 Gazelle JSON 响应。
-func parseGazelleJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
- var resp map[string]interface{}
- if err := json.Unmarshal(data, &resp); err != nil {
- return nil, fmt.Errorf("parse JSON: %w", err)
- }
-
- result := &SiteSearchResult{
- SiteName: siteName,
- Items: []TorrentItem{},
- }
-
- if status, ok := resp["status"].(string); ok && status == "failure" {
- return result, nil
- }
-
- results, ok := resp["results"].([]interface{})
- if !ok {
- return result, nil
- }
-
- for _, r := range results {
- torrent, ok := r.(map[string]interface{})
- if !ok {
- continue
- }
- item := TorrentItem{}
- if v, ok := torrent["torrentId"].(float64); ok {
- item.ID = strconv.Itoa(int(v))
- }
- if v, ok := torrent["groupName"].(string); ok {
- item.Title = v
- }
- if v, ok := torrent["artist"].(string); ok {
- item.Subtitle = v
- }
- if v, ok := torrent["category"].(string); ok {
- item.Category = v
- }
- if v, ok := torrent["size"].(float64); ok {
- item.Size = int64(v)
- }
- if v, ok := torrent["seeders"].(float64); ok {
- item.Seeders = int(v)
- }
- if v, ok := torrent["leechers"].(float64); ok {
- item.Leechers = int(v)
- }
- if v, ok := torrent["snatched"].(float64); ok {
- item.Snatched = int(v)
- }
- if v, ok := torrent["freeTorrent"].(string); ok && v == "1" {
- item.Free = true
- }
- if v, ok := torrent["freeTorrent"].(bool); ok {
- item.Free = v
- }
- if v, ok := torrent["time"].(string); ok {
- if t, err := time.Parse(time.RFC3339, v); err == nil {
- item.UploadTime = t
- }
- }
-
- item.DetailURL = baseURL + "/torrents.php?torrentid=" + item.ID
- item.DownloadURL = baseURL + "/torrents.php?action=download&id=" + item.ID
- result.Items = append(result.Items, item)
- }
-
- if total, ok := resp["totalResults"].(float64); ok {
- result.Total = int(total)
- } else {
- result.Total = len(result.Items)
- }
- return result, nil
-}
diff --git a/internal/service/site_adapter_mteam.go b/internal/service/site_adapter_mteam.go
deleted file mode 100644
index 4bd9ad0..0000000
--- a/internal/service/site_adapter_mteam.go
+++ /dev/null
@@ -1,258 +0,0 @@
-// Package service — M-Team site adapter.
-package service
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "net/url"
- "strconv"
- "strings"
- "time"
-)
-
-// ─── MTeam 适配器 ────────────────────────────────────────────────────────────
-
-// MTeamAdapter MTeam.cc 独立站适配器。
-type MTeamAdapter struct {
- client *http.Client
-}
-
-// NewMTeamAdapter 创建 MTeam 适配器。
-func NewMTeamAdapter() *MTeamAdapter {
- return &MTeamAdapter{
- client: &http.Client{Timeout: 30 * time.Second},
- }
-}
-
-func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
- if strings.TrimSpace(cfg.APIKey) == "" {
- return fmt.Errorf("M-Team 需要填写 API Access Token(控制台 → 实验室 → 存取令牌),不能使用 Cookie 访问开放 API")
- }
- // 与旧版参考实现对齐:
- // 用 camelCase 参数(pageNumber / pageSize),同时接受 code 为字符串 "0"
- // 或数值 0;兼容 M-Team v3 API 不同版本的返回。
- if err := reserveMTeamAPIQuota(ctx, cfg, mteamAPIEndpointSearch); err != nil {
- return fmt.Errorf("authenticate: %w", err)
- }
- u := cfg.URL + "/api/torrent/search"
- payload := `{"pageNumber":1,"pageSize":1,"mode":"all"}`
- data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte(payload))
- if err != nil {
- return mteamRequestError("authenticate", cfg, err)
- }
- preview := string(data)
- if len(preview) > 400 {
- preview = preview[:400] + "..."
- }
- if status == http.StatusUnauthorized || status == http.StatusForbidden {
- return fmt.Errorf("authentication failed: status %d, body=%s", status, preview)
- }
- if status >= 300 && status < 400 {
- return fmt.Errorf("authentication failed: HTTP %d (API Key 无效或未登录), body=%s", status, preview)
- }
- if status != http.StatusOK {
- return fmt.Errorf("authenticate failed: status %d, body=%s", status, preview)
- }
- var resp map[string]interface{}
- if err := json.Unmarshal(data, &resp); err != nil {
- return fmt.Errorf("parse response: %w (body=%s)", err, preview)
- }
- if mteamCodeOK(resp["code"]) {
- return nil
- }
- msg, _ := resp["message"].(string)
- if msg == "" {
- msg = fmt.Sprintf("code=%s", mteamCodeString(resp["code"]))
- }
- return fmt.Errorf("authentication failed: %s (body=%s)", msg, preview)
-}
-
-func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
- // 与参考项目对齐:使用 camelCase 字段名,page 从 1 开始。
- if page <= 0 {
- page = 1
- }
- payload := map[string]interface{}{
- "keyword": keyword,
- "pageNumber": page,
- "pageSize": 50,
- }
- body, _ := json.Marshal(payload)
-
- if err := reserveMTeamAPIQuota(ctx, cfg, mteamAPIEndpointSearch); err != nil {
- return nil, err
- }
- u := cfg.URL + "/api/torrent/search"
- data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, body)
- if err != nil {
- return nil, mteamRequestError("search", cfg, err)
- }
- if status != http.StatusOK {
- return nil, fmt.Errorf("search failed: status %d", status)
- }
-
- return parseMTeamJSON(data, cfg.Name, cfg.URL)
-}
-
-func (a *MTeamAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
- if page <= 0 {
- page = 1
- }
- payload := map[string]interface{}{
- "keyword": "",
- "pageNumber": page,
- "pageSize": 50,
- }
- if category != "" {
- payload["categories"] = []string{category}
- }
- body, _ := json.Marshal(payload)
-
- if err := reserveMTeamAPIQuota(ctx, cfg, mteamAPIEndpointSearch); err != nil {
- return nil, err
- }
- u := cfg.URL + "/api/torrent/search"
- data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, body)
- if err != nil {
- return nil, mteamRequestError("browse", cfg, err)
- }
- if status != http.StatusOK {
- return nil, fmt.Errorf("browse failed: status %d", status)
- }
-
- return parseMTeamJSON(data, cfg.Name, cfg.URL)
-}
-
-func (a *MTeamAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
- if err := reserveMTeamAPIQuota(ctx, cfg, mteamAPIEndpointDetail); err != nil {
- return nil, err
- }
- u := cfg.URL + "/api/torrent/detail?id=" + url.QueryEscape(id)
- data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, nil)
- if err != nil {
- return nil, mteamRequestError("detail", cfg, err)
- }
- if status != http.StatusOK {
- return nil, fmt.Errorf("detail failed: status %d", status)
- }
-
- var resp map[string]interface{}
- if err := json.Unmarshal(data, &resp); err != nil {
- return nil, fmt.Errorf("parse response: %w", err)
- }
-
- dataField, ok := resp["data"].(map[string]interface{})
- if !ok {
- return nil, fmt.Errorf("detail not found")
- }
-
- detail := &TorrentDetail{
- ID: id,
- DetailURL: cfg.URL + "/detail/" + id,
- }
-
- if v, ok := dataField["name"].(string); ok {
- detail.Title = v
- }
- if v, ok := dataField["subtitle"].(string); ok {
- detail.Subtitle = v
- }
- if v, ok := dataField["size"].(float64); ok {
- detail.Size = int64(v)
- }
- if v, ok := dataField["status"].(map[string]interface{}); ok {
- if seeders, ok := v["seeders"].(float64); ok {
- detail.Seeders = int(seeders)
- }
- if leechers, ok := v["leechers"].(float64); ok {
- detail.Leechers = int(leechers)
- }
- if snatched, ok := v["completed"].(float64); ok {
- detail.Snatched = int(snatched)
- }
- }
- if v, ok := dataField["free"].(bool); ok {
- detail.Free = v
- }
- if v, ok := dataField["download"].(string); ok {
- detail.DownloadURL = v
- }
- if v, ok := dataField["description"].(string); ok {
- detail.Description = stripHTML(v)
- }
-
- return detail, nil
-}
-
-// GetDownloadURL 解析 M-Team 种子的真实下载链接。
-//
-// M-Team v3 流程:
-//
-// POST /api/torrent/genDlToken?id={tid} (带 x-api-key)
-// → {"code":"0","data":"https://api.m-team.cc/api/rss/dlv2?sign=..."}
-//
-// 拿到的 sign URL 可被任何下载客户端无认证地直接 GET。这是旧版参考实现
-// _download_torrent_file 方法的子集。
-func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
- if err := reserveMTeamAPIQuota(ctx, cfg, mteamAPIEndpointDownload); err != nil {
- return "", err
- }
- u := cfg.URL + "/api/torrent/genDlToken?id=" + id
- // genDlToken 是 POST 但参数走 query string;body 留空。
- data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte("{}"))
- if err != nil {
- return "", mteamRequestError("genDlToken", cfg, err)
- }
- if status >= 300 {
- return "", fmt.Errorf("genDlToken: HTTP %d", status)
- }
- var resp map[string]interface{}
- if err := json.Unmarshal(data, &resp); err != nil {
- return "", fmt.Errorf("genDlToken parse: %w", err)
- }
- codeStr := ""
- switch v := resp["code"].(type) {
- case string:
- codeStr = v
- case float64:
- codeStr = strconv.Itoa(int(v))
- }
- if codeStr != "0" && codeStr != "200" {
- msg, _ := resp["message"].(string)
- if msg == "" {
- msg = "unknown error"
- }
- return "", fmt.Errorf("genDlToken: %s", msg)
- }
- dl, _ := resp["data"].(string)
- if dl == "" {
- return "", fmt.Errorf("genDlToken: empty data field")
- }
- return dl, nil
-}
-
-func mteamRequestError(action string, cfg SiteConfig, err error) error {
- if err == nil {
- return nil
- }
- if isSiteRequestTimeout(err) {
- timeout := cfg.Timeout
- if timeout <= 0 {
- timeout = 30 * time.Second
- }
- return fmt.Errorf("%s: M-Team API request timed out after %s; check Docker/IPv6/proxy access to api.m-team.cc or increase the site timeout to 45-60s: %w",
- action, timeout.Round(time.Second), err)
- }
- return fmt.Errorf("%s: %w", action, err)
-}
-
-func isSiteRequestTimeout(err error) bool {
- if errors.Is(err, context.DeadlineExceeded) {
- return true
- }
- var timeout interface{ Timeout() bool }
- return errors.As(err, &timeout) && timeout.Timeout()
-}
diff --git a/internal/service/site_adapter_mteam_parse.go b/internal/service/site_adapter_mteam_parse.go
deleted file mode 100644
index c8917ea..0000000
--- a/internal/service/site_adapter_mteam_parse.go
+++ /dev/null
@@ -1,135 +0,0 @@
-// Package service — M-Team search response parsing.
-package service
-
-import (
- "encoding/json"
- "fmt"
- "strconv"
- "time"
-)
-
-// parseMTeamJSON 解析 MTeam v3 JSON 响应。
-//
-// 响应结构(与旧版参考实现一致):
-//
-// {
-// "code": "0", // 字符串 "0" 表示成功
-// "message": "SUCCESS",
-// "data": {
-// "total": "123",
-// "data": [ ... ] // 旧字段名 "lists" 已被替换为 "data"
-// }
-// }
-func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
- // 用 map 反序列化以兼容 code/total 既可能是字符串又可能是数字。
- var raw map[string]interface{}
- if err := json.Unmarshal(data, &raw); err != nil {
- return nil, fmt.Errorf("parse JSON: %w", err)
- }
-
- // code 兼容字符串与数字。
- codeStr := ""
- switch v := raw["code"].(type) {
- case string:
- codeStr = v
- case float64:
- codeStr = strconv.Itoa(int(v))
- }
- if codeStr != "" && codeStr != "0" && codeStr != "200" {
- msg, _ := raw["message"].(string)
- if msg == "" {
- msg = fmt.Sprintf("code=%s", codeStr)
- }
- return nil, fmt.Errorf("mteam: %s", msg)
- }
-
- dataField, _ := raw["data"].(map[string]interface{})
- if dataField == nil {
- return &SiteSearchResult{SiteName: siteName, Items: []TorrentItem{}}, nil
- }
-
- // total 兼容字符串与数字。
- total := 0
- switch v := dataField["total"].(type) {
- case string:
- total, _ = strconv.Atoi(v)
- case float64:
- total = int(v)
- }
-
- // data.data(v3)优先;兜底兼容旧的 data.lists。
- var rows []interface{}
- switch v := dataField["data"].(type) {
- case []interface{}:
- rows = v
- }
- if rows == nil {
- if v, ok := dataField["lists"].([]interface{}); ok {
- rows = v
- }
- }
-
- result := &SiteSearchResult{
- SiteName: siteName,
- Items: []TorrentItem{},
- Total: total,
- }
-
- for _, rawT := range rows {
- t, ok := rawT.(map[string]interface{})
- if !ok {
- continue
- }
- item := TorrentItem{}
- if v, ok := t["id"].(string); ok {
- item.ID = v
- } else if v, ok := t["id"].(float64); ok {
- item.ID = strconv.Itoa(int(v))
- }
- if v, ok := t["name"].(string); ok {
- item.Title = v
- }
- if v, ok := t["subtitle"].(string); ok {
- item.Subtitle = v
- }
- if v, ok := t["category"].(map[string]interface{}); ok {
- if name, ok := v["name"].(string); ok {
- item.Category = name
- }
- }
- if v, ok := t["size"].(float64); ok {
- item.Size = int64(v)
- } else if v, ok := t["size"].(string); ok {
- // v3 API 把 size 序列化成字符串。
- if n, err := strconv.ParseInt(v, 10, 64); err == nil {
- item.Size = n
- }
- }
- if v, ok := t["status"].(map[string]interface{}); ok {
- if seeders, ok := v["seeders"].(float64); ok {
- item.Seeders = int(seeders)
- }
- if leechers, ok := v["leechers"].(float64); ok {
- item.Leechers = int(leechers)
- }
- if snatched, ok := v["completed"].(float64); ok {
- item.Snatched = int(snatched)
- }
- }
- if v, ok := t["free"].(bool); ok {
- item.Free = v
- }
- if v, ok := t["uploadTime"].(float64); ok {
- item.UploadTime = time.Unix(int64(v), 0)
- }
-
- item.DetailURL = baseURL + "/detail/" + item.ID
- // 标记 download_url 指向 genDlToken;真正的下载链接由 handler 层
- // 在用户点"下载"时通过 MTeamAdapter.GetDownloadURL 解析。
- // 这样前端 SiteSearchPage 才知道这一行有可用的下载入口。
- item.DownloadURL = baseURL + "/api/torrent/genDlToken?id=" + item.ID
- result.Items = append(result.Items, item)
- }
-
- return result, nil
-}
diff --git a/internal/service/site_adapter_nexusphp.go b/internal/service/site_adapter_nexusphp.go
deleted file mode 100644
index 1700a45..0000000
--- a/internal/service/site_adapter_nexusphp.go
+++ /dev/null
@@ -1,122 +0,0 @@
-// Package service — NexusPHP site adapter.
-package service
-
-import (
- "context"
- "fmt"
- "net/http"
- "net/url"
- "strconv"
- "strings"
- "time"
-)
-
-// ─── NexusPHP 适配器 ─────────────────────────────────────────────────────────
-
-// NexusPHPAdapter NexusPHP 框架适配器(馒头、HDHome、CHDBits 等)。
-type NexusPHPAdapter struct {
- client *http.Client
-}
-
-// NewNexusPHPAdapter 创建 NexusPHP 适配器。
-func NewNexusPHPAdapter() *NexusPHPAdapter {
- return &NexusPHPAdapter{
- client: &http.Client{Timeout: 30 * time.Second},
- }
-}
-
-func (a *NexusPHPAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
- // 走 doRequest 以便复用代理 / FlareSolverr / 浏览器头。
- data, status, err := doRequest(ctx, a.client, "GET", cfg.URL+"/index.php", cfg, nil)
- if err != nil {
- return fmt.Errorf("request failed: %w", err)
- }
-
- if status == http.StatusFound {
- return fmt.Errorf("authentication failed: redirected to login page")
- }
- if status == http.StatusUnauthorized || status == http.StatusForbidden {
- return fmt.Errorf("authentication failed: status %d", status)
- }
- if status >= 400 {
- return fmt.Errorf("authentication failed: status %d", status)
- }
-
- body := string(data)
- // NexusPHP 登录后页面通常包含 logout 或 userdetails;
- // 仅当二者都不存在且明确显示登录表单时才判失败。
- if strings.Contains(body, "userdetails") || strings.Contains(body, "logout") || strings.Contains(body, "退出") {
- return nil
- }
- if strings.Contains(body, "takelogin.php") || strings.Contains(body, "id=\"loginform\"") {
- return fmt.Errorf("authentication failed: not logged in")
- }
- // 状态码 OK 但页面不含明显标记时不再武断判失败。
- return nil
-}
-
-func (a *NexusPHPAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
- params := url.Values{}
- params.Set("searchstr", keyword)
- params.Set("search", keyword)
- params.Set("search_area", "0")
- params.Set("search_mode", "0")
- params.Set("page", strconv.Itoa(page))
- params.Set("inclbookmarked", "0")
- params.Set("incldead", "0")
-
- u := cfg.URL + "/torrents.php?" + params.Encode()
- data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
- if err != nil {
- return nil, fmt.Errorf("search request: %w", err)
- }
- if status != http.StatusOK {
- return nil, fmt.Errorf("search failed: status %d", status)
- }
-
- body := string(data)
- if nexusPHPPageLooksLogin(body) {
- return nil, fmt.Errorf("search failed: not logged in or cookie expired")
- }
- return parseNexusPHPHTML(body, cfg.Name, cfg.URL)
-}
-
-func (a *NexusPHPAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
- params := url.Values{}
- if category != "" {
- params.Set("cat", category)
- }
- params.Set("page", strconv.Itoa(page))
-
- u := cfg.URL + "/torrents.php?" + params.Encode()
- data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
- if err != nil {
- return nil, fmt.Errorf("browse request: %w", err)
- }
- if status != http.StatusOK {
- return nil, fmt.Errorf("browse failed: status %d", status)
- }
-
- body := string(data)
- if nexusPHPPageLooksLogin(body) {
- return nil, fmt.Errorf("browse failed: not logged in or cookie expired")
- }
- return parseNexusPHPHTML(body, cfg.Name, cfg.URL)
-}
-
-func (a *NexusPHPAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
- u := cfg.URL + "/details.php?id=" + id
- data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
- if err != nil {
- return nil, fmt.Errorf("detail request: %w", err)
- }
- if status != http.StatusOK {
- return nil, fmt.Errorf("detail failed: status %d", status)
- }
-
- return parseNexusPHPDetailHTML(string(data), id, cfg.URL)
-}
-
-func (a *NexusPHPAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
- return cfg.URL + "/download.php?id=" + id, nil
-}
diff --git a/internal/service/site_adapter_nexusphp_detail.go b/internal/service/site_adapter_nexusphp_detail.go
deleted file mode 100644
index 6ce57a8..0000000
--- a/internal/service/site_adapter_nexusphp_detail.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package service
-
-import (
- "regexp"
- "strconv"
- "strings"
-)
-
-// parseNexusPHPDetailHTML 解析种子详情页。
-func parseNexusPHPDetailHTML(html, id, baseURL string) (*TorrentDetail, error) {
- detail := &TorrentDetail{
- ID: id,
- DetailURL: baseURL + "/details.php?id=" + id,
- }
- if m := regexp.MustCompile(`]*>([^<]+)
`).FindStringSubmatch(html); len(m) >= 2 {
- detail.Title = strings.TrimSpace(m[1])
- }
- if m := regexp.MustCompile(`]*class="[^"]*sub[^"]*"[^>]*>([^<]+)`).FindStringSubmatch(html); len(m) >= 2 {
- detail.Subtitle = strings.TrimSpace(m[1])
- }
- if m := regexp.MustCompile(`(?i)info_hash[^<]*\s*]*>([^<]+) | `).FindStringSubmatch(html); len(m) >= 2 {
- detail.InfoHash = strings.TrimSpace(m[1])
- }
- if m := regexp.MustCompile(`(?i)imdb[^<]*\s*]*>[^<]*(tt\d+)`).FindStringSubmatch(html); len(m) >= 2 {
- detail.ImdbID = m[1]
- }
- if m := regexp.MustCompile(`(?i)size[^<]* | \s*]*>(\d+\.?\d*)\s*(GB|MB|TB|KB)`).FindStringSubmatch(html); len(m) >= 3 {
- detail.Size = parseSizeString(m[1], m[2])
- }
- if m := regexp.MustCompile(`seeders[^<]* | \s*]*>(\d+) | \s*]*>\s* | \s*]*>\s* | \s*]*>leechers[^<]* | \s*]*>(\d+)`).FindStringSubmatch(html); len(m) >= 3 {
- detail.Seeders, _ = strconv.Atoi(m[1])
- detail.Leechers, _ = strconv.Atoi(m[2])
- }
- if m := regexp.MustCompile(`(?i)times completed[^<]* | \s*]*>(\d+)`).FindStringSubmatch(html); len(m) >= 2 {
- detail.Snatched, _ = strconv.Atoi(m[1])
- }
- if m := regexp.MustCompile(`(?i) ]*id="kdescr"[^>]*>(.*?) `).FindStringSubmatch(html); len(m) >= 2 {
- detail.Description = stripHTML(m[1])
- }
- detail.DownloadURL = baseURL + "/download.php?id=" + id
- detail.Free = strings.Contains(html, "free") || strings.Contains(html, "免费")
- return detail, nil
-}
diff --git a/internal/service/site_adapter_nexusphp_list.go b/internal/service/site_adapter_nexusphp_list.go
deleted file mode 100644
index 1d2d553..0000000
--- a/internal/service/site_adapter_nexusphp_list.go
+++ /dev/null
@@ -1,232 +0,0 @@
-package service
-
-import (
- "html"
- "net/url"
- "regexp"
- "strconv"
- "strings"
- "time"
-)
-
-var (
- nexusPHPFreeLabelRE = regexp.MustCompile(`(?i)(class="[^"]*(?:free|free2|twoupfree|free_download)[^"]*"|促销|免费)`)
- nexusPHPRiskLabelRE = regexp.MustCompile(`(?i)(?:class|title|alt)=["'][^"']*\bhr\b[^"']*["']`)
-)
-
-// parseNexusPHPHTML 解析 NexusPHP 种子列表 HTML。
-func parseNexusPHPHTML(html, siteName, baseURL string) (*SiteSearchResult, error) {
- result := &SiteSearchResult{
- SiteName: siteName,
- Items: []TorrentItem{},
- Page: 1,
- }
-
- for _, row := range nexusPHPTorrentRows(html) {
- item := parseNexusPHPRow(row, baseURL)
- if item.ID != "" {
- result.Items = append(result.Items, item)
- }
- }
-
- result.Total = len(result.Items)
- return result, nil
-}
-
-func nexusPHPPageLooksLogin(pageHTML string) bool {
- lower := strings.ToLower(pageHTML)
- if strings.Contains(lower, "details.php") || strings.Contains(lower, "download.php") {
- return false
- }
- for _, marker := range []string{
- "takelogin.php",
- "id=\"loginform\"",
- "id='loginform'",
- "name=\"loginform\"",
- "name='loginform'",
- "type=\"password\"",
- "type='password'",
- } {
- if strings.Contains(lower, marker) {
- return true
- }
- }
- return false
-}
-
-// parseNexusPHPRow 解析单行种子条目。
-func parseNexusPHPRow(row, baseURL string) TorrentItem {
- item := TorrentItem{}
- if link := firstNexusPHPLink(row, "details.php"); link != nil {
- item.ID = link.query.Get("id")
- item.Title = nexusPHPTitleFromLink(*link)
- item.Subtitle = nexusPHPSubtitle(row)
- item.Labels = nexusPHPRowLabels(row)
- item.DetailURL = resolveSiteURL(baseURL, link.href)
- }
- if link := firstNexusPHPLink(row, "download.php"); link != nil {
- item.DownloadURL = resolveSiteURL(baseURL, link.href)
- }
- if sizeMatches := regexp.MustCompile(`(?i)(\d+\.?\d*)\s*(GiB|MiB|TiB|KiB|GB|MB|TB|KB)`).FindStringSubmatch(row); len(sizeMatches) >= 3 {
- item.Size = parseSizeString(sizeMatches[1], sizeMatches[2])
- }
- if value, ok := nexusPHPIntByClass(row, "seeders"); ok {
- item.Seeders = value
- }
- if value, ok := nexusPHPIntByClass(row, "leechers"); ok {
- item.Leechers = value
- }
- if value, ok := nexusPHPIntByClass(row, "snatched"); ok {
- item.Snatched = value
- }
- if item.Snatched == 0 {
- if m := regexp.MustCompile(`snatched[^"]*"[^>]*>(\d+)`).FindStringSubmatch(row); len(m) >= 2 {
- item.Snatched, _ = strconv.Atoi(m[1])
- }
- }
- item.Free = regexp.MustCompile(`(?i)(class="free|free2|twoupfree|free_download|促销|免费)`).MatchString(row)
- if m := regexp.MustCompile(`(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})`).FindStringSubmatch(row); len(m) >= 2 {
- if t, err := time.Parse("2006-01-02 15:04", m[1]); err == nil {
- item.UploadTime = t
- }
- }
- if m := regexp.MustCompile(`cat=(\d+)[^"]*"[^>]*title="([^"]+)"`).FindStringSubmatch(row); len(m) >= 3 {
- item.Category = strings.TrimSpace(m[2])
- }
- return item
-}
-
-type nexusPHPLink struct {
- href string
- attrs string
- text string
- query url.Values
-}
-
-func nexusPHPTorrentRows(pageHTML string) []string {
- rows := regexp.MustCompile(`(?is) | ]*>.*?
`).FindAllString(pageHTML, -1)
- out := make([]string, 0, len(rows))
- for _, row := range rows {
- if strings.Contains(strings.ToLower(row), "details.php") {
- out = append(out, row)
- }
- }
- return out
-}
-
-func firstNexusPHPLink(row, path string) *nexusPHPLink {
- pattern := regexp.MustCompile(`(?is)]*href\s*=\s*["']([^"']*)["'][^>]*)>(.*?)`)
- for _, match := range pattern.FindAllStringSubmatch(row, -1) {
- if len(match) < 4 {
- continue
- }
- href := html.UnescapeString(strings.TrimSpace(match[2]))
- parsed, err := url.Parse(href)
- if err != nil || !nexusPHPLinkPathMatches(parsed, path) {
- continue
- }
- return &nexusPHPLink{
- href: href,
- attrs: match[1],
- text: cleanNexusPHPText(match[3]),
- query: parsed.Query(),
- }
- }
- return nil
-}
-
-func nexusPHPLinkPathMatches(parsed *url.URL, want string) bool {
- if parsed == nil {
- return false
- }
- path := strings.TrimSpace(parsed.Path)
- if path == "" {
- path = strings.TrimSpace(parsed.Opaque)
- }
- path = strings.Trim(strings.ToLower(path), "/")
- want = strings.Trim(strings.ToLower(strings.TrimSpace(want)), "/")
- if path == "" || want == "" {
- return false
- }
- return path == want || strings.HasSuffix(path, "/"+want)
-}
-
-func nexusPHPTitleFromLink(link nexusPHPLink) string {
- for _, attr := range []string{"title", "data-title"} {
- if value := htmlAttr(link.attrs, attr); value != "" {
- return value
- }
- }
- return link.text
-}
-
-func nexusPHPSubtitle(row string) string {
- for _, pattern := range []*regexp.Regexp{
- regexp.MustCompile(`(?is)]*(?:class|id)\s*=\s*["'][^"']*(?:subtitle|small_descr|descr|sub)[^"']*["'][^>]*>(.*?)`),
- regexp.MustCompile(`(?is)]*(?:class|id)\s*=\s*["'][^"']*(?:subtitle|small_descr|descr|sub)[^"']*["'][^>]*>(.*?)`),
- } {
- if match := pattern.FindStringSubmatch(row); len(match) >= 2 {
- return cleanNexusPHPText(match[1])
- }
- }
- return ""
-}
-
-func nexusPHPRowLabels(row string) string {
- labels := make([]string, 0, 4)
- lower := strings.ToLower(row)
- add := func(label string) {
- for _, existing := range labels {
- if existing == label {
- return
- }
- }
- labels = append(labels, label)
- }
- if nexusPHPFreeLabelRE.MatchString(row) {
- add("free")
- }
- if strings.Contains(lower, "hit and run") || strings.Contains(lower, "hit&run") || strings.Contains(lower, "h&r") ||
- nexusPHPRiskLabelRE.MatchString(row) ||
- strings.Contains(row, "禁转") || strings.Contains(row, "禁止转载") || strings.Contains(row, "禁下") || strings.Contains(row, "禁止下载") {
- add("HR")
- }
- return strings.Join(labels, " ")
-}
-
-func nexusPHPIntByClass(row, className string) (int, bool) {
- pattern := regexp.MustCompile(`(?is)]*(?:class|id)\s*=\s*["'][^"']*` + regexp.QuoteMeta(className) + `[^"']*["'][^>]*>(.*?) | `)
- if match := pattern.FindStringSubmatch(row); len(match) >= 2 {
- text := cleanNexusPHPText(match[1])
- valueMatch := regexp.MustCompile(`\d+`).FindString(text)
- if valueMatch != "" {
- value, _ := strconv.Atoi(valueMatch)
- return value, true
- }
- }
- return 0, false
-}
-
-func htmlAttr(attrs, name string) string {
- pattern := regexp.MustCompile(`(?is)\b` + regexp.QuoteMeta(name) + `\s*=\s*["']([^"']*)["']`)
- if match := pattern.FindStringSubmatch(attrs); len(match) >= 2 {
- return cleanNexusPHPText(match[1])
- }
- return ""
-}
-
-func cleanNexusPHPText(value string) string {
- return strings.Join(strings.Fields(html.UnescapeString(stripHTML(value))), " ")
-}
-
-func resolveSiteURL(baseURL, href string) string {
- base, err := url.Parse(strings.TrimRight(baseURL, "/") + "/")
- if err != nil {
- return strings.TrimSpace(href)
- }
- ref, err := url.Parse(strings.TrimSpace(href))
- if err != nil {
- return strings.TrimSpace(href)
- }
- return base.ResolveReference(ref).String()
-}
diff --git a/internal/service/site_adapter_parse.go b/internal/service/site_adapter_parse.go
deleted file mode 100644
index 8e25c50..0000000
--- a/internal/service/site_adapter_parse.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package service
-
-import (
- "regexp"
- "strconv"
- "strings"
-)
-
-func mteamCodeOK(code any) bool {
- codeStr := mteamCodeString(code)
- return codeStr == "0" || codeStr == "200"
-}
-
-func mteamCodeString(code any) string {
- switch v := code.(type) {
- case string:
- return strings.TrimSpace(v)
- case float64:
- return strconv.Itoa(int(v))
- case int:
- return strconv.Itoa(v)
- default:
- return ""
- }
-}
-
-// parseSizeString 将带单位的字符串转换为字节数。
-func parseSizeString(value string, unit string) int64 {
- v, err := strconv.ParseFloat(value, 64)
- if err != nil {
- return 0
- }
- switch strings.TrimSuffix(strings.ToLower(unit), "i") {
- case "kb":
- return int64(v * 1024)
- case "mb":
- return int64(v * 1024 * 1024)
- case "gb":
- return int64(v * 1024 * 1024 * 1024)
- case "tb":
- return int64(v * 1024 * 1024 * 1024 * 1024)
- default:
- return int64(v)
- }
-}
-
-// stripHTML 移除 HTML 标签。
-func stripHTML(s string) string {
- re := regexp.MustCompile(`<[^>]*>`)
- return re.ReplaceAllString(s, "")
-}
diff --git a/internal/service/site_adapter_test.go b/internal/service/site_adapter_test.go
deleted file mode 100644
index ae32043..0000000
--- a/internal/service/site_adapter_test.go
+++ /dev/null
@@ -1,448 +0,0 @@
-package service
-
-import (
- "context"
- "encoding/json"
- "errors"
- "net/http"
- "net/http/httptest"
- "net/url"
- "strings"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestMTeamAuthenticateRequiresAPIKey(t *testing.T) {
- adapter := NewMTeamAdapter()
- err := adapter.Authenticate(context.Background(), SiteConfig{
- URL: "https://api.m-team.cc",
- AuthType: "api_key",
- })
- if err == nil || !strings.Contains(err.Error(), "API Access Token") {
- t.Fatalf("Authenticate error = %v, want API Access Token hint", err)
- }
-}
-
-func TestMTeamAuthenticateUsesOpenAPIKeyHeader(t *testing.T) {
- var gotPath string
- var gotKey string
- var gotPayload map[string]any
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotPath = r.URL.Path
- gotKey = r.Header.Get("x-api-key")
- if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil {
- t.Fatalf("decode request body: %v", err)
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`))
- }))
- defer server.Close()
-
- adapter := NewMTeamAdapter()
- err := adapter.Authenticate(context.Background(), SiteConfig{
- URL: server.URL,
- AuthType: "api_key",
- APIKey: "token-123",
- Timeout: 5 * time.Second,
- })
- if err != nil {
- t.Fatalf("Authenticate returned error: %v", err)
- }
- if gotPath != "/api/torrent/search" {
- t.Fatalf("path = %q, want /api/torrent/search", gotPath)
- }
- if gotKey != "token-123" {
- t.Fatalf("x-api-key = %q, want token-123", gotKey)
- }
- if gotPayload["mode"] != "all" || gotPayload["keyword"] != nil {
- t.Fatalf("payload = %#v, want mode all without keyword probe", gotPayload)
- }
-}
-
-func TestMTeamAuthenticateReportsAPIMessage(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"code":1,"message":"key無效","data":null}`))
- }))
- defer server.Close()
-
- adapter := NewMTeamAdapter()
- err := adapter.Authenticate(context.Background(), SiteConfig{
- URL: server.URL,
- AuthType: "api_key",
- APIKey: "bad-token",
- Timeout: 5 * time.Second,
- })
- if err == nil || !strings.Contains(err.Error(), "key無效") {
- t.Fatalf("Authenticate error = %v, want key invalid message", err)
- }
-}
-
-func TestMTeamAuthenticateHonorsConfiguredTimeout(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- time.Sleep(2 * time.Second)
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`))
- }))
- defer server.Close()
-
- adapter := NewMTeamAdapter()
- started := time.Now()
- err := adapter.Authenticate(context.Background(), SiteConfig{
- URL: server.URL,
- AuthType: "api_key",
- APIKey: "token-123",
- Timeout: time.Second,
- })
- if err == nil {
- t.Fatal("Authenticate error = nil, want timeout")
- }
- if elapsed := time.Since(started); elapsed >= 1500*time.Millisecond {
- t.Fatalf("Authenticate elapsed = %s, want configured timeout to stop before upstream response", elapsed)
- }
- if !strings.Contains(err.Error(), "M-Team API request timed out") {
- t.Fatalf("Authenticate error = %v, want M-Team timeout hint", err)
- }
-}
-
-func TestAPISiteDefaultTimeoutIsRaised(t *testing.T) {
- if got := siteRequestTimeout("mteam", 15); got != 45*time.Second {
- t.Fatalf("mteam timeout = %s, want 45s", got)
- }
- if got := siteRequestTimeout("yemapt", 0); got != 45*time.Second {
- t.Fatalf("yemapt timeout = %s, want 45s", got)
- }
- if got := siteRequestTimeout("nexusphp", 15); got != 15*time.Second {
- t.Fatalf("nexusphp timeout = %s, want 15s", got)
- }
- if got := siteRequestTimeout("mteam", 60); got != 60*time.Second {
- t.Fatalf("custom mteam timeout = %s, want 60s", got)
- }
-}
-
-func TestYemaPTAuthenticateUsesAuthorizationHeader(t *testing.T) {
- var gotPath string
- var gotAuth string
- var gotXAPIKey string
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotPath = r.URL.Path
- gotAuth = r.Header.Get("Authorization")
- gotXAPIKey = r.Header.Get("x-api-key")
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"success":true,"showType":0,"data":{"id":10,"name":"tester"}}`))
- }))
- defer server.Close()
-
- adapter := NewYemaPTAdapter()
- err := adapter.Authenticate(context.Background(), SiteConfig{
- Type: "yemapt",
- URL: server.URL,
- AuthType: "api_key",
- APIKey: "auth-123",
- Timeout: 5 * time.Second,
- })
- if err != nil {
- t.Fatalf("Authenticate returned error: %v", err)
- }
- if gotPath != "/openApi/user/fetchBasicInfo.json" {
- t.Fatalf("path = %q, want /openApi/user/fetchBasicInfo.json", gotPath)
- }
- if gotAuth != "auth-123" {
- t.Fatalf("Authorization = %q, want auth-123", gotAuth)
- }
- if gotXAPIKey != "" {
- t.Fatalf("x-api-key = %q, want empty", gotXAPIKey)
- }
-}
-
-func TestYemaPTAuthenticateReportsAPIMessage(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"success":false,"errorCode":403,"errorMessage":"need api auth"}`))
- }))
- defer server.Close()
-
- adapter := NewYemaPTAdapter()
- err := adapter.Authenticate(context.Background(), SiteConfig{
- Type: "yemapt",
- URL: server.URL,
- AuthType: "api_key",
- APIKey: "bad-auth",
- Timeout: 5 * time.Second,
- })
- if err == nil || !strings.Contains(err.Error(), "need api auth") {
- t.Fatalf("Authenticate error = %v, want need api auth", err)
- }
-}
-
-func TestNewSiteAdapterDetectsYemaPTURL(t *testing.T) {
- adapter := NewSiteAdapter(&model.Site{
- Type: "nexusphp",
- URL: "https://www.yemapt.org",
- })
- if _, ok := adapter.(*YemaPTAdapter); !ok {
- t.Fatalf("adapter = %T, want *YemaPTAdapter", adapter)
- }
-}
-
-func TestBuildRequestAPIKeyHeaderBySite(t *testing.T) {
- yemaReq, err := buildRequest(context.Background(), http.MethodGet, "https://www.yemapt.org/openApi/user/fetchBasicInfo.json", SiteConfig{
- Type: "yemapt",
- URL: "https://www.yemapt.org",
- AuthType: "api_key",
- APIKey: "yema-auth",
- }, nil)
- if err != nil {
- t.Fatal(err)
- }
- if got := yemaReq.Header.Get("Authorization"); got != "yema-auth" {
- t.Fatalf("YemaPT Authorization = %q, want yema-auth", got)
- }
- if got := yemaReq.Header.Get("x-api-key"); got != "" {
- t.Fatalf("YemaPT x-api-key = %q, want empty", got)
- }
-
- mteamReq, err := buildRequest(context.Background(), http.MethodGet, "https://api.m-team.cc/api/torrent/search", SiteConfig{
- Type: "mteam",
- URL: "https://api.m-team.cc",
- AuthType: "api_key",
- APIKey: "mteam-auth",
- }, nil)
- if err != nil {
- t.Fatal(err)
- }
- if got := mteamReq.Header.Get("x-api-key"); got != "mteam-auth" {
- t.Fatalf("M-Team x-api-key = %q, want mteam-auth", got)
- }
- if got := mteamReq.Header.Get("Authorization"); got != "" {
- t.Fatalf("M-Team Authorization = %q, want empty", got)
- }
-}
-
-func TestNexusPHPSearchUsesSearchstr(t *testing.T) {
- var gotQuery string
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotQuery = r.URL.RawQuery
- _, _ = w.Write([]byte(``))
- }))
- defer server.Close()
-
- adapter := NewNexusPHPAdapter()
- result, err := adapter.Search(t.Context(), SiteConfig{
- Name: "Nexus",
- URL: server.URL,
- AuthType: "cookie",
- Cookie: "uid=1; pass=token",
- Timeout: 5 * time.Second,
- }, "测试", 2)
- if err != nil {
- t.Fatalf("Search returned error: %v", err)
- }
- values, err := url.ParseQuery(gotQuery)
- if err != nil {
- t.Fatal(err)
- }
- if values.Get("searchstr") != "测试" || values.Get("search") != "测试" || values.Get("page") != "2" {
- t.Fatalf("query = %q", gotQuery)
- }
- if len(result.Items) != 1 || result.Items[0].Title != "测试资源" {
- t.Fatalf("items = %#v", result.Items)
- }
-}
-
-func TestNexusPHPSearchReportsExpiredCookieLoginPage(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, _ = w.Write([]byte(``))
- }))
- defer server.Close()
-
- adapter := NewNexusPHPAdapter()
- _, err := adapter.Search(t.Context(), SiteConfig{
- Name: "Nexus",
- URL: server.URL,
- AuthType: "cookie",
- Cookie: "uid=1; pass=expired",
- Timeout: 5 * time.Second,
- }, "测试", 1)
- if err == nil || !strings.Contains(err.Error(), "cookie expired") {
- t.Fatalf("Search error = %v, want cookie expired hint", err)
- }
-}
-
-func TestParseNexusPHPHTMLModernRows(t *testing.T) {
- page := `
-
-
- | 电影 |
-
- ignored
- 副标题 & 描述
- 下载
- |
- 12.5 GiB |
- 33 |
- 4 |
- 99 |
-
-
`
- result, err := parseNexusPHPHTML(page, "Nexus", "https://pt.example")
- if err != nil {
- t.Fatal(err)
- }
- if len(result.Items) != 1 {
- t.Fatalf("items = %#v", result.Items)
- }
- item := result.Items[0]
- if item.ID != "456" || item.Title != "Some & Movie 2026 2160p" || item.Subtitle != "副标题 & 描述" {
- t.Fatalf("parsed item = %#v", item)
- }
- if item.DetailURL != "https://pt.example/details.php?id=456&hit=1" || item.DownloadURL != "https://pt.example/download.php?id=456&passkey=abc" {
- t.Fatalf("urls = detail %q download %q", item.DetailURL, item.DownloadURL)
- }
- if item.Seeders != 33 || item.Leechers != 4 || item.Snatched != 99 {
- t.Fatalf("stats = %#v", item)
- }
-}
-
-func TestParseNexusPHPHTMLCapturesRiskAndPromotionLabels(t *testing.T) {
- page := `
-`
- result, err := parseNexusPHPHTML(page, "Nexus", "https://pt.example")
- if err != nil {
- t.Fatal(err)
- }
- if len(result.Items) != 1 {
- t.Fatalf("items = %#v", result.Items)
- }
- item := result.Items[0]
- if !item.Free {
- t.Fatalf("item.Free = false, want free promotion detected: %#v", item)
- }
- if !strings.Contains(item.Labels, "HR") || !strings.Contains(item.Labels, "free") {
- t.Fatalf("labels = %q, want HR and free", item.Labels)
- }
-}
-
-func TestParseNexusPHPHTMLIgnoresUserDetailsLinks(t *testing.T) {
- page := `
-`
- result, err := parseNexusPHPHTML(page, "Nexus", "https://pt.example")
- if err != nil {
- t.Fatal(err)
- }
- if len(result.Items) != 1 {
- t.Fatalf("items = %#v, want only real torrent details row", result.Items)
- }
- if result.Items[0].ID != "789" || result.Items[0].Title != "问心 S01 1080p" {
- t.Fatalf("parsed item = %#v", result.Items[0])
- }
-}
-
-func TestMTeamAPIRateLimits(t *testing.T) {
- search := mteamAPIRateLimits(mteamAPIEndpointSearch)
- if len(search) != 1 || search[0].Limit != 1500 || search[0].Window != 24*time.Hour {
- t.Fatalf("search limits = %#v, want 1500/24h", search)
- }
- detail := mteamAPIRateLimits(mteamAPIEndpointDetail)
- if len(detail) != 1 || detail[0].Limit != 100 || detail[0].Window != time.Hour {
- t.Fatalf("detail limits = %#v, want 100/1h", detail)
- }
- download := mteamAPIRateLimits(mteamAPIEndpointDownload)
- if len(download) != 2 ||
- download[0].Limit != 100 || download[0].Window != time.Hour ||
- download[1].Limit != 1000 || download[1].Window != 24*time.Hour {
- t.Fatalf("download limits = %#v, want 100/1h and 1000/24h", download)
- }
-}
-
-func TestPersistentSiteAPIRateLimiterPersistsSlidingWindow(t *testing.T) {
- db := newServiceTestDB(t, &model.Setting{})
- repos := repository.New(db)
- now := time.Date(2026, 6, 20, 12, 0, 0, 0, time.UTC)
- limiter := newPersistentSiteAPIRateLimiter(repos)
- limiter.now = func() time.Time { return now }
- limit := siteAPIRateLimit{Bucket: "test_1h", Limit: 2, Window: time.Hour}
-
- if err := limiter.Allow(t.Context(), "mteam:test", limit); err != nil {
- t.Fatalf("first allow: %v", err)
- }
- if err := limiter.Allow(t.Context(), "mteam:test", limit); err != nil {
- t.Fatalf("second allow: %v", err)
- }
- err := limiter.Allow(t.Context(), "mteam:test", limit)
- var limited *siteAPIRateLimitError
- if !errors.As(err, &limited) {
- t.Fatalf("third allow error = %v, want siteAPIRateLimitError", err)
- }
- if limited.RetryAfter != time.Hour {
- t.Fatalf("retry_after = %v, want 1h", limited.RetryAfter)
- }
-
- restarted := newPersistentSiteAPIRateLimiter(repos)
- restarted.now = func() time.Time { return now.Add(30 * time.Minute) }
- if err := restarted.Allow(t.Context(), "mteam:test", limit); !errors.As(err, &limited) {
- t.Fatalf("restarted allow error = %v, want persisted limit", err)
- }
-
- restarted.now = func() time.Time { return now.Add(time.Hour + time.Second) }
- if err := restarted.Allow(t.Context(), "mteam:test", limit); err != nil {
- t.Fatalf("allow after window: %v", err)
- }
-}
-
-func TestMTeamRateLimitStopsRequestBeforeHTTP(t *testing.T) {
- var requests atomic.Int32
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requests.Add(1)
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`))
- }))
- defer server.Close()
-
- adapter := NewMTeamAdapter()
- limiter := &staticSiteAPIRateLimiter{err: &siteAPIRateLimitError{
- Bucket: "torrent_search_24h",
- Limit: 1000,
- Window: 24 * time.Hour,
- RetryAfter: time.Hour,
- }}
- _, err := adapter.Search(t.Context(), SiteConfig{
- URL: server.URL,
- AuthType: "api_key",
- APIKey: "token-123",
- Timeout: 5 * time.Second,
- rateLimiter: limiter,
- }, "show", 1)
- if err == nil || !strings.Contains(err.Error(), "rate limit") {
- t.Fatalf("Search error = %v, want rate limit", err)
- }
- if got := requests.Load(); got != 0 {
- t.Fatalf("HTTP requests = %d, want 0", got)
- }
- if limiter.calls != 1 {
- t.Fatalf("limiter calls = %d, want 1", limiter.calls)
- }
-}
-
-type staticSiteAPIRateLimiter struct {
- err error
- calls int
-}
-
-func (l *staticSiteAPIRateLimiter) Allow(context.Context, string, ...siteAPIRateLimit) error {
- l.calls++
- return l.err
-}
diff --git a/internal/service/site_adapter_unit3d.go b/internal/service/site_adapter_unit3d.go
deleted file mode 100644
index f99fb75..0000000
--- a/internal/service/site_adapter_unit3d.go
+++ /dev/null
@@ -1,198 +0,0 @@
-// Package service — UNIT3D site adapter.
-package service
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "net/url"
- "strconv"
- "time"
-)
-
-// ─── UNIT3D 适配器 ───────────────────────────────────────────────────────────
-
-// UNIT3DAdapter UNIT3D 框架适配器。
-type UNIT3DAdapter struct {
- client *http.Client
-}
-
-// NewUNIT3DAdapter 创建 UNIT3D 适配器。
-func NewUNIT3DAdapter() *UNIT3DAdapter {
- return &UNIT3DAdapter{
- client: &http.Client{Timeout: 30 * time.Second},
- }
-}
-
-func (a *UNIT3DAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
- u := cfg.URL + "/api/torrents?limit=1"
- data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
- if err != nil {
- return fmt.Errorf("authenticate: %w", err)
- }
- if status == http.StatusUnauthorized || status == http.StatusForbidden {
- return fmt.Errorf("authentication failed: status %d", status)
- }
- if status != http.StatusOK {
- return fmt.Errorf("authenticate failed: status %d", status)
- }
- var resp map[string]interface{}
- if err := json.Unmarshal(data, &resp); err == nil {
- if errMsg, ok := resp["message"].(string); ok {
- return fmt.Errorf("authentication failed: %s", errMsg)
- }
- }
- return nil
-}
-
-func (a *UNIT3DAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
- params := url.Values{}
- params.Set("search", keyword)
- params.Set("page", strconv.Itoa(page))
-
- u := cfg.URL + "/api/torrents?" + params.Encode()
- data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
- if err != nil {
- return nil, fmt.Errorf("search: %w", err)
- }
- if status != http.StatusOK {
- return nil, fmt.Errorf("search failed: status %d", status)
- }
-
- return parseUNIT3DJSON(data, cfg.Name, cfg.URL)
-}
-
-func (a *UNIT3DAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
- params := url.Values{}
- if category != "" {
- params.Set("category", category)
- }
- params.Set("page", strconv.Itoa(page))
-
- u := cfg.URL + "/api/torrents?" + params.Encode()
- data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
- if err != nil {
- return nil, fmt.Errorf("browse: %w", err)
- }
- if status != http.StatusOK {
- return nil, fmt.Errorf("browse failed: status %d", status)
- }
-
- return parseUNIT3DJSON(data, cfg.Name, cfg.URL)
-}
-
-func (a *UNIT3DAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
- u := cfg.URL + "/api/torrents/" + id
- data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
- if err != nil {
- return nil, fmt.Errorf("detail: %w", err)
- }
- if status != http.StatusOK {
- return nil, fmt.Errorf("detail failed: status %d", status)
- }
-
- var torrent map[string]interface{}
- if err := json.Unmarshal(data, &torrent); err != nil {
- return nil, fmt.Errorf("parse response: %w", err)
- }
-
- detail := &TorrentDetail{
- ID: id,
- DetailURL: cfg.URL + "/torrents/" + id,
- }
-
- if v, ok := torrent["name"].(string); ok {
- detail.Title = v
- }
- if v, ok := torrent["description"].(string); ok {
- detail.Description = stripHTML(v)
- }
- if v, ok := torrent["size"].(float64); ok {
- detail.Size = int64(v)
- }
- if v, ok := torrent["seeders"].(float64); ok {
- detail.Seeders = int(v)
- }
- if v, ok := torrent["leechers"].(float64); ok {
- detail.Leechers = int(v)
- }
- if v, ok := torrent["times_completed"].(float64); ok {
- detail.Snatched = int(v)
- }
- if v, ok := torrent["free"].(bool); ok {
- detail.Free = v
- }
- if v, ok := torrent["info_hash"].(string); ok {
- detail.InfoHash = v
- }
-
- detail.DownloadURL = cfg.URL + "/api/torrents/" + id + "/download"
- return detail, nil
-}
-
-func (a *UNIT3DAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
- return cfg.URL + "/api/torrents/" + id + "/download", nil
-}
-
-// parseUNIT3DJSON 解析 UNIT3D JSON 响应。
-func parseUNIT3DJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
- var resp struct {
- Data []map[string]interface{} `json:"data"`
- Meta struct {
- Total int `json:"total"`
- CurrentPage int `json:"current_page"`
- } `json:"meta"`
- }
- if err := json.Unmarshal(data, &resp); err != nil {
- return nil, fmt.Errorf("parse JSON: %w", err)
- }
-
- result := &SiteSearchResult{
- SiteName: siteName,
- Items: []TorrentItem{},
- Page: resp.Meta.CurrentPage,
- Total: resp.Meta.Total,
- }
-
- for _, t := range resp.Data {
- item := TorrentItem{}
- if v, ok := t["id"].(float64); ok {
- item.ID = strconv.Itoa(int(v))
- }
- if v, ok := t["name"].(string); ok {
- item.Title = v
- }
- if v, ok := t["category"].(map[string]interface{}); ok {
- if name, ok := v["name"].(string); ok {
- item.Category = name
- }
- }
- if v, ok := t["size"].(float64); ok {
- item.Size = int64(v)
- }
- if v, ok := t["seeders"].(float64); ok {
- item.Seeders = int(v)
- }
- if v, ok := t["leechers"].(float64); ok {
- item.Leechers = int(v)
- }
- if v, ok := t["times_completed"].(float64); ok {
- item.Snatched = int(v)
- }
- if v, ok := t["free"].(bool); ok {
- item.Free = v
- }
- if v, ok := t["created_at"].(string); ok {
- if t2, err := time.Parse(time.RFC3339, v); err == nil {
- item.UploadTime = t2
- }
- }
-
- item.DetailURL = baseURL + "/torrents/" + item.ID
- item.DownloadURL = baseURL + "/api/torrents/" + item.ID + "/download"
- result.Items = append(result.Items, item)
- }
-
- return result, nil
-}
diff --git a/internal/service/site_adapter_yemapt.go b/internal/service/site_adapter_yemapt.go
deleted file mode 100644
index d7082f1..0000000
--- a/internal/service/site_adapter_yemapt.go
+++ /dev/null
@@ -1,92 +0,0 @@
-// Package service — YemaPT site adapter.
-package service
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "net/url"
- "strings"
- "time"
-)
-
-type YemaPTAdapter struct {
- client *http.Client
-}
-
-func NewYemaPTAdapter() *YemaPTAdapter {
- return &YemaPTAdapter{
- client: &http.Client{Timeout: 30 * time.Second},
- }
-}
-
-func (a *YemaPTAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
- if strings.TrimSpace(cfg.APIKey) == "" {
- return errors.New("YemaPT 需要填写个人详情页创建的第三方对接专用 API Auth Key")
- }
- u := strings.TrimRight(cfg.URL, "/") + "/openApi/user/fetchBasicInfo.json"
- data, status, err := doRequestJSON(ctx, a.client, http.MethodGet, u, cfg, nil)
- if err != nil {
- return fmt.Errorf("yemapt authenticate: %w", err)
- }
- if status != http.StatusOK {
- return fmt.Errorf("yemapt authenticate failed: status %d", status)
- }
- var resp yemaPTAPIResponse
- if err := json.Unmarshal(data, &resp); err != nil {
- return fmt.Errorf("yemapt authenticate parse: %w", err)
- }
- if resp.Success {
- return nil
- }
- if resp.ErrorMessage != "" {
- return fmt.Errorf("yemapt authenticate failed: %s", resp.ErrorMessage)
- }
- if resp.ErrorCode != 0 {
- return fmt.Errorf("yemapt authenticate failed: errorCode=%d", resp.ErrorCode)
- }
- return errors.New("yemapt authenticate failed")
-}
-
-func (a *YemaPTAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
- return nil, errYemaPTTorrentOpenAPIUnsupported()
-}
-
-func (a *YemaPTAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
- return nil, errYemaPTTorrentOpenAPIUnsupported()
-}
-
-func (a *YemaPTAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
- return nil, errYemaPTTorrentOpenAPIUnsupported()
-}
-
-func (a *YemaPTAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
- return "", errYemaPTTorrentOpenAPIUnsupported()
-}
-
-type yemaPTAPIResponse struct {
- Success bool `json:"success"`
- ShowType int `json:"showType"`
- ErrorCode int `json:"errorCode"`
- ErrorMessage string `json:"errorMessage"`
- Data json.RawMessage `json:"data"`
-}
-
-func errYemaPTTorrentOpenAPIUnsupported() error {
- return errors.New("YemaPT 当前公开 OpenAPI 未提供种子搜索/详情/下载接口")
-}
-
-func isYemaPTConfig(cfg SiteConfig) bool {
- return strings.EqualFold(strings.TrimSpace(cfg.Type), "yemapt") || isYemaPTURL(cfg.URL)
-}
-
-func isYemaPTURL(raw string) bool {
- u, err := url.Parse(strings.TrimSpace(raw))
- if err != nil {
- return false
- }
- host := strings.ToLower(u.Hostname())
- return host == "yemapt.org" || strings.HasSuffix(host, ".yemapt.org")
-}
diff --git a/internal/service/site_connection.go b/internal/service/site_connection.go
deleted file mode 100644
index 7ecd469..0000000
--- a/internal/service/site_connection.go
+++ /dev/null
@@ -1,99 +0,0 @@
-package service
-
-import (
- "context"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/helper"
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-// TestConnection tries to reach the site's base URL with the configured
-// credentials and reports success/failure.
-//
-// 测试逻辑(与旧版参考实现对齐):
-//
-// 1. 优先调用对应站点适配器的 Authenticate(),让 PT 站点(M-Team / UNIT3D /
-// Gazelle 等)使用各自的开放 API 验证,而不是去拉首页 HTML——后者通常
-// 被 Cloudflare 直接 403 但 API 能正常访问。
-// 2. 适配器不可用或站点类型未知时,回退到 helper.TestSiteConnectivity 的
-// 通用浏览器头 GET 方案。
-// 3. helper.TestSiteConnectivity 在全局 FlareSolverr 启用且站点开启了
-// BrowserEmulation 时,会自动走 FlareSolverr。
-func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, string, error) {
- site, err := s.FindByID(ctx, id)
- if err != nil || site == nil {
- return false, "site not found", err
- }
-
- flareSolverrURL := s.flareSolverrURL
-
- // ── Path 1: site-aware adapter Authenticate ────────────────────────
- // custom_rss 没有真适配器,跳过;其它类型先尝试针对性认证端点。
- if adapter := NewSiteAdapter(site); adapter != nil && site.Type != "" && site.Type != "custom_rss" {
- cfg := s.siteModelToConfig(site)
- actx, cancel := context.WithTimeout(ctx, cfg.Timeout)
- defer cancel()
- if authErr := adapter.Authenticate(actx, cfg); authErr == nil {
- now := time.Now()
- _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).
- Updates(map[string]any{
- "login_status": "ok",
- "last_error": "",
- "last_check_at": &now,
- }).Error
- return true, "连接成功", nil
- } else {
- if site.Type == "mteam" || site.Type == "yemapt" || isYemaPTURL(site.URL) {
- s.log.Warn("site adapter authenticate failed",
- zap.String("site", site.Name),
- zap.String("type", site.Type),
- zap.Error(authErr))
- now := time.Now()
- _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).
- Updates(map[string]any{
- "login_status": "fail",
- "last_error": authErr.Error(),
- "last_check_at": &now,
- }).Error
- return false, authErr.Error(), nil
- }
- s.log.Warn("site adapter authenticate failed, falling back to generic test",
- zap.String("site", site.Name),
- zap.String("type", site.Type),
- zap.Error(authErr))
- // 回退到通用 GET 测试 — 给 Cookie/RSS 类站点一个机会
- }
- }
-
- // ── Path 2: generic GET with browser headers / FlareSolverr ───────
- timeout := int(siteRequestTimeout(site.Type, site.Timeout).Seconds())
- ok, msg, err := helper.TestSiteConnectivity(site, flareSolverrURL, timeout, s.log)
- if err != nil {
- now := time.Now()
- _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).
- Updates(map[string]any{
- "login_status": "fail",
- "last_error": err.Error(),
- "last_check_at": &now,
- }).Error
- return false, err.Error(), nil
- }
-
- loginStatus := "ok"
- storedError := ""
- if !ok {
- loginStatus = "fail"
- storedError = msg
- }
- now := time.Now()
- _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).
- Updates(map[string]any{
- "login_status": loginStatus,
- "last_error": storedError,
- "last_check_at": &now,
- }).Error
- return ok, msg, nil
-}
diff --git a/internal/service/site_crud.go b/internal/service/site_crud.go
deleted file mode 100644
index 46a2183..0000000
--- a/internal/service/site_crud.go
+++ /dev/null
@@ -1,161 +0,0 @@
-package service
-
-import (
- "context"
- "encoding/json"
- "errors"
- "strings"
- "time"
-
- "gorm.io/gorm"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-// Create persists a new site.
-func (s *SiteService) Create(ctx context.Context, site *model.Site) error {
- if strings.TrimSpace(site.Name) == "" || strings.TrimSpace(site.URL) == "" {
- return errors.New("name and url required")
- }
- site.URL = strings.TrimRight(site.URL, "/")
- if site.Type == "" {
- site.Type = "nexusphp"
- }
- if site.AuthType == "" {
- site.AuthType = "cookie"
- }
- return s.repo.DB.WithContext(ctx).Create(site).Error
-}
-
-// List returns every site ordered by created_at.
-func (s *SiteService) List(ctx context.Context) ([]model.Site, error) {
- var sites []model.Site
- err := s.repo.DB.WithContext(ctx).Order("created_at asc").Find(&sites).Error
- if sites == nil {
- sites = []model.Site{}
- }
- return sites, err
-}
-
-// FindByID returns a single site or nil.
-func (s *SiteService) FindByID(ctx context.Context, id string) (*model.Site, error) {
- var site model.Site
- err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&site).Error
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return nil, nil
- }
- return &site, err
-}
-
-// siteUpdatableFields is the whitelist of columns that may be patched via
-// the update endpoint. Fields like id, created_at, deleted_at, login_status,
-// upload_bytes, download_bytes are excluded to prevent injection.
-var siteUpdatableFields = map[string]bool{
- "name": true,
- "url": true,
- "type": true,
- "auth_type": true,
- "api_key": true,
- "cookie": true,
- "auth_header": true,
- "user_agent": true,
- "rss_url": true,
- "timeout": true,
- "priority": true,
- "use_proxy": true,
- "rate_limit": true,
- "browser_emulation": true,
- "downloader": true,
- "enabled": true,
- "is_default": true,
- "extra": true,
-}
-
-// Update applies a partial patch to an existing site.
-func (s *SiteService) Update(ctx context.Context, id string, updates map[string]any) error {
- if id == "" {
- return errors.New("site id required")
- }
- filtered := make(map[string]any, len(updates))
- for k, v := range updates {
- if siteUpdatableFields[k] {
- filtered[k] = v
- }
- }
- if len(filtered) == 0 {
- return errors.New("no valid fields to update")
- }
- if raw, ok := filtered["url"].(string); ok {
- filtered["url"] = strings.TrimRight(strings.TrimSpace(raw), "/")
- }
- for _, key := range []string{"api_key", "cookie", "auth_header"} {
- if raw, ok := filtered[key].(string); ok && strings.TrimSpace(raw) == "" {
- delete(filtered, key)
- }
- }
- return s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).Updates(filtered).Error
-}
-
-// Delete removes a site.
-func (s *SiteService) Delete(ctx context.Context, id string) error {
- return s.repo.DB.WithContext(ctx).Where("id = ?", id).Delete(&model.Site{}).Error
-}
-
-// siteModelToConfig 将 model.Site 转换为适配器使用的 SiteConfig。
-// 当全局 FlareSolverr 已启用且此站点开启了 BrowserEmulation 时,填充 FlareSolverrURL。
-func (svc *SiteService) siteModelToConfig(s *model.Site) SiteConfig {
- timeout := siteRequestTimeout(s.Type, s.Timeout)
- userAgent := s.UserAgent
- if userAgent == "" {
- userAgent = model.DefaultUserAgent
- }
- var extra map[string]string
- if s.Extra != "" {
- _ = json.Unmarshal([]byte(s.Extra), &extra)
- }
-
- // Per-site FlareSolverr opt-in: only when global FlareSolverr is enabled
- // AND this site has BrowserEmulation turned on.
- flareSolverrURL := ""
- if svc.flareSolverrURL != "" && s.BrowserEmulation {
- flareSolverrURL = svc.flareSolverrURL
- }
-
- return SiteConfig{
- SiteID: s.ID,
- Name: s.Name,
- Type: s.Type,
- URL: s.URL,
- AuthType: s.AuthType,
- Cookie: s.Cookie,
- APIKey: s.APIKey,
- AuthHeader: s.AuthHeader,
- UserAgent: userAgent,
- Timeout: timeout,
- Extra: extra,
- FlareSolverrURL: flareSolverrURL,
- UseProxy: s.UseProxy,
- RateLimit: s.RateLimit,
- rateLimiter: svc.apiRateLimiter,
- }
-}
-
-func siteRequestTimeout(siteType string, timeoutSeconds int) time.Duration {
- timeout := time.Duration(timeoutSeconds) * time.Second
- if timeout <= 0 {
- timeout = 15 * time.Second
- }
- if isAPISiteType(siteType) && timeout <= 15*time.Second {
- return 45 * time.Second
- }
- return timeout
-}
-
-func isAPISiteType(siteType string) bool {
- switch strings.ToLower(strings.TrimSpace(siteType)) {
- case "mteam", "yemapt":
- return true
- default:
- return false
- }
-}
diff --git a/internal/service/site_download.go b/internal/service/site_download.go
deleted file mode 100644
index 4e3bc74..0000000
--- a/internal/service/site_download.go
+++ /dev/null
@@ -1,179 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "mime"
- "net/http"
- "net/url"
- "path"
- "strings"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-// ResolveDownloadURL converts tracker-specific search result URLs into a URL
-// that a downloader can fetch directly. M-Team, NexusPHP and similar sites
-// often expose a signed/detail endpoint in search results; qBittorrent cannot
-// call those APIs with the configured site credentials, so subscriptions need
-// the same resolution path as the manual download button.
-func (s *SiteService) ResolveDownloadURL(ctx context.Context, raw string) string {
- if strings.TrimSpace(raw) == "" {
- return raw
- }
- matched := s.matchSiteForURL(ctx, raw)
- if matched == nil {
- return raw
- }
-
- u, err := url.Parse(raw)
- if err != nil || u.Host == "" {
- return raw
- }
- id := u.Query().Get("id")
- if id == "" {
- return raw
- }
- adapter := GetAdapterForType(matched.Type)
- if adapter == nil {
- return raw
- }
- cfg := s.siteModelToConfig(matched)
- timeout := cfg.Timeout
- if timeout <= 0 {
- timeout = 15 * time.Second
- }
- resolveCtx, cancel := context.WithTimeout(ctx, timeout)
- defer cancel()
- resolved, err := adapter.GetDownloadURL(resolveCtx, cfg, id)
- if err != nil || resolved == "" {
- if s.log != nil {
- s.log.Warn("resolve PT download URL failed",
- zap.String("site", matched.Name),
- zap.String("raw", redactSensitiveDownloadURL(raw)),
- zap.Error(err))
- }
- return raw
- }
- return resolved
-}
-
-func redactSensitiveDownloadURL(raw string) string {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return ""
- }
- if strings.HasPrefix(strings.ToLower(raw), "magnet:") {
- return "magnet:?xt=***"
- }
- u, err := url.Parse(raw)
- if err != nil || u.Host == "" {
- return "[redacted-download-url]"
- }
- u.RawQuery = ""
- u.Fragment = ""
- return u.String()
-}
-
-func (s *SiteService) FetchTorrentFile(ctx context.Context, raw string) ([]byte, string, error) {
- parsed, err := url.Parse(strings.TrimSpace(raw))
- if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
- return nil, "", errors.New("no matching PT site for torrent URL")
- }
- matched := s.matchSiteForURL(ctx, raw)
- cfg := SiteConfig{Timeout: 30 * time.Second}
- if matched != nil {
- cfg = s.siteModelToConfig(matched)
- }
- timeout := cfg.Timeout
- if timeout <= 0 {
- timeout = 30 * time.Second
- }
- req, err := buildRequest(ctx, http.MethodGet, raw, cfg, nil)
- if err != nil {
- return nil, "", err
- }
- req.Header.Set("Accept", "application/x-bittorrent,application/octet-stream,*/*")
- client := newHTTPClient(cfg, timeout)
- resp, err := client.Do(req)
- if err != nil {
- return nil, "", err
- }
- defer resp.Body.Close()
- if resp.StatusCode >= 400 {
- return nil, "", fmt.Errorf("torrent fetch: HTTP %d", resp.StatusCode)
- }
- const maxTorrentSize = 32 << 20
- data, err := io.ReadAll(io.LimitReader(resp.Body, maxTorrentSize+1))
- if err != nil {
- return nil, "", err
- }
- if len(data) == 0 {
- return nil, "", errors.New("torrent fetch: empty body")
- }
- if len(data) > maxTorrentSize {
- return nil, "", errors.New("torrent fetch: body too large")
- }
- if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") {
- return nil, "", errors.New("torrent fetch: upstream returned HTML")
- }
- if torrentInfoHash(data) == "" {
- return nil, "", errors.New("torrent fetch: upstream did not return a valid torrent")
- }
- return data, torrentFilename(raw, resp.Header.Get("Content-Disposition")), nil
-}
-
-func (s *SiteService) matchSiteForURL(ctx context.Context, raw string) *model.Site {
- u, err := url.Parse(raw)
- if err != nil || u.Host == "" {
- return nil
- }
- host := strings.ToLower(u.Host)
-
- sites, err := s.List(ctx)
- if err != nil || len(sites) == 0 {
- return nil
- }
- for i := range sites {
- if siteHostMatches(host, sites[i].URL) || siteHostMatches(host, sites[i].RSSURL) {
- return &sites[i]
- }
- }
- return nil
-}
-
-func siteHostMatches(host, raw string) bool {
- if raw == "" {
- return false
- }
- u, err := url.Parse(raw)
- if err != nil || u.Host == "" {
- return false
- }
- siteHost := strings.ToLower(u.Host)
- return strings.EqualFold(siteHost, host) || strings.HasSuffix(host, "."+siteHost)
-}
-
-func torrentFilename(rawURL, disposition string) string {
- if disposition != "" {
- if _, params, err := mime.ParseMediaType(disposition); err == nil {
- if filename := strings.TrimSpace(params["filename"]); filename != "" {
- return filename
- }
- }
- }
- if u, err := url.Parse(rawURL); err == nil {
- if name := strings.TrimSpace(path.Base(u.Path)); name != "" && name != "." && name != "/" {
- if !strings.HasSuffix(strings.ToLower(name), ".torrent") {
- name += ".torrent"
- }
- return name
- }
- }
- return "download.torrent"
-}
diff --git a/internal/service/site_rate_limiter.go b/internal/service/site_rate_limiter.go
deleted file mode 100644
index 322cde0..0000000
--- a/internal/service/site_rate_limiter.go
+++ /dev/null
@@ -1,280 +0,0 @@
-package service
-
-import (
- "context"
- "crypto/sha1"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "sort"
- "strings"
- "sync"
- "time"
-
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-const (
- mteamAPIEndpointSearch = "torrent_search"
- mteamAPIEndpointDetail = "torrent_detail"
- mteamAPIEndpointDownload = "torrent_download"
-
- mteamAPISearchDailyLimit = 1500
-)
-
-type siteAPIRateLimit struct {
- Bucket string
- Limit int
- Window time.Duration
-}
-
-type siteAPIRateLimiter interface {
- Allow(ctx context.Context, siteKey string, limits ...siteAPIRateLimit) error
-}
-
-type siteAPIRateLimitError struct {
- SiteKey string
- Bucket string
- Limit int
- Window time.Duration
- RetryAfter time.Duration
-}
-
-func (e *siteAPIRateLimitError) Error() string {
- if e == nil {
- return ""
- }
- return fmt.Sprintf("M-Team API rate limit reached for %s: %d requests per %s, retry after %s",
- e.Bucket, e.Limit, formatRateLimitDuration(e.Window), formatRateLimitDuration(e.RetryAfter))
-}
-
-type persistentSiteAPIRateLimiter struct {
- repo *repository.Container
- fallback *memorySiteAPIRateLimiter
- now func() time.Time
- mu sync.Mutex
-}
-
-func newPersistentSiteAPIRateLimiter(repo *repository.Container) *persistentSiteAPIRateLimiter {
- return &persistentSiteAPIRateLimiter{
- repo: repo,
- fallback: newMemorySiteAPIRateLimiter(time.Now),
- now: time.Now,
- }
-}
-
-func (l *persistentSiteAPIRateLimiter) Allow(ctx context.Context, siteKey string, limits ...siteAPIRateLimit) error {
- if err := ctx.Err(); err != nil {
- return err
- }
- if l == nil || l.repo == nil || l.repo.Setting == nil {
- if l != nil && l.fallback != nil {
- return l.fallback.Allow(ctx, siteKey, limits...)
- }
- return defaultMemorySiteAPIRateLimiter.Allow(ctx, siteKey, limits...)
- }
- l.mu.Lock()
- defer l.mu.Unlock()
-
- now := time.Now()
- if l.now != nil {
- now = l.now()
- }
- records := make([]siteAPIRateLimitRecord, 0, len(limits))
- for _, limit := range normalizeSiteAPIRateLimits(limits) {
- key := siteAPIRateLimitSettingKey(siteKey, limit.Bucket)
- raw, err := l.repo.Setting.Get(ctx, key)
- if err != nil {
- return err
- }
- timestamps := pruneSiteAPIRateTimestamps(parseSiteAPIRateTimestamps(raw), now, limit.Window)
- if err := checkSiteAPIRateLimit(siteKey, limit, timestamps, now); err != nil {
- return err
- }
- records = append(records, siteAPIRateLimitRecord{key: key, timestamps: timestamps})
- }
- nowUnix := now.Unix()
- for _, record := range records {
- next := append(record.timestamps, nowUnix)
- if err := l.repo.Setting.Set(ctx, record.key, encodeSiteAPIRateTimestamps(next)); err != nil {
- return err
- }
- }
- return nil
-}
-
-type memorySiteAPIRateLimiter struct {
- now func() time.Time
- mu sync.Mutex
- buckets map[string][]int64
-}
-
-var defaultMemorySiteAPIRateLimiter = newMemorySiteAPIRateLimiter(time.Now)
-
-func newMemorySiteAPIRateLimiter(now func() time.Time) *memorySiteAPIRateLimiter {
- if now == nil {
- now = time.Now
- }
- return &memorySiteAPIRateLimiter{now: now, buckets: map[string][]int64{}}
-}
-
-func (l *memorySiteAPIRateLimiter) Allow(ctx context.Context, siteKey string, limits ...siteAPIRateLimit) error {
- if err := ctx.Err(); err != nil {
- return err
- }
- if l == nil {
- return nil
- }
- l.mu.Lock()
- defer l.mu.Unlock()
-
- now := l.now()
- records := make([]siteAPIRateLimitRecord, 0, len(limits))
- for _, limit := range normalizeSiteAPIRateLimits(limits) {
- key := siteAPIRateLimitSettingKey(siteKey, limit.Bucket)
- timestamps := pruneSiteAPIRateTimestamps(l.buckets[key], now, limit.Window)
- if err := checkSiteAPIRateLimit(siteKey, limit, timestamps, now); err != nil {
- return err
- }
- records = append(records, siteAPIRateLimitRecord{key: key, timestamps: timestamps})
- }
- nowUnix := now.Unix()
- for _, record := range records {
- l.buckets[record.key] = append(record.timestamps, nowUnix)
- }
- return nil
-}
-
-type siteAPIRateLimitRecord struct {
- key string
- timestamps []int64
-}
-
-func reserveMTeamAPIQuota(ctx context.Context, cfg SiteConfig, endpoint string) error {
- limits := mteamAPIRateLimits(endpoint)
- if len(limits) == 0 {
- return nil
- }
- // M-Team's published API quotas are upstream hard limits, so protect them
- // regardless of the generic per-site RateLimit toggle.
- limiter := cfg.rateLimiter
- if limiter == nil {
- limiter = defaultMemorySiteAPIRateLimiter
- }
- return limiter.Allow(ctx, mteamAPIRateSiteKey(cfg), limits...)
-}
-
-func mteamAPIRateLimits(endpoint string) []siteAPIRateLimit {
- switch endpoint {
- case mteamAPIEndpointSearch:
- return []siteAPIRateLimit{{Bucket: "torrent_search_24h", Limit: mteamAPISearchDailyLimit, Window: 24 * time.Hour}}
- case mteamAPIEndpointDetail:
- return []siteAPIRateLimit{{Bucket: "torrent_detail_1h", Limit: 100, Window: time.Hour}}
- case mteamAPIEndpointDownload:
- return []siteAPIRateLimit{
- {Bucket: "torrent_download_1h", Limit: 100, Window: time.Hour},
- {Bucket: "torrent_download_24h", Limit: 1000, Window: 24 * time.Hour},
- }
- default:
- return nil
- }
-}
-
-func mteamAPIRateSiteKey(cfg SiteConfig) string {
- base := strings.TrimRight(strings.ToLower(strings.TrimSpace(cfg.URL)), "/")
- if base == "" {
- base = "mteam"
- }
- if apiKey := strings.TrimSpace(cfg.APIKey); apiKey != "" {
- sum := sha1.Sum([]byte(apiKey))
- return base + "|api:" + hex.EncodeToString(sum[:])
- }
- if siteID := strings.TrimSpace(cfg.SiteID); siteID != "" {
- return base + "|site:" + siteID
- }
- if name := strings.TrimSpace(cfg.Name); name != "" {
- return base + "|name:" + strings.ToLower(name)
- }
- return base
-}
-
-func normalizeSiteAPIRateLimits(limits []siteAPIRateLimit) []siteAPIRateLimit {
- out := make([]siteAPIRateLimit, 0, len(limits))
- for _, limit := range limits {
- limit.Bucket = strings.TrimSpace(limit.Bucket)
- if limit.Bucket == "" || limit.Limit <= 0 || limit.Window <= 0 {
- continue
- }
- out = append(out, limit)
- }
- return out
-}
-
-func checkSiteAPIRateLimit(siteKey string, limit siteAPIRateLimit, timestamps []int64, now time.Time) error {
- if len(timestamps) < limit.Limit {
- return nil
- }
- oldest := time.Unix(timestamps[0], 0)
- retryAfter := oldest.Add(limit.Window).Sub(now)
- if retryAfter < time.Second {
- retryAfter = time.Second
- }
- return &siteAPIRateLimitError{
- SiteKey: siteKey,
- Bucket: limit.Bucket,
- Limit: limit.Limit,
- Window: limit.Window,
- RetryAfter: retryAfter,
- }
-}
-
-func siteAPIRateLimitSettingKey(siteKey, bucket string) string {
- sum := sha1.Sum([]byte(siteKey))
- return "site.api_rate." + hex.EncodeToString(sum[:])[:20] + "." + bucket
-}
-
-func parseSiteAPIRateTimestamps(raw string) []int64 {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return nil
- }
- var out []int64
- if err := json.Unmarshal([]byte(raw), &out); err != nil {
- return nil
- }
- return out
-}
-
-func encodeSiteAPIRateTimestamps(values []int64) string {
- data, _ := json.Marshal(values)
- return string(data)
-}
-
-func pruneSiteAPIRateTimestamps(values []int64, now time.Time, window time.Duration) []int64 {
- if len(values) == 0 {
- return nil
- }
- cutoff := now.Add(-window).Unix()
- out := make([]int64, 0, len(values))
- for _, value := range values {
- if value > cutoff && value <= now.Add(time.Minute).Unix() {
- out = append(out, value)
- }
- }
- sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
- return out
-}
-
-func formatRateLimitDuration(value time.Duration) string {
- if value < 0 {
- value = 0
- }
- value = value.Round(time.Second)
- if value%time.Hour == 0 && value >= time.Hour {
- return fmt.Sprintf("%dh", int(value/time.Hour))
- }
- if value%time.Minute == 0 && value >= time.Minute {
- return fmt.Sprintf("%dm", int(value/time.Minute))
- }
- return value.String()
-}
diff --git a/internal/service/site_search.go b/internal/service/site_search.go
deleted file mode 100644
index 53bd491..0000000
--- a/internal/service/site_search.go
+++ /dev/null
@@ -1,209 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "sort"
- "strings"
- "sync"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-// SearchResult is one torrent returned by a site adapter search.
-type SearchResult struct {
- SiteName string `json:"site_name"`
- SiteID string `json:"site_id"`
- Title string `json:"title"`
- Subtitle string `json:"subtitle,omitempty"`
- Labels string `json:"labels,omitempty"`
- TorrentURL string `json:"torrent_url"`
- DownloadURL string `json:"download_url"`
- Category string `json:"category,omitempty"`
- SearchKeyword string `json:"search_keyword,omitempty"`
- Size int64 `json:"size"`
- Seeders int `json:"seeders"`
- Leechers int `json:"leechers"`
- Free bool `json:"free"`
-}
-
-// Search fans out a keyword query to every enabled site and returns
-// merged results sorted by seeders descending.
-// Uses concurrent search with sync.WaitGroup for performance.
-func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResult, error) {
- if strings.TrimSpace(keyword) == "" {
- return []SearchResult{}, nil
- }
- sites, err := s.List(ctx)
- if err != nil {
- return nil, err
- }
-
- var (
- mu sync.Mutex
- wg sync.WaitGroup
- enabledCount int
- failedCount int
- failureErrs []error
- failures []string
- results []SearchResult
- )
-
- for i := range sites {
- if !sites[i].Enabled {
- continue
- }
- enabledCount++
- wg.Add(1)
- go func(site model.Site) {
- defer wg.Done()
-
- adapter := NewSiteAdapter(&site)
- if adapter == nil {
- mu.Lock()
- failedCount++
- err := fmt.Errorf("%s: unsupported site type %s", site.Name, site.Type)
- failureErrs = append(failureErrs, err)
- failures = append(failures, err.Error())
- mu.Unlock()
- return
- }
-
- cfg := s.siteModelToConfig(&site)
- timeout := cfg.Timeout
- ctxWithTimeout, cancel := context.WithTimeout(ctx, timeout)
- defer cancel()
-
- result, err := adapter.Search(ctxWithTimeout, cfg, keyword, 1)
- if err != nil {
- mu.Lock()
- failedCount++
- failureErr := fmt.Errorf("%s: %w", site.Name, err)
- failureErrs = append(failureErrs, failureErr)
- failures = append(failures, failureErr.Error())
- mu.Unlock()
- s.log.Warn("site search failed",
- zap.String("site", site.Name),
- zap.String("type", site.Type),
- zap.String("url", site.URL),
- zap.String("keyword", keyword),
- zap.Duration("timeout", timeout),
- zap.Error(err))
- return
- }
- if result == nil {
- return
- }
- siteResults := siteSearchResultsFromItems(site, result, keyword)
- mu.Lock()
- results = append(results, siteResults...)
- mu.Unlock()
- }(sites[i])
- }
- wg.Wait()
-
- // Ensure results is never nil (return [] instead of null in JSON)
- if results == nil {
- results = []SearchResult{}
- }
-
- // Sort by seeders desc.
- sort.Slice(results, func(i, j int) bool {
- return results[i].Seeders > results[j].Seeders
- })
- if s.log != nil {
- s.log.Info("site search completed",
- zap.String("keyword", keyword),
- zap.Int("enabled_sites", enabledCount),
- zap.Int("failed_sites", failedCount),
- zap.Int("results_count", len(results)))
- }
- if enabledCount > 0 && failedCount >= enabledCount && len(results) == 0 {
- if len(failureErrs) > 0 {
- return results, fmt.Errorf("all enabled sites failed while searching %q: %w", keyword, errors.Join(failureErrs...))
- }
- return results, fmt.Errorf("all enabled sites failed while searching %q: %s", keyword, strings.Join(failures, "; "))
- }
- return results, nil
-}
-
-// SearchSite runs a keyword search against one configured site, regardless of
-// whether the site is enabled globally. This is used by per-site diagnostics in
-// the management UI, where the user expects the selected site to be tested
-// directly instead of a full fan-out followed by filtering.
-func (s *SiteService) SearchSite(ctx context.Context, siteID, keyword string, page int) ([]SearchResult, error) {
- if strings.TrimSpace(keyword) == "" {
- return []SearchResult{}, nil
- }
- if page <= 0 {
- page = 1
- }
- site, err := s.FindByID(ctx, siteID)
- if err != nil {
- return nil, err
- }
- if site == nil {
- return nil, fmt.Errorf("site not found")
- }
- adapter := NewSiteAdapter(site)
- if adapter == nil {
- return nil, fmt.Errorf("%s: unsupported site type %s", site.Name, site.Type)
- }
- cfg := s.siteModelToConfig(site)
- timeout := cfg.Timeout
- ctxWithTimeout, cancel := context.WithTimeout(ctx, timeout)
- defer cancel()
-
- result, err := adapter.Search(ctxWithTimeout, cfg, keyword, page)
- if err != nil {
- if s.log != nil {
- s.log.Warn("single site search failed",
- zap.String("site", site.Name),
- zap.String("type", site.Type),
- zap.String("url", site.URL),
- zap.String("keyword", keyword),
- zap.Duration("timeout", timeout),
- zap.Error(err))
- }
- return nil, err
- }
- out := siteSearchResultsFromItems(*site, result, keyword)
- sort.Slice(out, func(i, j int) bool {
- return out[i].Seeders > out[j].Seeders
- })
- if s.log != nil {
- s.log.Info("single site search completed",
- zap.String("site", site.Name),
- zap.String("keyword", keyword),
- zap.Int("results_count", len(out)))
- }
- return out, nil
-}
-
-func siteSearchResultsFromItems(site model.Site, result *SiteSearchResult, keyword string) []SearchResult {
- if result == nil || len(result.Items) == 0 {
- return []SearchResult{}
- }
- out := make([]SearchResult, 0, len(result.Items))
- for _, item := range result.Items {
- out = append(out, SearchResult{
- SiteName: site.Name,
- SiteID: site.ID,
- Title: item.Title,
- Subtitle: item.Subtitle,
- Labels: item.Labels,
- TorrentURL: item.DetailURL,
- DownloadURL: item.DownloadURL,
- Category: item.Category,
- SearchKeyword: keyword,
- Size: item.Size,
- Seeders: item.Seeders,
- Leechers: item.Leechers,
- Free: item.Free,
- })
- }
- return out
-}
diff --git a/internal/service/site_test.go b/internal/service/site_test.go
deleted file mode 100644
index 82894a2..0000000
--- a/internal/service/site_test.go
+++ /dev/null
@@ -1,184 +0,0 @@
-package service
-
-import (
- "context"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestSiteUpdateKeepsSecretsWhenPatchIsBlank(t *testing.T) {
- db := newServiceTestDB(t, &model.Site{})
- svc := NewSiteService(zap.NewNop(), &repository.Container{DB: db}, "")
- site := &model.Site{
- Name: "M-Team",
- Type: "mteam",
- URL: "https://api.m-team.cc",
- AuthType: "api_key",
- APIKey: "token-123",
- Enabled: true,
- }
- if err := svc.Create(context.Background(), site); err != nil {
- t.Fatal(err)
- }
-
- if err := svc.Update(context.Background(), site.ID, map[string]any{
- "url": "https://api.m-team.cc/",
- "api_key": "",
- "cookie": "",
- }); err != nil {
- t.Fatal(err)
- }
-
- got, err := svc.FindByID(context.Background(), site.ID)
- if err != nil {
- t.Fatal(err)
- }
- if got.APIKey != "token-123" {
- t.Fatalf("APIKey = %q, want original token", got.APIKey)
- }
- if got.URL != "https://api.m-team.cc" {
- t.Fatalf("URL = %q, want trimmed URL", got.URL)
- }
-}
-
-func TestYemaPTTestConnectionDoesNotFallbackAfterAuthFailure(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"success":false,"errorCode":403,"errorMessage":"need api auth"}`))
- }))
- defer server.Close()
-
- db := newServiceTestDB(t, &model.Site{})
- repos := repository.New(db)
- svc := NewSiteService(zap.NewNop(), repos, "")
- site := &model.Site{
- Name: "YemaPT",
- Type: "yemapt",
- URL: server.URL,
- AuthType: "api_key",
- APIKey: "bad-auth",
- Enabled: true,
- }
- if err := svc.Create(context.Background(), site); err != nil {
- t.Fatal(err)
- }
-
- ok, msg, err := svc.TestConnection(context.Background(), site.ID)
- if err != nil {
- t.Fatal(err)
- }
- if ok {
- t.Fatal("TestConnection succeeded after YemaPT auth failure")
- }
- if !strings.Contains(msg, "need api auth") {
- t.Fatalf("message = %q, want need api auth", msg)
- }
-}
-
-func TestRedactSensitiveDownloadURL(t *testing.T) {
- tests := []struct {
- name string
- raw string
- want string
- }{
- {
- name: "query secrets",
- raw: "https://pt.example/download.php?id=123&passkey=secret#frag",
- want: "https://pt.example/download.php",
- },
- {
- name: "magnet",
- raw: "magnet:?xt=urn:btih:abc&dn=movie",
- want: "magnet:?xt=***",
- },
- {
- name: "invalid",
- raw: "not a url",
- want: "[redacted-download-url]",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := redactSensitiveDownloadURL(tt.raw); got != tt.want {
- t.Fatalf("redactSensitiveDownloadURL() = %q, want %q", got, tt.want)
- }
- })
- }
-}
-
-func TestSiteSearchReturnsErrorWhenAllEnabledSitesFail(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- http.Error(w, "upstream timeout simulation", http.StatusGatewayTimeout)
- }))
- defer upstream.Close()
-
- db := newServiceTestDB(t, &model.Site{})
- repos := repository.New(db)
- svc := NewSiteService(zap.NewNop(), repos, "")
- site := &model.Site{
- Name: "馒头",
- Type: "mteam",
- URL: upstream.URL,
- AuthType: "api_key",
- APIKey: "token-123",
- Enabled: true,
- Timeout: 5,
- }
- if err := svc.Create(context.Background(), site); err != nil {
- t.Fatal(err)
- }
-
- results, err := svc.Search(context.Background(), "南部档案 2026")
- if err == nil {
- t.Fatalf("Search error = nil, want all-sites-failed error; results=%#v", results)
- }
- if len(results) != 0 {
- t.Fatalf("results = %#v, want none on all-sites failure", results)
- }
- if !strings.Contains(err.Error(), "all enabled sites failed") || !strings.Contains(err.Error(), "馒头") {
- t.Fatalf("error = %q, want site failure context", err.Error())
- }
-}
-
-func TestSearchSiteQueriesSelectedSiteEvenWhenDisabled(t *testing.T) {
- var gotQuery string
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotQuery = r.URL.RawQuery
- _, _ = w.Write([]byte(``))
- }))
- defer upstream.Close()
-
- db := newServiceTestDB(t, &model.Site{})
- repos := repository.New(db)
- svc := NewSiteService(zap.NewNop(), repos, "")
- site := &model.Site{
- Name: "Selected Nexus",
- Type: "nexusphp",
- URL: upstream.URL,
- AuthType: "cookie",
- Cookie: "uid=1; pass=token",
- Enabled: false,
- Timeout: 5,
- }
- if err := svc.Create(context.Background(), site); err != nil {
- t.Fatal(err)
- }
-
- results, err := svc.SearchSite(context.Background(), site.ID, "Selected", 1)
- if err != nil {
- t.Fatalf("SearchSite returned error: %v", err)
- }
- if !strings.Contains(gotQuery, "searchstr=Selected") {
- t.Fatalf("query = %q, want searchstr=Selected", gotQuery)
- }
- if len(results) != 1 || results[0].SiteID != site.ID || results[0].Title != "Selected Site Result" {
- t.Fatalf("results = %#v", results)
- }
-}
diff --git a/internal/service/stats.go b/internal/service/stats.go
deleted file mode 100644
index 6ab8728..0000000
--- a/internal/service/stats.go
+++ /dev/null
@@ -1,163 +0,0 @@
-// Package service — statistics aggregator.
-//
-// StatsService computes the dashboard numbers for the admin / home page:
-// - total libraries, media items, users
-// - total disk size and durations
-// - top recently-watched media
-// - process metadata (CPU / memory) via gopsutil
-package service
-
-import (
- "context"
- "runtime"
- "time"
-
- "github.com/shirou/gopsutil/v3/cpu"
- "github.com/shirou/gopsutil/v3/disk"
- "github.com/shirou/gopsutil/v3/mem"
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-// StatsService computes aggregate stats.
-type StatsService struct {
- log *zap.Logger
- repo *repository.Container
- cache *RuntimeCacheService
-}
-
-// NewStatsService is the constructor.
-func NewStatsService(log *zap.Logger, repo *repository.Container) *StatsService {
- return &StatsService{log: log, repo: repo}
-}
-
-func (s *StatsService) SetRuntimeCache(cache *RuntimeCacheService) *StatsService {
- if s != nil {
- s.cache = cache
- }
- return s
-}
-
-// Snapshot is the JSON returned by /api/stats.
-type Snapshot struct {
- Libraries int64 `json:"libraries"`
- MediaCount int64 `json:"media_count"`
- UsersCount int64 `json:"users_count"`
- TotalSizeBytes int64 `json:"total_size_bytes"`
- TotalSeconds int64 `json:"total_seconds"`
- RecentlyAdded []model.Media `json:"recently_added"`
- Hardware Hardware `json:"hardware"`
- GeneratedAt time.Time `json:"generated_at"`
-}
-
-// Hardware is the live CPU / memory / disk readings.
-type Hardware struct {
- CPUPercent float64 `json:"cpu_percent"`
- MemoryUsed uint64 `json:"memory_used"`
- MemoryTotal uint64 `json:"memory_total"`
- DiskUsed uint64 `json:"disk_used"`
- DiskTotal uint64 `json:"disk_total"`
- GoVersion string `json:"go_version"`
- Goroutines int `json:"goroutines"`
-}
-
-// Compute builds a fresh snapshot.
-func (s *StatsService) Compute(ctx context.Context, dataDir string) (*Snapshot, error) {
- const cacheKey = "stats:snapshot:base"
- if s.cache != nil {
- var cached Snapshot
- if s.cache.GetJSON(ctx, cacheKey, &cached) {
- cached.GeneratedAt = time.Now()
- cached.Hardware = readHardware(dataDir)
- return &cached, nil
- }
- }
- snap := &Snapshot{GeneratedAt: time.Now()}
- libs, err := s.repo.Library.List(ctx)
- if err != nil {
- return nil, err
- }
- libs = FilterDisplayCloudLibraries(ctx, s.repo, libs)
- activeLibraryIDs := make([]string, 0, len(libs))
- for _, lib := range libs {
- if !lib.Enabled {
- continue
- }
- activeLibraryIDs = append(activeLibraryIDs, lib.ID)
- }
- snap.Libraries = int64(len(activeLibraryIDs))
- mediaQuery := s.repo.DB.Model(&model.Media{})
- if len(activeLibraryIDs) == 0 {
- mediaQuery = mediaQuery.Where("1 = 0")
- } else {
- mediaQuery = mediaQuery.Where("library_id IN ?", activeLibraryIDs)
- }
- if err := mediaQuery.Count(&snap.MediaCount).Error; err != nil {
- return nil, err
- }
- if err := s.repo.DB.Model(&model.User{}).Count(&snap.UsersCount).Error; err != nil {
- return nil, err
- }
- type sumRow struct {
- Size int64
- Seconds int64
- }
- var sum sumRow
- sumQuery := s.repo.DB.Model(&model.Media{})
- if len(activeLibraryIDs) == 0 {
- sumQuery = sumQuery.Where("1 = 0")
- } else {
- sumQuery = sumQuery.Where("library_id IN ?", activeLibraryIDs)
- }
- if err := sumQuery.
- Select("COALESCE(SUM(size_bytes),0) as size, COALESCE(SUM(duration_sec),0) as seconds").
- Scan(&sum).Error; err != nil {
- return nil, err
- }
- snap.TotalSizeBytes = sum.Size
- snap.TotalSeconds = sum.Seconds
-
- recentQuery := s.repo.DB.Model(&model.Media{})
- if len(activeLibraryIDs) == 0 {
- recentQuery = recentQuery.Where("1 = 0")
- } else {
- recentQuery = recentQuery.Where("library_id IN ?", activeLibraryIDs)
- }
- if err := recentQuery.
- Order("created_at desc").Limit(12).
- Find(&snap.RecentlyAdded).Error; err != nil {
- return nil, err
- }
-
- if s.cache != nil {
- cacheCopy := *snap
- cacheCopy.Hardware = Hardware{}
- s.cache.SetJSON(ctx, cacheKey, cacheCopy, 10*time.Second)
- }
- snap.Hardware = readHardware(dataDir)
- return snap, nil
-}
-
-func readHardware(dataDir string) Hardware {
- hw := Hardware{
- GoVersion: runtime.Version(),
- Goroutines: runtime.NumGoroutine(),
- }
- if usage, err := cpu.Percent(0, false); err == nil && len(usage) > 0 {
- hw.CPUPercent = usage[0]
- }
- if v, err := mem.VirtualMemory(); err == nil {
- hw.MemoryUsed = v.Used
- hw.MemoryTotal = v.Total
- }
- if dataDir == "" {
- dataDir = "/"
- }
- if d, err := disk.Usage(dataDir); err == nil {
- hw.DiskUsed = d.Used
- hw.DiskTotal = d.Total
- }
- return hw
-}
diff --git a/internal/service/stats_test.go b/internal/service/stats_test.go
deleted file mode 100644
index 7844395..0000000
--- a/internal/service/stats_test.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package service
-
-import (
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestStatsComputeFiltersDisabledLibraries(t *testing.T) {
- db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.User{})
- repos := repository.New(db)
- enabled := &model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
- disabled := &model.Library{Name: "停用库", Path: "/media/disabled", Type: "movie", Enabled: false}
- if err := repos.Library.Create(t.Context(), enabled); err != nil {
- t.Fatal(err)
- }
- if err := repos.Library.Create(t.Context(), disabled); err != nil {
- t.Fatal(err)
- }
- if err := db.Model(&model.Library{}).Where("id = ?", disabled.ID).Update("enabled", false).Error; err != nil {
- t.Fatal(err)
- }
- for _, media := range []*model.Media{
- {LibraryID: enabled.ID, Title: "Visible", Path: "/media/movies/a.mkv", SizeBytes: 100},
- {LibraryID: disabled.ID, Title: "Hidden", Path: "/media/disabled/b.mkv", SizeBytes: 900},
- } {
- if err := repos.Media.Upsert(t.Context(), media); err != nil {
- t.Fatal(err)
- }
- }
-
- snap, err := NewStatsService(zap.NewNop(), repos).Compute(t.Context(), t.TempDir())
- if err != nil {
- t.Fatal(err)
- }
- if snap.Libraries != 1 || snap.MediaCount != 1 || snap.TotalSizeBytes != 100 {
- t.Fatalf("stats = libraries=%d media=%d size=%d, want 1/1/100", snap.Libraries, snap.MediaCount, snap.TotalSizeBytes)
- }
-}
diff --git a/internal/service/storage_cloud_resolve.go b/internal/service/storage_cloud_resolve.go
deleted file mode 100644
index b816834..0000000
--- a/internal/service/storage_cloud_resolve.go
+++ /dev/null
@@ -1,277 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "strings"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/service/cloud"
-)
-
-type cloudResolveCacheEntry struct {
- link *cloud.DirectLink
- expiresAt time.Time
- hits int
- lastHit time.Time
-}
-
-type cloudResolveCall struct {
- done chan struct{}
- link *cloud.DirectLink
- err error
-}
-
-const (
- cloudResolveHotHitThreshold = 3
- cloudResolveBackgroundRefreshMax = 30 * time.Second
-)
-
-// CloudResolve resolves a cloud file reference to a direct link.
-//
-// clientUA is the User-Agent of the playback client that will follow the 302
-// redirect. Some provider CDN links are bound to the UA used to request them,
-// so we resolve with the client's own UA. When clientUA is empty the provider's
-// default UA is used.
-func (s *StorageConfigService) CloudResolve(ctx context.Context, typ, fileRef, clientUA string) (*cloud.DirectLink, error) {
- if s == nil {
- return nil, errors.New("storage config service unavailable")
- }
- cacheKey := s.resolveCacheKey(typ, fileRef, clientUA)
- if link, ok, refresh := s.cachedResolve(cacheKey, typ); ok {
- if refresh {
- s.refreshResolveInBackground(cacheKey, typ, fileRef, clientUA)
- }
- return link, nil
- }
- if call, owner := s.beginResolve(cacheKey); !owner {
- select {
- case <-call.done:
- if call.err != nil {
- return nil, call.err
- }
- return cloneDirectLink(call.link), nil
- case <-ctx.Done():
- return nil, ctx.Err()
- }
- } else {
- defer s.finishResolve(cacheKey, call)
- p, err := s.cloudProviderWithUA(ctx, typ, clientUA)
- if err != nil {
- call.err = err
- return nil, err
- }
- link, err := p.Resolve(ctx, fileRef)
- if err != nil {
- call.err = err
- return nil, err
- }
- call.link = cloneDirectLink(link)
- s.storeResolvedLink(cacheKey, typ, link)
- return cloneDirectLink(link), nil
- }
-}
-
-func (s *StorageConfigService) resolveCacheKey(typ, fileRef, clientUA string) string {
- return strings.TrimSpace(typ) + "\x00" + strings.TrimSpace(fileRef) + "\x00" + strings.TrimSpace(clientUA)
-}
-
-func (s *StorageConfigService) cachedResolve(key, typ string) (*cloud.DirectLink, bool, bool) {
- s.resolveMu.Lock()
- defer s.resolveMu.Unlock()
- if s.resolveCache == nil {
- s.resolveCache = make(map[string]cloudResolveCacheEntry)
- return nil, false, false
- }
- entry, ok := s.resolveCache[key]
- now := time.Now()
- if !ok || now.After(entry.expiresAt) {
- if ok {
- delete(s.resolveCache, key)
- }
- return nil, false, false
- }
- entry.hits++
- entry.lastHit = now
- s.resolveCache[key] = entry
- refreshWindow := cloudResolveHotRefreshWindow(cloudResolveCacheTTL(typ))
- shouldRefresh := entry.hits >= cloudResolveHotHitThreshold &&
- refreshWindow > 0 &&
- now.Add(refreshWindow).After(entry.expiresAt)
- return cloneDirectLink(entry.link), true, shouldRefresh
-}
-
-func (s *StorageConfigService) beginResolve(key string) (*cloudResolveCall, bool) {
- s.resolveMu.Lock()
- defer s.resolveMu.Unlock()
- if s.resolveFlight == nil {
- s.resolveFlight = make(map[string]*cloudResolveCall)
- }
- if call := s.resolveFlight[key]; call != nil {
- return call, false
- }
- call := &cloudResolveCall{done: make(chan struct{})}
- s.resolveFlight[key] = call
- return call, true
-}
-
-func (s *StorageConfigService) finishResolve(key string, call *cloudResolveCall) {
- s.resolveMu.Lock()
- if current := s.resolveFlight[key]; current == call {
- delete(s.resolveFlight, key)
- }
- s.resolveMu.Unlock()
- close(call.done)
-}
-
-func (s *StorageConfigService) refreshResolveInBackground(key, typ, fileRef, clientUA string) {
- if s == nil {
- return
- }
- go func() {
- call, owner := s.beginResolve(key)
- if !owner {
- return
- }
- defer s.finishResolve(key, call)
- ctx, cancel := context.WithTimeout(context.Background(), cloudResolveBackgroundRefreshMax)
- defer cancel()
- p, err := s.cloudProviderWithUA(ctx, typ, clientUA)
- if err != nil {
- call.err = err
- if s.log != nil {
- s.log.Debug("refresh cloud direct link failed", zap.String("provider", typ), zap.Error(err))
- }
- return
- }
- link, err := p.Resolve(ctx, fileRef)
- if err != nil {
- call.err = err
- if s.log != nil {
- s.log.Debug("refresh cloud direct link failed", zap.String("provider", typ), zap.Error(err))
- }
- return
- }
- call.link = cloneDirectLink(link)
- s.storeResolvedLink(key, typ, link)
- }()
-}
-
-func (s *StorageConfigService) storeResolvedLink(key, typ string, link *cloud.DirectLink) {
- if link == nil || strings.TrimSpace(link.URL) == "" {
- return
- }
- ttl := cloudResolveCacheTTL(typ)
- if ttl <= 0 {
- return
- }
- s.resolveMu.Lock()
- defer s.resolveMu.Unlock()
- if s.resolveCache == nil {
- s.resolveCache = make(map[string]cloudResolveCacheEntry)
- }
- now := time.Now()
- hits := 0
- if existing, ok := s.resolveCache[key]; ok {
- hits = existing.hits
- }
- s.resolveCache[key] = cloudResolveCacheEntry{link: cloneDirectLink(link), expiresAt: now.Add(ttl), hits: hits, lastHit: now}
-}
-
-func cloudResolveHotRefreshWindow(ttl time.Duration) time.Duration {
- if ttl <= 0 {
- return 0
- }
- window := ttl / 4
- if window < 15*time.Second {
- window = 15 * time.Second
- }
- if window > 2*time.Minute {
- window = 2 * time.Minute
- }
- return window
-}
-
-func cloudResolveCacheTTL(typ string) time.Duration {
- switch typ {
- case cloud.Type115, cloud.TypeCloudDrive2, cloud.TypeOpenList:
- return 2 * time.Minute
- default:
- return 5 * time.Minute
- }
-}
-
-func cloneDirectLink(link *cloud.DirectLink) *cloud.DirectLink {
- if link == nil {
- return nil
- }
- out := &cloud.DirectLink{
- URL: link.URL,
- Headers: make(map[string]string, len(link.Headers)),
- Proxy: link.Proxy,
- }
- for k, v := range link.Headers {
- out.Headers[k] = v
- }
- return out
-}
-
-func (s *StorageConfigService) clearResolveCacheForType(typ string) {
- typ = strings.TrimSpace(typ)
- if typ == "" {
- return
- }
- prefix := typ + "\x00"
- s.resolveMu.Lock()
- defer s.resolveMu.Unlock()
- for key := range s.resolveCache {
- if strings.HasPrefix(key, prefix) {
- delete(s.resolveCache, key)
- }
- }
- for key, call := range s.resolveFlight {
- if strings.HasPrefix(key, prefix) && call != nil {
- call.err = fmt.Errorf("%s storage config changed", typ)
- }
- }
-}
-
-func (s *StorageConfigService) CloudResolveUncached(ctx context.Context, typ, fileRef, clientUA string) (*cloud.DirectLink, error) {
- p, err := s.cloudProviderWithUA(ctx, typ, clientUA)
- if err != nil {
- return nil, err
- }
- return p.Resolve(ctx, fileRef)
-}
-
-// cloudProviderWithUA builds a provider, overriding the request UA when a
-// non-empty clientUA is supplied.
-func (s *StorageConfigService) cloudProviderWithUA(ctx context.Context, typ, clientUA string) (cloud.Provider, error) {
- if !cloud.IsCloudType(typ) {
- return nil, fmt.Errorf("not a cloud provider: %q", typ)
- }
- view, err := s.Get(ctx, typ)
- if err != nil {
- return nil, err
- }
- if view == nil {
- return nil, fmt.Errorf("%s storage not configured", typ)
- }
- if !view.Enabled {
- return nil, fmt.Errorf("%s storage disabled", typ)
- }
- cfg := view.Config
- if strings.TrimSpace(clientUA) != "" {
- // Copy so we never mutate the cached view config.
- cp := make(map[string]any, len(cfg)+1)
- for k, v := range cfg {
- cp[k] = v
- }
- cp["ua"] = clientUA
- cfg = cp
- }
- return cloud.New(typ, cfg, s.clientForConfig(cfg))
-}
diff --git a/internal/service/storage_cloud_text.go b/internal/service/storage_cloud_text.go
deleted file mode 100644
index 60d1060..0000000
--- a/internal/service/storage_cloud_text.go
+++ /dev/null
@@ -1,45 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "io"
- "net/http"
- "strings"
-)
-
-// CloudReadText resolves a small cloud file and returns its text payload. It is
-// used for cloud-hosted .strm files: the scanner reads the STRM target once and
-// stores the real playback URL, while the media bytes still stay in the cloud.
-func (s *StorageConfigService) CloudReadText(ctx context.Context, typ, fileRef string, limit int64) (string, error) {
- if limit <= 0 {
- limit = 64 << 10
- }
- link, err := s.CloudResolve(ctx, typ, fileRef, "")
- if err != nil {
- return "", err
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil)
- if err != nil {
- return "", err
- }
- for k, v := range link.Headers {
- req.Header.Set(k, v)
- }
- resp, err := s.client.Do(req)
- if err != nil {
- return "", err
- }
- defer resp.Body.Close()
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return "", fmt.Errorf("%s: read strm returned http %d", typ, resp.StatusCode)
- }
- body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
- if err != nil {
- return "", err
- }
- if int64(len(body)) > limit {
- return "", fmt.Errorf("%s: strm file is too large", typ)
- }
- return strings.TrimSpace(strings.TrimPrefix(string(body), "\ufeff")), nil
-}
diff --git a/internal/service/storage_config.go b/internal/service/storage_config.go
deleted file mode 100644
index a820feb..0000000
--- a/internal/service/storage_config.go
+++ /dev/null
@@ -1,276 +0,0 @@
-// Package service — external storage configuration management.
-//
-// StorageConfigService stores connection settings encrypted at rest
-// (via CryptoService). It also exposes a Test() probe so the React UI
-// can verify the credentials before saving.
-package service
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "strings"
- "sync"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
- "github.com/ShukeBta/MediaStationGo/internal/service/cloud"
-)
-
-// StorageConfigService encrypts + persists external storage configs.
-type StorageConfigService struct {
- log *zap.Logger
- repo *repository.Container
- crypto *CryptoService
- client *http.Client
- resolveMu sync.Mutex
- resolveCache map[string]cloudResolveCacheEntry
- resolveFlight map[string]*cloudResolveCall
-}
-
-// NewStorageConfigService is the constructor.
-func NewStorageConfigService(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *StorageConfigService {
- return &StorageConfigService{
- log: log,
- repo: repo,
- crypto: crypto,
- client: &http.Client{Timeout: 120 * time.Second},
- resolveCache: make(map[string]cloudResolveCacheEntry),
- resolveFlight: make(map[string]*cloudResolveCall),
- }
-}
-
-// StorageInput is the create / update payload accepted by the API.
-// Config is a free-form map whose required keys depend on Type.
-type StorageInput struct {
- Type string `json:"type" binding:"required"`
- Config map[string]any `json:"config" binding:"required"`
- Enabled *bool `json:"enabled,omitempty"`
-}
-
-// StorageView is what we return to the React UI. The actual ciphertext
-// is decoded back to a map (with secret keys still redacted in the
-// list endpoint via Redact).
-type StorageView struct {
- model.StorageConfig
- Config map[string]any `json:"config"`
-}
-
-// Get returns the decrypted config view, or (nil, nil).
-func (s *StorageConfigService) Get(ctx context.Context, kind string) (*StorageView, error) {
- row, err := s.repo.StorageConfig.Get(ctx, kind)
- if err != nil {
- return nil, err
- }
- if row == nil {
- return nil, nil
- }
- plain := s.crypto.Decrypt(row.Config)
- var cfg map[string]any
- _ = json.Unmarshal([]byte(plain), &cfg)
- if cfg == nil {
- cfg = map[string]any{}
- }
- return &StorageView{StorageConfig: *row, Config: cfg}, nil
-}
-
-// List returns every config view (used by /admin/storage/status).
-func (s *StorageConfigService) List(ctx context.Context) ([]StorageView, error) {
- rows, err := s.repo.StorageConfig.List(ctx)
- if err != nil {
- return nil, err
- }
- out := make([]StorageView, 0, len(rows))
- for _, r := range rows {
- if !IsAdminStorageConfigurable(r.Type) {
- continue
- }
- plain := s.crypto.Decrypt(r.Config)
- var cfg map[string]any
- _ = json.Unmarshal([]byte(plain), &cfg)
- // Redact secrets when listing.
- for _, k := range []string{"password", "secret_key", "token", "cookie", "access_key"} {
- if v, ok := cfg[k]; ok && fmt.Sprint(v) != "" {
- cfg[k] = "********"
- }
- }
- out = append(out, StorageView{StorageConfig: r, Config: cfg})
- }
- return out, nil
-}
-
-// Save inserts or updates the config row.
-func (s *StorageConfigService) Save(ctx context.Context, in StorageInput) (*StorageView, error) {
- if !validStorageType(in.Type) {
- return nil, fmt.Errorf("unsupported storage type %q", in.Type)
- }
- cfg := cloneStorageConfigMap(in.Config)
- if shouldPreserveStorageSecretsOnSave(in.Enabled) {
- merged, err := s.mergeExistingStorageSecrets(ctx, in.Type, cfg)
- if err != nil {
- return nil, err
- }
- cfg = merged
- }
- blob, err := json.Marshal(cfg)
- if err != nil {
- return nil, err
- }
- cipher := s.crypto.Encrypt(string(blob))
- row := &model.StorageConfig{
- Type: in.Type,
- Config: cipher,
- Enabled: true,
- }
- if in.Enabled != nil {
- row.Enabled = *in.Enabled
- }
- if err := s.repo.StorageConfig.Upsert(ctx, row); err != nil {
- return nil, err
- }
- s.clearResolveCacheForType(in.Type)
- return s.Get(ctx, in.Type)
-}
-
-// Logout clears saved cloud login credentials, disables the storage backend,
-// and removes virtual cloud libraries/media for that provider. It intentionally
-// keeps non-secret connection hints such as server / WebDAV URL / timeout so
-// the admin can log in again without rebuilding the form.
-func (s *StorageConfigService) Logout(ctx context.Context, typ string) (*StorageView, error) {
- if !validStorageType(typ) {
- return nil, fmt.Errorf("unsupported storage type %q", typ)
- }
- if !cloud.IsCloudType(typ) {
- return nil, fmt.Errorf("not a cloud provider: %q", typ)
- }
- view, err := s.Get(ctx, typ)
- if err != nil {
- return nil, err
- }
- if view == nil {
- return nil, fmt.Errorf("%s storage not configured", typ)
- }
- cfg := make(map[string]any, len(view.Config))
- for k, v := range view.Config {
- if isStorageLoginSecretKey(k) || isDeprecatedStoragePlaybackKey(k) {
- continue
- }
- cfg[k] = v
- }
- enabled := false
- saved, err := s.Save(ctx, StorageInput{Type: typ, Config: cfg, Enabled: &enabled})
- if err != nil {
- return nil, err
- }
- purged, err := s.purgeCloudLibraries(ctx, typ)
- if err != nil {
- return nil, err
- }
- if s.log != nil {
- s.log.Info("storage logout cleared cloud libraries",
- zap.String("storage_type", typ),
- zap.Int("libraries_deleted", purged))
- }
- return saved, nil
-}
-
-func (s *StorageConfigService) purgeCloudLibraries(ctx context.Context, storageType string) (int, error) {
- if s == nil || s.repo == nil || s.repo.Library == nil || s.repo.Media == nil {
- return 0, nil
- }
- libs, err := s.repo.Library.List(ctx)
- if err != nil {
- return 0, fmt.Errorf("list libraries: %w", err)
- }
- var affectedLibs []string
- for _, lib := range libs {
- if mount, ok := ParseCloudLibraryMount(lib.Path); ok && mount.Provider == storageType {
- affectedLibs = append(affectedLibs, lib.ID)
- }
- }
- for _, libID := range affectedLibs {
- if err := s.repo.Media.PurgeByLibrary(ctx, libID); err != nil {
- if s.log != nil {
- s.log.Warn("purge media by library failed", zap.String("library_id", libID), zap.Error(err))
- }
- return len(affectedLibs), fmt.Errorf("purge media by library %s: %w", libID, err)
- }
- }
- for _, libID := range affectedLibs {
- if err := s.repo.Library.Delete(ctx, libID); err != nil {
- if s.log != nil {
- s.log.Warn("delete library failed", zap.String("library_id", libID), zap.Error(err))
- }
- return len(affectedLibs), fmt.Errorf("delete library %s: %w", libID, err)
- }
- }
- return len(affectedLibs), nil
-}
-
-func isStorageLoginSecretKey(key string) bool {
- switch strings.ToLower(strings.TrimSpace(key)) {
- case "cookie", "token", "username", "password", "access_key", "secret_key":
- return true
- default:
- return false
- }
-}
-
-func isDeprecatedStoragePlaybackKey(key string) bool {
- switch strings.ToLower(strings.TrimSpace(key)) {
- case "force_302", "force_proxy":
- return true
- default:
- return false
- }
-}
-
-func validStorageType(t string) bool {
- switch t {
- case "alist", "s3", "webdav", cloud.Type115, cloud.TypeCloudDrive2, cloud.TypeOpenList:
- return true
- }
- return false
-}
-
-// strr is a tiny helper to avoid importing fmt.Sprint just to coerce
-// interface{} → string. (Named "strr" so it doesn't collide with the
-// notify channel's `str` helper which already lives in this package.)
-func strr(v any) string {
- if v == nil {
- return ""
- }
- if s, ok := v.(string); ok {
- return strings.TrimSpace(s)
- }
- return strings.TrimSpace(fmt.Sprint(v))
-}
-
-// DeleteStorage 删除存储配置并清理关联数据
-func (s *StorageConfigService) DeleteStorage(ctx context.Context, storageType string) error {
- // 查找配置
- cfg, err := s.repo.StorageConfig.Get(ctx, storageType)
- if err != nil || cfg == nil {
- return fmt.Errorf("storage config not found: %s", storageType)
- }
-
- affectedLibs, err := s.purgeCloudLibraries(ctx, storageType)
- if err != nil {
- return err
- }
-
- // 删除存储配置
- if err := s.repo.StorageConfig.Delete(ctx, cfg.ID); err != nil {
- return fmt.Errorf("delete storage config: %w", err)
- }
-
- s.log.Info("storage deleted",
- zap.String("storage_type", storageType),
- zap.Int("libraries_deleted", affectedLibs))
-
- return nil
-}
diff --git a/internal/service/storage_config_cache_test.go b/internal/service/storage_config_cache_test.go
deleted file mode 100644
index 37a5e4b..0000000
--- a/internal/service/storage_config_cache_test.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package service
-
-import (
- "fmt"
- "net/http"
- "net/http/httptest"
- "sync/atomic"
- "testing"
- "time"
-)
-
-func TestCloudResolveHotCacheRefreshesInBackground(t *testing.T) {
- var resolves atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/api/fs/get" {
- t.Fatalf("unexpected path %s", r.URL.Path)
- }
- n := resolves.Add(1)
- w.Header().Set("Content-Type", "application/json")
- _, _ = fmt.Fprintf(w, `{"code":200,"data":{"raw_url":"http://cdn.local/%d.mkv"}}`, n)
- }))
- defer upstream.Close()
-
- _, storage := newStorageUploadTestService(t)
- if _, err := storage.Save(t.Context(), StorageInput{
- Type: "openlist",
- Config: map[string]any{
- "server": upstream.URL,
- "token": "token",
- },
- }); err != nil {
- t.Fatal(err)
- }
-
- link, err := storage.CloudResolve(t.Context(), "openlist", "/Movies/f1.mkv", "Player/1")
- if err != nil {
- t.Fatal(err)
- }
- if link.URL != "http://cdn.local/1.mkv" || resolves.Load() != 1 {
- t.Fatalf("first resolve link=%#v resolves=%d", link, resolves.Load())
- }
- for i := 0; i < cloudResolveHotHitThreshold-1; i++ {
- link, err = storage.CloudResolve(t.Context(), "openlist", "/Movies/f1.mkv", "Player/1")
- if err != nil {
- t.Fatal(err)
- }
- if link.URL != "http://cdn.local/1.mkv" || resolves.Load() != 1 {
- t.Fatalf("cached resolve link=%#v resolves=%d", link, resolves.Load())
- }
- }
-
- key := storage.resolveCacheKey("openlist", "/Movies/f1.mkv", "Player/1")
- storage.resolveMu.Lock()
- entry := storage.resolveCache[key]
- entry.hits = cloudResolveHotHitThreshold
- entry.expiresAt = time.Now().Add(5 * time.Second)
- storage.resolveCache[key] = entry
- storage.resolveMu.Unlock()
-
- link, err = storage.CloudResolve(t.Context(), "openlist", "/Movies/f1.mkv", "Player/1")
- if err != nil {
- t.Fatal(err)
- }
- if link.URL != "http://cdn.local/1.mkv" {
- t.Fatalf("hot hit should return cached link immediately, got %s", link.URL)
- }
- deadline := time.Now().Add(2 * time.Second)
- for resolves.Load() < 2 && time.Now().Before(deadline) {
- time.Sleep(10 * time.Millisecond)
- }
- if resolves.Load() < 2 {
- t.Fatalf("background refresh did not run, resolves=%d", resolves.Load())
- }
- link, err = storage.CloudResolve(t.Context(), "openlist", "/Movies/f1.mkv", "Player/1")
- if err != nil {
- t.Fatal(err)
- }
- if link.URL != "http://cdn.local/2.mkv" {
- t.Fatalf("refreshed link = %s, want second URL", link.URL)
- }
-}
-
-func TestCloudResolveCacheTTLUsesShortTTLForCloudPlaybackLinks(t *testing.T) {
- for _, typ := range []string{"cloud115", "clouddrive2", "openlist"} {
- if got := cloudResolveCacheTTL(typ); got != 2*time.Minute {
- t.Fatalf("%s cloud resolve cache ttl = %v, want 2m", typ, got)
- }
- }
-}
diff --git a/internal/service/storage_config_cloud.go b/internal/service/storage_config_cloud.go
deleted file mode 100644
index a415ac5..0000000
--- a/internal/service/storage_config_cloud.go
+++ /dev/null
@@ -1,147 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/service/cloud"
-)
-
-// CloudProvider constructs a cloud-disk provider from the saved (decrypted)
-// config for the given type, or returns an error if not configured.
-func (s *StorageConfigService) CloudProvider(ctx context.Context, typ string) (cloud.Provider, error) {
- if !cloud.IsCloudType(typ) {
- return nil, fmt.Errorf("not a cloud provider: %q", typ)
- }
- view, err := s.Get(ctx, typ)
- if err != nil {
- return nil, err
- }
- if view == nil {
- return nil, fmt.Errorf("%s storage not configured", typ)
- }
- if !view.Enabled {
- return nil, fmt.Errorf("%s storage disabled", typ)
- }
- return cloud.New(typ, view.Config, s.clientForConfig(view.Config))
-}
-
-// CloudList lists entries under dirID for the configured cloud provider.
-func (s *StorageConfigService) CloudList(ctx context.Context, typ, dirID string) ([]cloud.FileEntry, error) {
- p, err := s.CloudProvider(ctx, typ)
- if err != nil {
- return nil, err
- }
- return p.List(ctx, dirID)
-}
-
-func (s *StorageConfigService) CloudMkdir(ctx context.Context, typ, parentDir, name string) (*cloud.FileEntry, error) {
- p, err := s.CloudProvider(ctx, typ)
- if err != nil {
- return nil, err
- }
- mutable, ok := p.(cloud.MutableProvider)
- if !ok {
- return nil, fmt.Errorf("%s does not support folder creation", typ)
- }
- return mutable.Mkdir(ctx, parentDir, name)
-}
-
-func (s *StorageConfigService) CloudRename(ctx context.Context, typ, ref, name string) (*cloud.FileEntry, error) {
- p, err := s.CloudProvider(ctx, typ)
- if err != nil {
- return nil, err
- }
- mutable, ok := p.(cloud.MutableProvider)
- if !ok {
- return nil, fmt.Errorf("%s does not support rename", typ)
- }
- return mutable.Rename(ctx, ref, name)
-}
-
-func (s *StorageConfigService) CloudMove(ctx context.Context, typ, ref, targetDir, name string) (*cloud.FileEntry, error) {
- p, err := s.CloudProvider(ctx, typ)
- if err != nil {
- return nil, err
- }
- movable, ok := p.(cloud.MovableProvider)
- if !ok {
- return nil, fmt.Errorf("%s does not support move", typ)
- }
- return movable.Move(ctx, ref, targetDir, name)
-}
-
-// cloudLibraryName maps a provider type to a friendly Chinese library name.
-func cloudLibraryName(typ string) string {
- switch typ {
- case cloud.Type115:
- return "115 网盘"
- case cloud.TypeCloudDrive2:
- return "CloudDrive2"
- case cloud.TypeOpenList:
- return "OpenList"
- default:
- return typ
- }
-}
-
-// ensureCloudLibrary returns (creating if necessary) the per-provider cloud
-// library that owns imported 302 media.
-func (s *StorageConfigService) ensureCloudLibrary(ctx context.Context, typ string) (*model.Library, error) {
- libs, err := s.repo.Library.List(ctx)
- if err != nil {
- return nil, err
- }
- path := "cloud://" + typ
- for i := range libs {
- if libs[i].Path == path {
- return &libs[i], nil
- }
- }
- lib := &model.Library{Name: cloudLibraryName(typ), Path: path, Type: "movie", Enabled: true}
- if err := s.repo.Library.Create(ctx, lib); err != nil {
- return nil, err
- }
- return lib, nil
-}
-
-// CloudImport creates (or refreshes) a playable media row backed by a cloud
-// file. Playback is served entirely via 302 redirect — the host never streams
-// the bytes (unless the provider requires proxy mode).
-func (s *StorageConfigService) CloudImport(ctx context.Context, typ, fileRef, name string, size int64) (*model.Media, error) {
- if !cloud.IsCloudType(typ) {
- return nil, fmt.Errorf("not a cloud provider: %q", typ)
- }
- if strings.TrimSpace(fileRef) == "" {
- return nil, errors.New("file reference required")
- }
- lib, err := s.ensureCloudLibrary(ctx, typ)
- if err != nil {
- return nil, err
- }
- title := strings.TrimSpace(name)
- container := ""
- if i := strings.LastIndex(title, "."); i > 0 {
- container = strings.ToLower(strings.TrimPrefix(title[i:], "."))
- title = title[:i]
- }
- if title == "" {
- title = fileRef
- }
- m := &model.Media{
- LibraryID: lib.ID,
- Title: title,
- Path: cloudMediaPath(typ, fileRef),
- SizeBytes: size,
- Container: container,
- STRMURL: BuildRelativeCloudPlayURL(typ, fileRef),
- ScrapeStatus: "pending",
- }
- if err := s.repo.Media.Upsert(ctx, m); err != nil {
- return nil, err
- }
- return m, nil
-}
diff --git a/internal/service/storage_config_logout_test.go b/internal/service/storage_config_logout_test.go
deleted file mode 100644
index 2b390a5..0000000
--- a/internal/service/storage_config_logout_test.go
+++ /dev/null
@@ -1,102 +0,0 @@
-package service
-
-import (
- "testing"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func TestStorageConfigLogoutClearsCredentialsAndCloudLibraries(t *testing.T) {
- repos, storage := newStorageUploadTestService(t)
- enabled := true
- if _, err := storage.Save(t.Context(), StorageInput{
- Type: "openlist",
- Config: map[string]any{
- "server": "http://openlist.test",
- "url": "http://openlist.test/dav/",
- "username": "user",
- "password": "pass",
- "token": "token",
- "timeout_seconds": "120",
- "force_302": "true",
- },
- Enabled: &enabled,
- }); err != nil {
- t.Fatalf("save storage: %v", err)
- }
- cloudLib := model.Library{Name: "OpenList", Path: BuildCloudLibraryPath("openlist", "/TV", "/TV"), Type: "tv", Enabled: true}
- if err := repos.Library.Create(t.Context(), &cloudLib); err != nil {
- t.Fatalf("create cloud library: %v", err)
- }
- localLib := model.Library{Name: "Local", Path: t.TempDir(), Type: "movie", Enabled: true}
- if err := repos.Library.Create(t.Context(), &localLib); err != nil {
- t.Fatalf("create local library: %v", err)
- }
- if err := repos.Media.Upsert(t.Context(), &model.Media{LibraryID: cloudLib.ID, Title: "Cloud", Path: "cloud://openlist/TV/Movie.mkv"}); err != nil {
- t.Fatalf("create cloud media: %v", err)
- }
- if err := repos.Media.Upsert(t.Context(), &model.Media{LibraryID: localLib.ID, Title: "Local", Path: localLib.Path + "/Movie.mkv"}); err != nil {
- t.Fatalf("create local media: %v", err)
- }
-
- view, err := storage.Logout(t.Context(), "openlist")
- if err != nil {
- t.Fatalf("logout: %v", err)
- }
- if view.Enabled {
- t.Fatal("storage should be disabled after logout")
- }
- for _, key := range []string{"username", "password", "token", "force_302", "force_proxy"} {
- if _, ok := view.Config[key]; ok {
- t.Fatalf("logout should clear %s, config = %#v", key, view.Config)
- }
- }
- if view.Config["server"] != "http://openlist.test" || view.Config["url"] != "http://openlist.test/dav/" || view.Config["timeout_seconds"] != "120" {
- t.Fatalf("logout should keep non-secret connection hints, config = %#v", view.Config)
- }
- if got, err := repos.Library.FindByID(t.Context(), cloudLib.ID); err != nil {
- t.Fatalf("find cloud library: %v", err)
- } else if got != nil {
- t.Fatalf("cloud library should be removed after logout: %#v", got)
- }
- if got, err := repos.Library.FindByID(t.Context(), localLib.ID); err != nil {
- t.Fatalf("find local library: %v", err)
- } else if got == nil {
- t.Fatal("local library should remain after cloud logout")
- }
- var cloudMediaCount int64
- if err := repos.DB.Unscoped().Model(&model.Media{}).Where("path = ?", "cloud://openlist/TV/Movie.mkv").Count(&cloudMediaCount).Error; err != nil {
- t.Fatalf("count cloud media: %v", err)
- }
- if cloudMediaCount != 0 {
- t.Fatalf("cloud media should be purged after logout, count=%d", cloudMediaCount)
- }
-}
-
-func TestStorageConfigListHidesDeprecatedQuarkRows(t *testing.T) {
- repos, storage := newStorageUploadTestService(t)
- if err := repos.StorageConfig.Upsert(t.Context(), &model.StorageConfig{
- Type: LegacyQuarkProvider,
- Config: storage.crypto.Encrypt(`{"cookie":"legacy"}`),
- Enabled: true,
- }); err != nil {
- t.Fatalf("insert legacy quark row: %v", err)
- }
- if _, err := storage.Save(t.Context(), StorageInput{
- Type: "openlist",
- Config: map[string]any{
- "server": "http://openlist.test",
- "token": "token",
- },
- }); err != nil {
- t.Fatalf("save openlist row: %v", err)
- }
-
- rows, err := storage.List(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(rows) != 1 || rows[0].Type != "openlist" {
- t.Fatalf("storage list = %#v, want only supported OpenList row", rows)
- }
-}
diff --git a/internal/service/storage_config_probe.go b/internal/service/storage_config_probe.go
deleted file mode 100644
index 8f34d3b..0000000
--- a/internal/service/storage_config_probe.go
+++ /dev/null
@@ -1,164 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "net/http"
- "strconv"
- "strings"
- "time"
-
- "github.com/ShukeBta/MediaStationGo/internal/service/cloud"
-)
-
-// Test runs a connection probe against the supplied (un-saved) config.
-// The implementation is best-effort: it issues a single HEAD/PROPFIND
-// to verify reachability, not full functionality.
-func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error {
- cfg := in.Config
- if cfg == nil {
- return errors.New("config required")
- }
- client := s.clientForConfig(cfg)
- switch in.Type {
- case "alist":
- server := strings.TrimRight(strr(cfg["server"]), "/")
- if server == "" {
- return errors.New("alist missing server")
- }
- req, _ := http.NewRequestWithContext(ctx, http.MethodGet, server+"/api/me", nil)
- if tok := strr(cfg["token"]); tok != "" {
- req.Header.Set("Authorization", tok)
- }
- resp, err := client.Do(req)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- return validateStorageProbeStatus("alist", resp.StatusCode)
- case cloud.TypeOpenList:
- p, err := cloud.New(in.Type, cfg, client)
- if err != nil {
- return err
- }
- return p.Ping(ctx)
- case "webdav":
- u := strr(cfg["url"])
- if u == "" {
- return errors.New("webdav missing url")
- }
- req, _ := http.NewRequestWithContext(ctx, "PROPFIND", u, nil)
- if user := strr(cfg["username"]); user != "" {
- req.SetBasicAuth(user, strr(cfg["password"]))
- }
- req.Header.Set("Depth", "0")
- resp, err := client.Do(req)
- if err != nil {
- return decorateStorageTransportError("webdav", u, err)
- }
- defer resp.Body.Close()
- if resp.StatusCode >= 400 && resp.StatusCode != http.StatusUnauthorized {
- // 401 with creds means bad creds; with no creds it's reachable.
- if user := strr(cfg["username"]); user == "" && resp.StatusCode == http.StatusUnauthorized {
- return nil
- }
- return fmt.Errorf("webdav returned %d", resp.StatusCode)
- }
- return nil
- case "s3":
- ep := strr(cfg["endpoint"])
- if ep == "" {
- return errors.New("s3 missing endpoint")
- }
- // We only verify endpoint reachability — full SigV4 is a large
- // dependency; the upstream Vue project also stops at this level.
- req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ep, nil)
- resp, err := client.Do(req)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- return nil
- case cloud.Type115, cloud.TypeCloudDrive2:
- p, err := cloud.New(in.Type, cfg, client)
- if err != nil {
- return err
- }
- return p.Ping(ctx)
- default:
- return fmt.Errorf("unsupported storage type %q", in.Type)
- }
-}
-
-func (s *StorageConfigService) clientForConfig(cfg map[string]any) *http.Client {
- if s == nil || s.client == nil {
- return &http.Client{Timeout: 120 * time.Second}
- }
- timeout := storageTimeoutFromConfig(cfg, s.client.Timeout)
- if timeout == s.client.Timeout {
- return s.client
- }
- cp := *s.client
- cp.Timeout = timeout
- return &cp
-}
-
-func storageTimeoutFromConfig(cfg map[string]any, fallback time.Duration) time.Duration {
- if fallback <= 0 {
- fallback = 120 * time.Second
- }
- raw := ""
- for _, key := range []string{"timeout_seconds", "webdav_timeout_seconds", "request_timeout_seconds"} {
- if value := strr(cfg[key]); value != "" {
- raw = value
- break
- }
- }
- if raw == "" {
- return fallback
- }
- seconds, err := strconv.Atoi(raw)
- if err != nil {
- if f, ferr := strconv.ParseFloat(raw, 64); ferr == nil {
- seconds = int(f)
- }
- }
- if seconds <= 0 {
- return fallback
- }
- if seconds < 5 {
- seconds = 5
- }
- if seconds > 600 {
- seconds = 600
- }
- return time.Duration(seconds) * time.Second
-}
-
-func validateStorageProbeStatus(name string, status int) error {
- if status >= 200 && status < 300 {
- return nil
- }
- if status == http.StatusUnauthorized || status == http.StatusForbidden {
- return fmt.Errorf("%s authentication failed: http %d;请检查 Token / 用户名密码", name, status)
- }
- if status >= 300 {
- return fmt.Errorf("%s returned %d", name, status)
- }
- return nil
-}
-
-func decorateStorageTransportError(name, target string, err error) error {
- if err == nil {
- return nil
- }
- message := err.Error()
- if strings.Contains(message, "server gave HTTP response to HTTPS client") {
- return fmt.Errorf("%s: %w;当前地址使用 https://,但服务端返回 HTTP。请改用 http:// 地址;OpenList 默认 WebDAV 通常是 http://host:5244/dav/,管理页面/API 地址通常是 http://host:5244", name, err)
- }
- if strings.Contains(message, "first record does not look like a TLS handshake") {
- return fmt.Errorf("%s: %w;疑似把 HTTP 服务配置成了 https://,请检查 %s 的协议头", name, err, target)
- }
- return err
-}
diff --git a/internal/service/storage_config_probe_test.go b/internal/service/storage_config_probe_test.go
deleted file mode 100644
index a94c3c4..0000000
--- a/internal/service/storage_config_probe_test.go
+++ /dev/null
@@ -1,153 +0,0 @@
-package service
-
-import (
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-)
-
-func TestStorageConfigOpenListHTTPSAgainstHTTPHint(t *testing.T) {
- openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, _ = w.Write([]byte(`{"code":200}`))
- }))
- defer openlist.Close()
-
- _, storage := newStorageUploadTestService(t)
- badHTTPS := "https://" + strings.TrimPrefix(openlist.URL, "http://")
- err := storage.Test(t.Context(), StorageInput{
- Type: "openlist",
- Config: map[string]any{
- "server": badHTTPS,
- },
- })
- if err == nil {
- t.Fatal("want protocol mismatch error")
- }
- if !strings.Contains(err.Error(), "请改用 http://") || !strings.Contains(err.Error(), "server gave HTTP response to HTTPS client") {
- t.Fatalf("unexpected error: %v", err)
- }
-}
-
-func TestStorageConfigOpenListTestRejectsUnauthorizedList(t *testing.T) {
- openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/dav" {
- t.Fatalf("unexpected openlist path %s", r.URL.Path)
- }
- w.WriteHeader(http.StatusUnauthorized)
- _, _ = w.Write([]byte("unauthorized"))
- }))
- defer openlist.Close()
-
- _, storage := newStorageUploadTestService(t)
- err := storage.Test(t.Context(), StorageInput{
- Type: "openlist",
- Config: map[string]any{
- "server": openlist.URL,
- },
- })
- if err == nil || !strings.Contains(err.Error(), "http 401") {
- t.Fatalf("openlist unauthorized probe error = %v, want http 401", err)
- }
-}
-
-func TestStorageConfigOpenListTestUsesAPIListWithToken(t *testing.T) {
- var listed bool
- openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/api/fs/list" {
- t.Fatalf("unexpected openlist path %s", r.URL.Path)
- }
- if r.Header.Get("Authorization") != "openlist-token" {
- t.Fatalf("authorization = %q", r.Header.Get("Authorization"))
- }
- listed = true
- _, _ = w.Write([]byte(`{"code":200,"data":{"content":[],"total":0}}`))
- }))
- defer openlist.Close()
-
- _, storage := newStorageUploadTestService(t)
- if err := storage.Test(t.Context(), StorageInput{
- Type: "openlist",
- Config: map[string]any{
- "server": openlist.URL,
- "token": "openlist-token",
- },
- }); err != nil {
- t.Fatalf("openlist API probe: %v", err)
- }
- if !listed {
- t.Fatal("openlist test should probe /api/fs/list")
- }
-}
-
-func TestStorageConfigAlistTestRejectsUnauthorized(t *testing.T) {
- alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/api/me" {
- t.Fatalf("unexpected alist path %s", r.URL.Path)
- }
- w.WriteHeader(http.StatusUnauthorized)
- }))
- defer alist.Close()
-
- _, storage := newStorageUploadTestService(t)
- err := storage.Test(t.Context(), StorageInput{
- Type: "alist",
- Config: map[string]any{
- "server": alist.URL,
- },
- })
- if err == nil || !strings.Contains(err.Error(), "authentication failed") {
- t.Fatalf("alist unauthorized probe error = %v, want authentication failed", err)
- }
-}
-
-func TestStorageConfigCloudProviderRejectsDisabledConfig(t *testing.T) {
- _, storage := newStorageUploadTestService(t)
- enabled := false
- if _, err := storage.Save(t.Context(), StorageInput{
- Type: "openlist",
- Config: map[string]any{
- "url": "http://127.0.0.1:5244/dav",
- },
- Enabled: &enabled,
- }); err != nil {
- t.Fatal(err)
- }
- _, err := storage.CloudProvider(t.Context(), "openlist")
- if err == nil || !strings.Contains(err.Error(), "disabled") {
- t.Fatalf("disabled provider error = %v, want disabled", err)
- }
-}
-
-func TestStorageConfigSavePreservesExistingSecretWhenFormLeavesItBlank(t *testing.T) {
- _, storage := newStorageUploadTestService(t)
- if _, err := storage.Save(t.Context(), StorageInput{
- Type: "openlist",
- Config: map[string]any{
- "server": "http://openlist.test",
- "token": "openlist-token",
- },
- }); err != nil {
- t.Fatal(err)
- }
- if _, err := storage.Save(t.Context(), StorageInput{
- Type: "openlist",
- Config: map[string]any{
- "server": "http://openlist.test",
- "token": "",
- "timeout_seconds": "180",
- },
- }); err != nil {
- t.Fatal(err)
- }
- view, err := storage.Get(t.Context(), "openlist")
- if err != nil {
- t.Fatal(err)
- }
- if view.Config["token"] != "openlist-token" {
- t.Fatalf("token = %#v, want preserved token", view.Config["token"])
- }
- if view.Config["timeout_seconds"] != "180" {
- t.Fatalf("timeout_seconds = %#v, want updated timeout", view.Config["timeout_seconds"])
- }
-}
diff --git a/internal/service/storage_config_secrets.go b/internal/service/storage_config_secrets.go
deleted file mode 100644
index d327fa9..0000000
--- a/internal/service/storage_config_secrets.go
+++ /dev/null
@@ -1,88 +0,0 @@
-// Package service — storage configuration secret preservation helpers.
-package service
-
-import (
- "context"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/service/cloud"
-)
-
-func shouldPreserveStorageSecretsOnSave(enabled *bool) bool {
- return enabled == nil || *enabled
-}
-
-func cloneStorageConfigMap(cfg map[string]any) map[string]any {
- out := make(map[string]any, len(cfg))
- for k, v := range cfg {
- out[k] = v
- }
- return out
-}
-
-func (s *StorageConfigService) mergeExistingStorageSecrets(ctx context.Context, typ string, cfg map[string]any) (map[string]any, error) {
- view, err := s.Get(ctx, typ)
- if err != nil || view == nil {
- return cfg, err
- }
- for _, key := range storagePreservedSecretKeys() {
- existing := strings.TrimSpace(strr(view.Config[key]))
- if existing == "" {
- continue
- }
- incoming, hasIncoming := cfg[key]
- if hasIncoming && !isBlankStorageSecret(incoming) {
- continue
- }
- if storageSecretReplacedByAlternative(typ, key, cfg, view.Config) {
- continue
- }
- cfg[key] = existing
- }
- return cfg, nil
-}
-
-func storagePreservedSecretKeys() []string {
- return []string{"password", "secret_key", "token", "cookie", "access_key"}
-}
-
-func isBlankStorageSecret(value any) bool {
- text := strings.TrimSpace(strr(value))
- return text == "" || text == "********"
-}
-
-func storageSecretReplacedByAlternative(typ, key string, cfg, existing map[string]any) bool {
- switch typ {
- case cloud.TypeOpenList:
- switch key {
- case "token":
- return strings.TrimSpace(strr(cfg["username"])) != "" && strings.TrimSpace(strr(cfg["password"])) != ""
- case "password":
- if strings.TrimSpace(strr(cfg["token"])) != "" {
- return true
- }
- return storagePlainFieldChanged("username", cfg, existing)
- }
- case "webdav", cloud.TypeCloudDrive2:
- if key == "password" {
- if strings.TrimSpace(strr(cfg["token"])) != "" {
- return true
- }
- return storagePlainFieldChanged("username", cfg, existing)
- }
- case "s3":
- if key == "secret_key" {
- return storagePlainFieldChanged("access_key", cfg, existing)
- }
- }
- return false
-}
-
-func storagePlainFieldChanged(key string, cfg, existing map[string]any) bool {
- incoming := strings.TrimSpace(strr(cfg[key]))
- if incoming == "" {
- return false
- }
- current := strings.TrimSpace(strr(existing[key]))
- return current != "" && incoming != current
-}
diff --git a/internal/service/storage_test.go b/internal/service/storage_test.go
deleted file mode 100644
index b86f532..0000000
--- a/internal/service/storage_test.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package service
-
-import (
- "slices"
- "testing"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
- "go.uber.org/zap"
-)
-
-func TestStorageBreakdownUsesCanonicalLibraryDisplay(t *testing.T) {
- db := newServiceTestDB(t, &model.Library{}, &model.Media{})
- repos := repository.New(db)
- libs := []model.Library{
- {Name: "外语电影", Path: "/media/电影/外语电影", Type: "movie", Enabled: true},
- {Name: "欧美动漫", Path: "/media/动漫/欧美动漫", Type: "tv", Enabled: true},
- {Name: "9KG", Path: "/media/成人/9KG", Type: "movie", Enabled: true},
- }
- for i := range libs {
- if err := repos.Library.Create(t.Context(), &libs[i]); err != nil {
- t.Fatal(err)
- }
- if err := repos.Media.Upsert(t.Context(), &model.Media{
- LibraryID: libs[i].ID,
- Title: libs[i].Name,
- Path: libs[i].Path + "/item.mkv",
- SizeBytes: 1024,
- }); err != nil {
- t.Fatal(err)
- }
- }
-
- breakdown, err := NewStorageService(zap.NewNop(), repos).Compute(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- gotNames := make([]string, 0, len(breakdown.ByLibrary))
- gotTypes := make([]string, 0, len(breakdown.ByLibrary))
- for _, row := range breakdown.ByLibrary {
- gotNames = append(gotNames, row.Name)
- gotTypes = append(gotTypes, row.Type)
- }
- if want := []string{"欧美电影", "美漫", "成人"}; !slices.Equal(gotNames, want) {
- t.Fatalf("library names = %#v, want %#v", gotNames, want)
- }
- if want := []string{"movie", "anime", "adult"}; !slices.Equal(gotTypes, want) {
- t.Fatalf("library types = %#v, want %#v", gotTypes, want)
- }
-}
diff --git a/internal/service/storage_types.go b/internal/service/storage_types.go
deleted file mode 100644
index 72ef2a9..0000000
--- a/internal/service/storage_types.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package service
-
-import (
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/service/cloud"
-)
-
-const LegacyQuarkProvider = "quark"
-
-func IsAdminStorageConfigurable(typ string) bool {
- switch strings.TrimSpace(typ) {
- case cloud.TypeOpenList, "alist", "webdav", cloud.TypeCloudDrive2, cloud.Type115:
- return true
- default:
- return false
- }
-}
-
-func IsAdminCloudConfigurable(typ string) bool {
- switch strings.TrimSpace(typ) {
- case cloud.Type115, cloud.TypeCloudDrive2, cloud.TypeOpenList:
- return true
- default:
- return false
- }
-}
-
-func IsDeprecatedNativeCloudProvider(typ string) bool {
- return strings.TrimSpace(typ) == LegacyQuarkProvider
-}
diff --git a/internal/service/storage_types_test.go b/internal/service/storage_types_test.go
deleted file mode 100644
index 4a3643e..0000000
--- a/internal/service/storage_types_test.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package service
-
-import "testing"
-
-func TestAdminStorageConfigurableTypes(t *testing.T) {
- for _, typ := range []string{"openlist", "alist", "webdav", "clouddrive2", "cloud115"} {
- if !IsAdminStorageConfigurable(typ) {
- t.Fatalf("%s should be configurable", typ)
- }
- }
- for _, typ := range []string{"quark", "s3", "", "unknown"} {
- if IsAdminStorageConfigurable(typ) {
- t.Fatalf("%s should not be configurable", typ)
- }
- }
-}
-
-func TestAdminCloudConfigurableTypes(t *testing.T) {
- for _, typ := range []string{"openlist", "clouddrive2", "cloud115"} {
- if !IsAdminCloudConfigurable(typ) {
- t.Fatalf("%s should be cloud-configurable", typ)
- }
- }
- for _, typ := range []string{"quark", "alist", "webdav", "s3", ""} {
- if IsAdminCloudConfigurable(typ) {
- t.Fatalf("%s should not be cloud-configurable", typ)
- }
- }
-}
diff --git a/internal/service/storage_upload.go b/internal/service/storage_upload.go
deleted file mode 100644
index 12bfb42..0000000
--- a/internal/service/storage_upload.go
+++ /dev/null
@@ -1,278 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "path"
- "path/filepath"
- "strings"
-)
-
-const (
- CloudUploadAutoEnabledKey = "cloud.upload_auto_enabled"
- CloudUploadProviderKey = "cloud.upload_provider"
- CloudUploadSourceDirKey = "cloud.upload_source_dir"
- CloudUploadDestPathKey = "cloud.upload_dest_path"
- CloudUploadRecursiveKey = "cloud.upload_recursive"
- CloudUploadSidecarsKey = "cloud.upload_sidecars"
- CloudUploadOverwriteKey = "cloud.upload_overwrite"
- CloudUploadTransferModeKey = "cloud.upload_transfer_mode"
- CloudUploadIntervalSecondsKey = "cloud.upload_interval_seconds"
- CloudUploadUnsupportedProvider = "本地文件直传目前支持 Alist / OpenList / WebDAV / CloudDrive2;115 原生上传需要分片上传私有接口,建议先用 CloudDrive2、OpenList 或 Alist 桥接后转存。"
-)
-
-type CloudUploadInput struct {
- Type string `json:"type"`
- SourcePath string `json:"source_path"`
- DestPath string `json:"dest_path"`
- Recursive bool `json:"recursive"`
- IncludeSidecars bool `json:"include_sidecars"`
- Overwrite bool `json:"overwrite"`
- TransferMode string `json:"transfer_mode"`
-}
-
-type CloudUploadResult struct {
- SourcePath string `json:"source_path"`
- DestPath string `json:"dest_path"`
- Uploaded int `json:"uploaded"`
- Moved int `json:"moved,omitempty"`
- Skipped int `json:"skipped"`
- Bytes int64 `json:"bytes"`
- Errors []string `json:"errors,omitempty"`
- Items []CloudUploadResultItem `json:"items,omitempty"`
-}
-
-type CloudUploadResultItem struct {
- Source string `json:"source"`
- Target string `json:"target"`
- Action string `json:"action"` // upload / move / skip / error
- Size int64 `json:"size,omitempty"`
- Reason string `json:"reason,omitempty"`
-}
-
-type storageUploader interface {
- ensureDir(ctx context.Context, remoteDir string) error
- exists(ctx context.Context, remotePath string) (bool, error)
- upload(ctx context.Context, localPath, remotePath string, size int64) error
-}
-
-var cloudUploadSidecarExtensions = map[string]struct{}{
- ".nfo": {}, ".jpg": {}, ".jpeg": {}, ".png": {}, ".webp": {},
- ".srt": {}, ".ass": {}, ".ssa": {}, ".vtt": {}, ".sub": {}, ".idx": {},
-}
-
-// UploadLocal copies or moves local media files into an external storage
-// backend. Cloud writes are intentionally gated by the per-storage
-// transfer_enabled switch so mounting/scanning a cloud provider does not
-// accidentally make it writable.
-func (s *StorageConfigService) UploadLocal(ctx context.Context, in CloudUploadInput) (*CloudUploadResult, error) {
- in.Type = strings.TrimSpace(in.Type)
- in.SourcePath = strings.TrimSpace(in.SourcePath)
- in.DestPath = normalizeRemotePath(in.DestPath)
- if in.SourcePath == "" {
- return nil, errors.New("source_path required")
- }
- view, err := s.Get(ctx, in.Type)
- if err != nil {
- return nil, err
- }
- if view == nil || !view.Enabled {
- return nil, fmt.Errorf("%s storage not configured", in.Type)
- }
- if !parseBoolSetting(strr(view.Config["transfer_enabled"]), false) {
- return nil, errors.New("cloud transfer is disabled for this storage; enable transfer in external storage settings before writing to cloud")
- }
- mode := resolveCloudUploadTransferMode(in.TransferMode, strr(view.Config["transfer_mode"]), s.settingValue(ctx, CloudUploadTransferModeKey))
- if mode != TransferCopy && mode != TransferMove {
- return nil, errors.New("transfer_mode must be copy or move")
- }
- in.TransferMode = string(mode)
- uploader, err := s.uploaderForView(in.Type, view)
- if err != nil {
- return nil, err
- }
- info, err := os.Stat(in.SourcePath)
- if err != nil {
- return nil, fmt.Errorf("source path not accessible: %w", err)
- }
- result := &CloudUploadResult{SourcePath: in.SourcePath, DestPath: in.DestPath}
- if !info.IsDir() {
- s.uploadOne(ctx, uploader, in, in.SourcePath, filepath.Base(in.SourcePath), info.Size(), result)
- return result, firstUploadError(result)
- }
- root := filepath.Clean(in.SourcePath)
- walkFn := func(localPath string, entryInfo os.FileInfo, walkErr error) error {
- if walkErr != nil {
- addUploadError(result, localPath, "", walkErr)
- return nil
- }
- if entryInfo == nil || entryInfo.IsDir() {
- if !in.Recursive && filepath.Clean(localPath) != root {
- return filepath.SkipDir
- }
- return nil
- }
- if !eligibleCloudUploadFile(localPath, in.IncludeSidecars) {
- return nil
- }
- rel, err := filepath.Rel(root, localPath)
- if err != nil {
- addUploadError(result, localPath, "", err)
- return nil
- }
- s.uploadOne(ctx, uploader, in, localPath, filepath.ToSlash(rel), entryInfo.Size(), result)
- return nil
- }
- if err := filepath.Walk(in.SourcePath, walkFn); err != nil {
- return result, err
- }
- return result, firstUploadError(result)
-}
-
-func (s *StorageConfigService) uploader(ctx context.Context, typ string) (storageUploader, error) {
- view, err := s.Get(ctx, typ)
- if err != nil {
- return nil, err
- }
- if view == nil || !view.Enabled {
- return nil, fmt.Errorf("%s storage not configured", typ)
- }
- return s.uploaderForView(typ, view)
-}
-
-func (s *StorageConfigService) uploaderForView(typ string, view *StorageView) (storageUploader, error) {
- if view == nil || !view.Enabled {
- return nil, fmt.Errorf("%s storage not configured", typ)
- }
- switch typ {
- case "alist":
- return newAlistUploader(view.Config), nil
- case "openlist":
- if strings.TrimSpace(strr(view.Config["server"])) != "" {
- return newNamedAlistUploader("openlist", view.Config), nil
- }
- return newWebDAVUploader(view.Config), nil
- case "webdav":
- return newWebDAVUploader(view.Config), nil
- case "clouddrive2":
- return newWebDAVUploader(view.Config), nil
- case "s3":
- return nil, errors.New("s3 local upload is not implemented yet")
- case "cloud115":
- return nil, errors.New(CloudUploadUnsupportedProvider)
- default:
- return nil, fmt.Errorf("unsupported storage type %q", typ)
- }
-}
-
-func (s *StorageConfigService) uploadOne(ctx context.Context, uploader storageUploader, in CloudUploadInput, localPath, rel string, size int64, result *CloudUploadResult) {
- remotePath := joinRemotePath(in.DestPath, rel)
- if err := uploader.ensureDir(ctx, path.Dir(remotePath)); err != nil {
- addUploadError(result, localPath, remotePath, err)
- return
- }
- if !in.Overwrite {
- exists, err := uploader.exists(ctx, remotePath)
- if err != nil {
- addUploadError(result, localPath, remotePath, err)
- return
- }
- if exists {
- result.Skipped++
- addUploadItem(result, CloudUploadResultItem{Source: localPath, Target: remotePath, Action: "skip", Size: size, Reason: "remote exists"})
- return
- }
- }
- if err := uploader.upload(ctx, localPath, remotePath, size); err != nil {
- addUploadError(result, localPath, remotePath, err)
- return
- }
- result.Uploaded++
- result.Bytes += size
- action := "upload"
- mode := resolveCloudUploadTransferMode(in.TransferMode)
- if mode == TransferMove {
- if err := os.Remove(localPath); err != nil {
- addUploadError(result, localPath, remotePath, fmt.Errorf("uploaded but failed to remove local source: %w", err))
- addUploadItem(result, CloudUploadResultItem{Source: localPath, Target: remotePath, Action: "upload", Size: size, Reason: "source remove failed"})
- return
- }
- result.Moved++
- action = "move"
- }
- addUploadItem(result, CloudUploadResultItem{Source: localPath, Target: remotePath, Action: action, Size: size})
-}
-
-func (s *StorageConfigService) settingValue(ctx context.Context, key string) string {
- if s == nil || s.repo == nil || s.repo.Setting == nil {
- return ""
- }
- v, _ := s.repo.Setting.Get(ctx, key)
- return strings.TrimSpace(v)
-}
-
-func resolveCloudUploadTransferMode(values ...string) TransferMode {
- for _, value := range values {
- switch strings.ToLower(strings.TrimSpace(value)) {
- case "move", "移动":
- return TransferMove
- case "copy", "复制":
- return TransferCopy
- }
- }
- return TransferCopy
-}
-
-func eligibleCloudUploadFile(localPath string, includeSidecars bool) bool {
- ext := strings.ToLower(filepath.Ext(localPath))
- if _, ok := videoExtensions[ext]; ok {
- return true
- }
- if includeSidecars {
- _, ok := cloudUploadSidecarExtensions[ext]
- return ok
- }
- return false
-}
-
-func addUploadError(result *CloudUploadResult, source, target string, err error) {
- result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", source, err))
- addUploadItem(result, CloudUploadResultItem{Source: source, Target: target, Action: "error", Reason: err.Error()})
-}
-
-func addUploadItem(result *CloudUploadResult, item CloudUploadResultItem) {
- if len(result.Items) < 200 {
- result.Items = append(result.Items, item)
- }
-}
-
-func firstUploadError(result *CloudUploadResult) error {
- if result.Uploaded > 0 || len(result.Errors) == 0 {
- return nil
- }
- return errors.New(result.Errors[0])
-}
-
-func normalizeRemotePath(p string) string {
- p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
- if p == "" || p == "." {
- return "/"
- }
- if !strings.HasPrefix(p, "/") {
- p = "/" + p
- }
- return path.Clean(p)
-}
-
-func joinRemotePath(base, rel string) string {
- parts := []string{normalizeRemotePath(base)}
- for _, part := range strings.Split(strings.ReplaceAll(rel, "\\", "/"), "/") {
- part = strings.TrimSpace(part)
- if part != "" && part != "." {
- parts = append(parts, part)
- }
- }
- return path.Clean(path.Join(parts...))
-}
diff --git a/internal/service/storage_upload_alist.go b/internal/service/storage_upload_alist.go
deleted file mode 100644
index 7e0aebf..0000000
--- a/internal/service/storage_upload_alist.go
+++ /dev/null
@@ -1,208 +0,0 @@
-package service
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "os"
- "path"
- "strings"
-)
-
-type alistUploader struct {
- name string
- server string
- token string
- username string
- password string
- client *http.Client
-}
-
-func newAlistUploader(cfg map[string]any) *alistUploader {
- return newNamedAlistUploader("alist", cfg)
-}
-
-func newNamedAlistUploader(name string, cfg map[string]any) *alistUploader {
- return &alistUploader{
- name: name,
- server: strings.TrimRight(strr(cfg["server"]), "/"),
- token: strr(cfg["token"]),
- username: strr(cfg["username"]),
- password: strr(cfg["password"]),
- client: &http.Client{},
- }
-}
-
-func (a *alistUploader) ensureDir(ctx context.Context, remoteDir string) error {
- if a.server == "" {
- return fmt.Errorf("%s missing server", a.name)
- }
- if err := a.ensureToken(ctx); err != nil {
- return err
- }
- remoteDir = normalizeRemotePath(remoteDir)
- if remoteDir == "/" {
- return nil
- }
- current := ""
- for _, part := range strings.Split(strings.Trim(remoteDir, "/"), "/") {
- current = normalizeRemotePath(path.Join(current, part))
- payload, _ := json.Marshal(map[string]string{"path": current})
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.server+"/api/fs/mkdir", bytes.NewReader(payload))
- if err != nil {
- return err
- }
- a.auth(req)
- req.Header.Set("Content-Type", "application/json")
- resp, err := a.client.Do(req)
- if err != nil {
- return decorateStorageTransportError(a.name, a.server, err)
- }
- err = a.checkJSON(resp, "alist mkdir")
- if err != nil && !isAlreadyExistsMessage(err.Error()) {
- return err
- }
- }
- return nil
-}
-
-func (a *alistUploader) exists(ctx context.Context, remotePath string) (bool, error) {
- if err := a.ensureToken(ctx); err != nil {
- return false, err
- }
- payload, _ := json.Marshal(map[string]string{"path": normalizeRemotePath(remotePath)})
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.server+"/api/fs/get", bytes.NewReader(payload))
- if err != nil {
- return false, err
- }
- a.auth(req)
- req.Header.Set("Content-Type", "application/json")
- resp, err := a.client.Do(req)
- if err != nil {
- return false, decorateStorageTransportError(a.name, a.server, err)
- }
- defer resp.Body.Close()
- if resp.StatusCode == http.StatusNotFound {
- return false, nil
- }
- var out struct {
- Code int `json:"code"`
- Message string `json:"message"`
- }
- _ = json.NewDecoder(resp.Body).Decode(&out)
- return resp.StatusCode >= 200 && resp.StatusCode < 300 && out.Code == 200, nil
-}
-
-func (a *alistUploader) upload(ctx context.Context, localPath, remotePath string, size int64) error {
- if err := a.ensureToken(ctx); err != nil {
- return err
- }
- f, err := os.Open(localPath) // #nosec G304 -- localPath is selected from configured local media files before upload.
- if err != nil {
- return err
- }
- defer f.Close()
- req, err := http.NewRequestWithContext(ctx, http.MethodPut, a.server+"/api/fs/put", f)
- if err != nil {
- return err
- }
- a.auth(req)
- req.ContentLength = size
- req.Header.Set("Content-Type", "application/octet-stream")
- req.Header.Set("File-Path", url.PathEscape(normalizeRemotePath(remotePath)))
- resp, err := a.client.Do(req)
- if err != nil {
- return decorateStorageTransportError(a.name, a.server, err)
- }
- return a.checkJSON(resp, "alist upload")
-}
-
-func (a *alistUploader) ensureToken(ctx context.Context) error {
- if strings.TrimSpace(a.token) != "" {
- return nil
- }
- if strings.TrimSpace(a.username) == "" || a.password == "" {
- return nil
- }
- if a.server == "" {
- return fmt.Errorf("%s missing server", a.name)
- }
- payload, _ := json.Marshal(map[string]string{
- "username": a.username,
- "password": a.password,
- })
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.server+"/api/auth/login", bytes.NewReader(payload))
- if err != nil {
- return err
- }
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
- resp, err := a.client.Do(req)
- if err != nil {
- return decorateStorageTransportError(a.name, a.server, err)
- }
- defer resp.Body.Close()
- body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return fmt.Errorf("%s login: http %d: %s", a.name, resp.StatusCode, strings.TrimSpace(string(body)))
- }
- var out struct {
- Code int `json:"code"`
- Message string `json:"message"`
- Data struct {
- Token string `json:"token"`
- } `json:"data"`
- }
- if err := json.Unmarshal(body, &out); err != nil {
- return fmt.Errorf("%s login: decode response: %w", a.name, err)
- }
- if out.Code != 0 && out.Code != 200 {
- msg := strings.TrimSpace(out.Message)
- if msg == "" {
- msg = fmt.Sprintf("code %d", out.Code)
- }
- return fmt.Errorf("%s login: %s", a.name, msg)
- }
- a.token = strings.TrimSpace(out.Data.Token)
- if a.token == "" {
- return fmt.Errorf("%s login returned empty token", a.name)
- }
- return nil
-}
-
-func (a *alistUploader) auth(req *http.Request) {
- if a.token != "" {
- req.Header.Set("Authorization", a.token)
- }
-}
-
-func (a *alistUploader) checkJSON(resp *http.Response, op string) error {
- defer resp.Body.Close()
- body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return fmt.Errorf("%s: http %d: %s", op, resp.StatusCode, strings.TrimSpace(string(body)))
- }
- var out struct {
- Code int `json:"code"`
- Message string `json:"message"`
- }
- if len(bytes.TrimSpace(body)) == 0 {
- return nil
- }
- if err := json.Unmarshal(body, &out); err != nil {
- return nil
- }
- if out.Code != 0 && out.Code != 200 {
- return fmt.Errorf("%s: %s", op, out.Message)
- }
- return nil
-}
-
-func isAlreadyExistsMessage(message string) bool {
- message = strings.ToLower(message)
- return strings.Contains(message, "exist") || strings.Contains(message, "已存在")
-}
diff --git a/internal/service/storage_upload_jobs_test.go b/internal/service/storage_upload_jobs_test.go
deleted file mode 100644
index 468c765..0000000
--- a/internal/service/storage_upload_jobs_test.go
+++ /dev/null
@@ -1,192 +0,0 @@
-package service
-
-import (
- "net/http"
- "net/http/httptest"
- "net/url"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "go.uber.org/zap"
-)
-
-func TestSchedulerCloudUploadUsesConfiguredLocalSource(t *testing.T) {
- var uploaded []string
- alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/api/fs/mkdir":
- _, _ = w.Write([]byte(`{"code":200}`))
- case "/api/fs/get":
- w.WriteHeader(http.StatusNotFound)
- case "/api/fs/put":
- decoded, _ := url.PathUnescape(r.Header.Get("File-Path"))
- uploaded = append(uploaded, decoded)
- _, _ = w.Write([]byte(`{"code":200}`))
- default:
- t.Fatalf("unexpected alist path %s", r.URL.Path)
- }
- }))
- defer alist.Close()
-
- repos, storage := newStorageUploadTestService(t)
- if _, err := storage.Save(t.Context(), StorageInput{
- Type: "alist",
- Config: map[string]any{
- "server": alist.URL,
- "token": "token",
- "transfer_enabled": "true",
- },
- }); err != nil {
- t.Fatal(err)
- }
- source := t.TempDir()
- if err := os.WriteFile(filepath.Join(source, "Show.S01E01.mkv"), []byte("episode"), 0o644); err != nil {
- t.Fatal(err)
- }
- for key, value := range map[string]string{
- CloudUploadAutoEnabledKey: "true",
- CloudUploadProviderKey: "alist",
- CloudUploadSourceDirKey: source,
- CloudUploadDestPathKey: "/cloud-media",
- CloudUploadRecursiveKey: "true",
- CloudUploadSidecarsKey: "false",
- } {
- if err := repos.Setting.Set(t.Context(), key, value); err != nil {
- t.Fatal(err)
- }
- }
- scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, nil, storage, NewHub(zap.NewNop()), "")
- if err := scheduler.jobUploadLocalToCloud(t.Context()); err != nil {
- t.Fatalf("cloud upload job: %v", err)
- }
- if len(uploaded) != 1 || uploaded[0] != "/cloud-media/Show.S01E01.mkv" {
- t.Fatalf("uploaded = %#v", uploaded)
- }
-}
-
-func TestStorageConfigUploadLocalToCloudDrive2(t *testing.T) {
- var uploaded []string
- dav := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case "MKCOL":
- w.WriteHeader(http.StatusCreated)
- case http.MethodHead:
- w.WriteHeader(http.StatusNotFound)
- case http.MethodPut:
- uploaded = append(uploaded, r.URL.Path)
- w.WriteHeader(http.StatusCreated)
- default:
- t.Fatalf("unexpected method %s %s", r.Method, r.URL.Path)
- }
- }))
- defer dav.Close()
-
- _, storage := newStorageUploadTestService(t)
- if _, err := storage.Save(t.Context(), StorageInput{
- Type: "clouddrive2",
- Config: map[string]any{
- "url": dav.URL + "/dav",
- "username": "user",
- "password": "pass",
- "transfer_enabled": "true",
- },
- }); err != nil {
- t.Fatal(err)
- }
- source := t.TempDir()
- if err := os.WriteFile(filepath.Join(source, "Movie.mkv"), []byte("movie"), 0o644); err != nil {
- t.Fatal(err)
- }
- res, err := storage.UploadLocal(t.Context(), CloudUploadInput{
- Type: "clouddrive2",
- SourcePath: source,
- DestPath: "/MediaStationGo",
- Recursive: true,
- })
- if err != nil {
- t.Fatalf("upload local: %v", err)
- }
- if res.Uploaded != 1 || len(uploaded) != 1 || uploaded[0] != "/dav/MediaStationGo/Movie.mkv" {
- t.Fatalf("result = %+v uploaded=%#v", res, uploaded)
- }
-}
-
-func TestStorageConfigUploadLocalRequiresTransferEnabled(t *testing.T) {
- _, storage := newStorageUploadTestService(t)
- if _, err := storage.Save(t.Context(), StorageInput{
- Type: "alist",
- Config: map[string]any{
- "server": "http://alist.test",
- "token": "token",
- },
- }); err != nil {
- t.Fatal(err)
- }
- source := t.TempDir()
- if err := os.WriteFile(filepath.Join(source, "Movie.mkv"), []byte("movie"), 0o644); err != nil {
- t.Fatal(err)
- }
- _, err := storage.UploadLocal(t.Context(), CloudUploadInput{
- Type: "alist",
- SourcePath: source,
- DestPath: "/MediaStationGo",
- Recursive: true,
- })
- if err == nil || !strings.Contains(err.Error(), "transfer is disabled") {
- t.Fatalf("upload error = %v, want transfer disabled", err)
- }
-}
-
-func TestStorageConfigUploadLocalMoveDeletesSourceAfterUpload(t *testing.T) {
- var uploaded []string
- alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/api/fs/mkdir":
- _, _ = w.Write([]byte(`{"code":200}`))
- case "/api/fs/get":
- w.WriteHeader(http.StatusNotFound)
- case "/api/fs/put":
- decoded, _ := url.PathUnescape(r.Header.Get("File-Path"))
- uploaded = append(uploaded, decoded)
- _, _ = w.Write([]byte(`{"code":200}`))
- default:
- t.Fatalf("unexpected alist path %s", r.URL.Path)
- }
- }))
- defer alist.Close()
-
- _, storage := newStorageUploadTestService(t)
- if _, err := storage.Save(t.Context(), StorageInput{
- Type: "alist",
- Config: map[string]any{
- "server": alist.URL,
- "token": "token",
- "transfer_enabled": "true",
- "transfer_mode": "move",
- },
- }); err != nil {
- t.Fatal(err)
- }
- source := t.TempDir()
- file := filepath.Join(source, "Movie.mkv")
- if err := os.WriteFile(file, []byte("movie"), 0o644); err != nil {
- t.Fatal(err)
- }
- res, err := storage.UploadLocal(t.Context(), CloudUploadInput{
- Type: "alist",
- SourcePath: source,
- DestPath: "/MediaStationGo",
- Recursive: true,
- })
- if err != nil {
- t.Fatalf("upload local move: %v", err)
- }
- if res.Uploaded != 1 || res.Moved != 1 || len(uploaded) != 1 {
- t.Fatalf("result = %+v uploaded=%#v", res, uploaded)
- }
- if _, err := os.Stat(file); !os.IsNotExist(err) {
- t.Fatalf("source should be removed after move upload, stat err=%v", err)
- }
-}
diff --git a/internal/service/storage_upload_test.go b/internal/service/storage_upload_test.go
deleted file mode 100644
index d39036f..0000000
--- a/internal/service/storage_upload_test.go
+++ /dev/null
@@ -1,233 +0,0 @@
-package service
-
-import (
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "net/url"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestStorageConfigUploadLocalToAlist(t *testing.T) {
- var uploaded []string
- var authHeaders []string
- alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/api/fs/mkdir":
- _ = json.NewDecoder(r.Body).Decode(&map[string]string{})
- _, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
- case "/api/fs/get":
- w.WriteHeader(http.StatusNotFound)
- _, _ = w.Write([]byte(`{"code":404,"message":"not found"}`))
- case "/api/fs/put":
- authHeaders = append(authHeaders, r.Header.Get("Authorization"))
- decoded, err := url.PathUnescape(r.Header.Get("File-Path"))
- if err != nil {
- t.Fatalf("decode file path: %v", err)
- }
- uploaded = append(uploaded, decoded)
- _, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
- default:
- t.Fatalf("unexpected alist path %s", r.URL.Path)
- }
- }))
- defer alist.Close()
-
- repos, storage := newStorageUploadTestService(t)
- if _, err := storage.Save(t.Context(), StorageInput{
- Type: "alist",
- Config: map[string]any{
- "server": alist.URL,
- "token": "alist-token",
- "transfer_enabled": "true",
- },
- }); err != nil {
- t.Fatal(err)
- }
- source := t.TempDir()
- if err := os.WriteFile(filepath.Join(source, "Movie.2026.mkv"), []byte("movie"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(source, "Movie.2026.nfo"), []byte("nfo"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(source, "ignore.txt"), []byte("txt"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- res, err := storage.UploadLocal(t.Context(), CloudUploadInput{
- Type: "alist",
- SourcePath: source,
- DestPath: "/backup",
- Recursive: true,
- IncludeSidecars: true,
- })
- if err != nil {
- t.Fatalf("upload local: %v", err)
- }
- if res.Uploaded != 2 || res.Skipped != 0 || len(res.Errors) != 0 {
- t.Fatalf("result = %+v", res)
- }
- sort.Strings(uploaded)
- want := []string{"/backup/Movie.2026.mkv", "/backup/Movie.2026.nfo"}
- if strings.Join(uploaded, "\n") != strings.Join(want, "\n") {
- t.Fatalf("uploaded = %#v, want %#v", uploaded, want)
- }
- for _, header := range authHeaders {
- if header != "alist-token" {
- t.Fatalf("authorization header = %q", header)
- }
- }
- if got, _ := repos.StorageConfig.Get(t.Context(), "alist"); got == nil {
- t.Fatalf("storage config should remain saved")
- }
-}
-
-func TestStorageConfigUploadLocalToOpenListAPI(t *testing.T) {
- var uploaded []string
- openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/api/fs/mkdir":
- _, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
- case "/api/fs/get":
- w.WriteHeader(http.StatusNotFound)
- _, _ = w.Write([]byte(`{"code":404,"message":"not found"}`))
- case "/api/fs/put":
- if r.Header.Get("Authorization") != "openlist-token" {
- t.Fatalf("authorization = %q", r.Header.Get("Authorization"))
- }
- decoded, err := url.PathUnescape(r.Header.Get("File-Path"))
- if err != nil {
- t.Fatalf("decode file path: %v", err)
- }
- uploaded = append(uploaded, decoded)
- _, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
- default:
- t.Fatalf("unexpected openlist path %s", r.URL.Path)
- }
- }))
- defer openlist.Close()
-
- _, storage := newStorageUploadTestService(t)
- if _, err := storage.Save(t.Context(), StorageInput{
- Type: "openlist",
- Config: map[string]any{
- "server": openlist.URL,
- "token": "openlist-token",
- "transfer_enabled": "true",
- },
- }); err != nil {
- t.Fatal(err)
- }
- source := t.TempDir()
- if err := os.WriteFile(filepath.Join(source, "Movie.2026.mkv"), []byte("movie"), 0o644); err != nil {
- t.Fatal(err)
- }
- res, err := storage.UploadLocal(t.Context(), CloudUploadInput{
- Type: "openlist",
- SourcePath: source,
- DestPath: "/OpenList",
- Recursive: true,
- })
- if err != nil {
- t.Fatalf("upload local: %v", err)
- }
- if res.Uploaded != 1 || len(uploaded) != 1 || uploaded[0] != "/OpenList/Movie.2026.mkv" {
- t.Fatalf("result = %+v uploaded=%#v", res, uploaded)
- }
-}
-
-func TestStorageConfigUploadLocalToOpenListAPIWithUsernamePassword(t *testing.T) {
- var loginSeen bool
- var uploaded []string
- openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- switch r.URL.Path {
- case "/api/auth/login":
- loginSeen = true
- var body map[string]string
- if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
- t.Fatalf("decode login body: %v", err)
- }
- if body["username"] != "alice" || body["password"] != "secret" {
- t.Fatalf("login body = %#v", body)
- }
- _, _ = w.Write([]byte(`{"code":200,"data":{"token":"openlist-session-token"}}`))
- case "/api/fs/mkdir":
- if r.Header.Get("Authorization") != "openlist-session-token" {
- t.Fatalf("mkdir authorization = %q", r.Header.Get("Authorization"))
- }
- _, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
- case "/api/fs/get":
- if r.Header.Get("Authorization") != "openlist-session-token" {
- t.Fatalf("get authorization = %q", r.Header.Get("Authorization"))
- }
- w.WriteHeader(http.StatusNotFound)
- _, _ = w.Write([]byte(`{"code":404,"message":"not found"}`))
- case "/api/fs/put":
- if r.Header.Get("Authorization") != "openlist-session-token" {
- t.Fatalf("put authorization = %q", r.Header.Get("Authorization"))
- }
- decoded, err := url.PathUnescape(r.Header.Get("File-Path"))
- if err != nil {
- t.Fatalf("decode file path: %v", err)
- }
- uploaded = append(uploaded, decoded)
- _, _ = w.Write([]byte(`{"code":200,"message":"success"}`))
- case "/dav":
- t.Fatal("OpenList username/password upload should use API, not WebDAV")
- default:
- t.Fatalf("unexpected openlist path %s", r.URL.Path)
- }
- }))
- defer openlist.Close()
-
- _, storage := newStorageUploadTestService(t)
- if _, err := storage.Save(t.Context(), StorageInput{
- Type: "openlist",
- Config: map[string]any{
- "server": openlist.URL,
- "username": "alice",
- "password": "secret",
- "transfer_enabled": "true",
- },
- }); err != nil {
- t.Fatal(err)
- }
- source := t.TempDir()
- if err := os.WriteFile(filepath.Join(source, "Movie.2026.mkv"), []byte("movie"), 0o644); err != nil {
- t.Fatal(err)
- }
- res, err := storage.UploadLocal(t.Context(), CloudUploadInput{
- Type: "openlist",
- SourcePath: source,
- DestPath: "/OpenList",
- Recursive: true,
- })
- if err != nil {
- t.Fatalf("upload local: %v", err)
- }
- if !loginSeen {
- t.Fatal("expected OpenList API login")
- }
- if res.Uploaded != 1 || len(uploaded) != 1 || uploaded[0] != "/OpenList/Movie.2026.mkv" {
- t.Fatalf("result = %+v uploaded=%#v", res, uploaded)
- }
-}
-
-func newStorageUploadTestService(t *testing.T) (*repository.Container, *StorageConfigService) {
- t.Helper()
- db := newServiceTestDB(t, &model.StorageConfig{}, &model.Setting{}, &model.Library{}, &model.Media{})
- repos := repository.New(db)
- log := zap.NewNop()
- return repos, NewStorageConfigService(log, repos, NewCryptoService("", log))
-}
diff --git a/internal/service/storage_upload_webdav.go b/internal/service/storage_upload_webdav.go
deleted file mode 100644
index 53817c6..0000000
--- a/internal/service/storage_upload_webdav.go
+++ /dev/null
@@ -1,128 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "os"
- "path"
- "strings"
-)
-
-type webDAVUploader struct {
- base *url.URL
- username string
- password string
- client *http.Client
-}
-
-func newWebDAVUploader(cfg map[string]any) *webDAVUploader {
- u, _ := url.Parse(strings.TrimRight(strr(cfg["url"]), "/"))
- return &webDAVUploader{
- base: u,
- username: strr(cfg["username"]),
- password: strr(cfg["password"]),
- client: &http.Client{},
- }
-}
-
-func (w *webDAVUploader) ensureDir(ctx context.Context, remoteDir string) error {
- if w.base == nil || w.base.Scheme == "" || w.base.Host == "" {
- return errors.New("webdav missing url")
- }
- remoteDir = normalizeRemotePath(remoteDir)
- if remoteDir == "/" {
- return nil
- }
- current := ""
- for _, part := range strings.Split(strings.Trim(remoteDir, "/"), "/") {
- current = normalizeRemotePath(path.Join(current, part))
- req, err := http.NewRequestWithContext(ctx, "MKCOL", w.urlFor(current), nil)
- if err != nil {
- return err
- }
- w.auth(req)
- resp, err := w.client.Do(req)
- if err != nil {
- return decorateStorageTransportError("webdav", w.urlFor(current), err)
- }
- _, _ = io.Copy(io.Discard, resp.Body)
- _ = resp.Body.Close()
- if resp.StatusCode >= 200 && resp.StatusCode < 300 {
- continue
- }
- if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusConflict {
- continue
- }
- return fmt.Errorf("webdav mkdir %s: http %d", current, resp.StatusCode)
- }
- return nil
-}
-
-func (w *webDAVUploader) exists(ctx context.Context, remotePath string) (bool, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodHead, w.urlFor(remotePath), nil)
- if err != nil {
- return false, err
- }
- w.auth(req)
- resp, err := w.client.Do(req)
- if err != nil {
- return false, decorateStorageTransportError("webdav", w.urlFor(remotePath), err)
- }
- _, _ = io.Copy(io.Discard, resp.Body)
- _ = resp.Body.Close()
- if resp.StatusCode == http.StatusNotFound {
- return false, nil
- }
- return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
-}
-
-func (w *webDAVUploader) upload(ctx context.Context, localPath, remotePath string, size int64) error {
- f, err := os.Open(localPath) // #nosec G304 -- localPath is selected from configured local media files before upload.
- if err != nil {
- return err
- }
- defer f.Close()
- req, err := http.NewRequestWithContext(ctx, http.MethodPut, w.urlFor(remotePath), f)
- if err != nil {
- return err
- }
- w.auth(req)
- req.ContentLength = size
- resp, err := w.client.Do(req)
- if err != nil {
- return decorateStorageTransportError("webdav", w.urlFor(remotePath), err)
- }
- _, _ = io.Copy(io.Discard, resp.Body)
- _ = resp.Body.Close()
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return fmt.Errorf("webdav upload %s: http %d", remotePath, resp.StatusCode)
- }
- return nil
-}
-
-func (w *webDAVUploader) auth(req *http.Request) {
- if w.username != "" {
- req.SetBasicAuth(w.username, w.password)
- }
-}
-
-func (w *webDAVUploader) urlFor(remotePath string) string {
- u := *w.base
- basePath := strings.TrimRight(u.EscapedPath(), "/")
- segments := make([]string, 0)
- if basePath != "" && basePath != "/" {
- segments = append(segments, strings.Trim(basePath, "/"))
- }
- for _, part := range strings.Split(strings.Trim(normalizeRemotePath(remotePath), "/"), "/") {
- if part != "" {
- segments = append(segments, url.PathEscape(part))
- }
- }
- u.RawPath = ""
- u.Path = "/" + strings.Join(segments, "/")
- return u.String()
-}
diff --git a/internal/service/strm_generate.go b/internal/service/strm_generate.go
deleted file mode 100644
index 0d2628e..0000000
--- a/internal/service/strm_generate.go
+++ /dev/null
@@ -1,309 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "strconv"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-type GenerateSTRMOptions struct {
- LibraryID string `json:"library_id"`
- OutputDir string `json:"output_dir"`
- BaseURL string `json:"base_url,omitempty"`
- Enabled bool `json:"enabled"`
- Overwrite bool `json:"overwrite"`
- IncludeLocal bool `json:"include_local"`
- PreserveTree bool `json:"preserve_tree"`
- PlaybackToken string `json:"-"`
- SkipSettingsSave bool `json:"-"`
-}
-
-const strmTreeIgnoredItemSampleLimit = 20
-
-type GenerateSTRMResult struct {
- LibraryID string `json:"library_id"`
- OutputDir string `json:"output_dir"`
- Generated int `json:"generated"`
- Updated int `json:"updated"`
- Skipped int `json:"skipped"`
- Cleaned int `json:"cleaned"`
- Total int `json:"total,omitempty"`
- Remaining int `json:"remaining,omitempty"`
- BatchLimited bool `json:"batch_limited,omitempty"`
- Ignored int `json:"ignored,omitempty"`
- Previewed int `json:"previewed,omitempty"`
- Errors []string `json:"errors,omitempty"`
- IgnoredItems []string `json:"ignored_items,omitempty"`
- Items []GenerateSTRMItem `json:"items,omitempty"`
- Refresh *STRMRefreshResult `json:"refresh,omitempty"`
-}
-
-type GenerateSTRMItem struct {
- MediaID string `json:"media_id"`
- Title string `json:"title"`
- FilePath string `json:"file_path"`
- URL string `json:"url,omitempty"`
- Action string `json:"action"`
- Reason string `json:"reason,omitempty"`
-}
-
-func (s *STRMService) GenerateForLibrary(ctx context.Context, opts GenerateSTRMOptions) (*GenerateSTRMResult, error) {
- if s == nil || s.repo == nil || s.repo.DB == nil {
- return nil, errors.New("strm service unavailable")
- }
- libraryID := strings.TrimSpace(opts.LibraryID)
- if libraryID == "" {
- return nil, errors.New("library_id required")
- }
- lib, err := s.repo.Library.FindByID(ctx, libraryID)
- if err != nil {
- return nil, err
- }
- if lib == nil {
- return nil, errors.New("library not found")
- }
- outputDir := s.resolveSTRMOutputDir(ctx, lib, opts)
- if outputDir == "" || outputDir == "." {
- return nil, errors.New("output_dir required")
- }
- s.saveSTRMGenerationSettings(ctx, outputDir, opts)
- if err := os.MkdirAll(outputDir, 0o755); err != nil { // #nosec G301 -- STRM output directories must stay readable by NAS/player users.
- return nil, err
- }
-
- rows, err := s.librarySTRMMedia(ctx, libraryID)
- if err != nil {
- return nil, err
- }
- res := &GenerateSTRMResult{LibraryID: libraryID, OutputDir: outputDir}
- expectedFiles := map[string]struct{}{}
- for _, media := range rows {
- select {
- case <-ctx.Done():
- return res, ctx.Err()
- default:
- }
- item := s.generateOne(ctx, *lib, media, outputDir, opts)
- res.addItem(item)
- if item.FilePath != "" && item.Action != "error" {
- expectedFiles[filepath.Clean(item.FilePath)] = struct{}{}
- }
- }
- if opts.Overwrite {
- cleaned, err := s.cleanupStaleGeneratedSTRM(ctx, outputDir, expectedFiles)
- if err != nil {
- res.Errors = append(res.Errors, err.Error())
- }
- res.Cleaned += cleaned
- }
- return res, nil
-}
-
-func (s *STRMService) GenerateForAllLibraries(ctx context.Context, opts GenerateSTRMOptions) (*GenerateSTRMResult, error) {
- if s == nil || s.repo == nil || s.repo.Library == nil {
- return nil, errors.New("strm service unavailable")
- }
- libraries, err := s.repo.Library.List(ctx)
- if err != nil {
- return nil, err
- }
- baseOutputDir := s.remapLegacySTRMOutputDir(resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir)))
- result := &GenerateSTRMResult{LibraryID: "*", OutputDir: baseOutputDir}
- for _, lib := range libraries {
- select {
- case <-ctx.Done():
- return result, ctx.Err()
- default:
- }
- next := opts
- next.LibraryID = lib.ID
- next.SkipSettingsSave = true
- if baseOutputDir != "" && baseOutputDir != "." {
- next.OutputDir = filepath.Join(baseOutputDir, strmLibraryOutputSubdir(lib))
- }
- part, err := s.GenerateForLibrary(ctx, next)
- if err != nil {
- result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", lib.Name, err))
- continue
- }
- result.merge(part)
- }
- if baseOutputDir != "" && baseOutputDir != "." && s.repo.Setting != nil {
- _ = s.repo.Setting.Set(ctx, "strm.output_dir", baseOutputDir)
- _ = s.repo.Setting.Set(ctx, "strm.output_scope", "all")
- _ = s.repo.Setting.Set(ctx, "strm.preserve_tree", strconv.FormatBool(opts.PreserveTree))
- result.OutputDir = baseOutputDir
- }
- return result, nil
-}
-
-func (s *STRMService) resolveSTRMOutputDir(ctx context.Context, lib *model.Library, opts GenerateSTRMOptions) string {
- outputDir := s.remapLegacySTRMOutputDir(resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir)))
- if (outputDir == "" || outputDir == ".") && s.repo.Setting != nil {
- if saved, err := s.repo.Setting.Get(ctx, "strm.output_dir"); err == nil {
- outputDir = s.remapLegacySTRMOutputDir(resolveMappedDestinationPath(strings.TrimSpace(saved)))
- }
- }
- if outputDir == "" || outputDir == "." {
- outputDir = s.defaultOutputDir(lib)
- }
- return strmLibrarySpecificOutputDir(outputDir, lib)
-}
-
-func (s *STRMService) remapLegacySTRMOutputDir(outputDir string) string {
- outputDir = filepath.Clean(strings.TrimSpace(outputDir))
- if outputDir == "" || outputDir == "." || s == nil || s.cfg == nil {
- return outputDir
- }
- dataDir := filepath.Clean(strings.TrimSpace(s.cfg.App.DataDir))
- if dataDir == "" || dataDir == "." || sameLibraryPath(dataDir, "/app/data") {
- return outputDir
- }
- legacy := "/app/data"
- normalized := strings.TrimRight(cleanPathForVolumeMapping(outputDir), "/")
- if sameLibraryPath(normalized, legacy) {
- return dataDir
- }
- if strings.HasPrefix(strings.ToLower(normalized), legacy+"/") {
- rel := strings.TrimPrefix(normalized[len(legacy):], "/")
- if rel != "" {
- return filepath.Join(dataDir, filepath.FromSlash(rel))
- }
- }
- return outputDir
-}
-
-func (s *STRMService) saveSTRMGenerationSettings(ctx context.Context, outputDir string, opts GenerateSTRMOptions) {
- if opts.SkipSettingsSave {
- return
- }
- if strings.TrimSpace(opts.BaseURL) != "" && s.repo.Setting != nil {
- baseURL := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/")
- _ = s.repo.Setting.Set(ctx, "app.server_url", baseURL)
- _ = s.repo.Setting.Set(ctx, "strm.base_url", baseURL)
- }
- if s.repo.Setting == nil {
- return
- }
- _ = s.repo.Setting.Set(ctx, "strm.auto_generate_enabled", strconv.FormatBool(opts.Enabled))
- _ = s.repo.Setting.Set(ctx, "strm.output_dir", outputDir)
- _ = s.repo.Setting.Set(ctx, "strm.output_scope", "library")
- _ = s.repo.Setting.Set(ctx, "strm.preserve_tree", strconv.FormatBool(opts.PreserveTree))
-}
-
-func (s *STRMService) librarySTRMMedia(ctx context.Context, libraryID string) ([]model.Media, error) {
- var rows []model.Media
- err := s.repo.DB.WithContext(ctx).
- Where("library_id = ?", libraryID).
- Order("title asc, season_num asc, episode_num asc, created_at asc").
- Find(&rows).Error
- return rows, err
-}
-
-func (s *STRMService) defaultOutputDir(lib *model.Library) string {
- subdir := strmLibraryOutputSubdir(*lib)
- if s != nil && s.cfg != nil && strings.TrimSpace(s.cfg.App.DataDir) != "" {
- return filepath.Join(s.cfg.App.DataDir, "strm", subdir)
- }
- return filepath.Join("data", "strm", subdir)
-}
-
-func (s *STRMService) generateOne(ctx context.Context, lib model.Library, media model.Media, outputDir string, opts GenerateSTRMOptions) GenerateSTRMItem {
- item := GenerateSTRMItem{MediaID: media.ID, Title: media.Title}
- playURL := s.strmPlaybackURL(ctx, media, opts.BaseURL, opts.PlaybackToken)
- if playURL == "" {
- item.Action = "skipped"
- item.Reason = "no playable strm target"
- return item
- }
- if strings.TrimSpace(media.STRMURL) == "" && !opts.IncludeLocal {
- item.Action = "skipped"
- item.Reason = "local media skipped"
- return item
- }
- rel := s.strmRelativePath(lib, media)
- if opts.PreserveTree {
- if treeRel := s.strmTreeRelativePath(media); treeRel != "" {
- rel = treeRel
- }
- }
- if rel == "" {
- item.Action = "skipped"
- item.Reason = "cannot build file name"
- return item
- }
- filePath := filepath.Join(outputDir, rel)
- item.FilePath = filePath
- item.URL = playURL
- if _, err := os.Stat(filePath); err == nil && !opts.Overwrite {
- item.Action = "skipped"
- item.Reason = "target exists"
- return item
- }
- action := "generated"
- if _, err := os.Stat(filePath); err == nil {
- action = "updated"
- }
- if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { // #nosec G301 -- STRM output directories must stay readable by NAS/player users.
- item.Action = "error"
- item.Reason = err.Error()
- return item
- }
- if err := os.WriteFile(filePath, []byte(playURL+"\n"), 0o644); err != nil { // #nosec G306 -- STRM files are media sidecars intended to be readable by players.
- item.Action = "error"
- item.Reason = err.Error()
- return item
- }
- if err := s.upsertGeneratedRecord(ctx, media, filePath, playURL, lib.Type); err != nil {
- item.Action = "error"
- item.Reason = err.Error()
- return item
- }
- item.Action = action
- return item
-}
-
-func (r *GenerateSTRMResult) addItem(item GenerateSTRMItem) {
- r.Items = append(r.Items, item)
- switch item.Action {
- case "generated":
- r.Generated++
- case "updated":
- r.Updated++
- case "skipped":
- r.Skipped++
- case "preview":
- r.Previewed++
- case "error":
- r.Errors = append(r.Errors, fmt.Sprintf("%s: %s", item.Title, item.Reason))
- }
-}
-
-func (r *GenerateSTRMResult) merge(part *GenerateSTRMResult) {
- if part == nil {
- return
- }
- if r.OutputDir == "" || r.OutputDir == "." {
- r.OutputDir = filepath.Dir(part.OutputDir)
- }
- r.Generated += part.Generated
- r.Updated += part.Updated
- r.Skipped += part.Skipped
- r.Cleaned += part.Cleaned
- r.Previewed += part.Previewed
- r.Ignored += part.Ignored
- r.Errors = append(r.Errors, part.Errors...)
- for _, item := range part.IgnoredItems {
- if len(r.IgnoredItems) >= strmTreeIgnoredItemSampleLimit {
- break
- }
- r.IgnoredItems = append(r.IgnoredItems, item)
- }
- r.Items = append(r.Items, part.Items...)
-}
diff --git a/internal/service/strm_generate_cleanup.go b/internal/service/strm_generate_cleanup.go
deleted file mode 100644
index 9cca145..0000000
--- a/internal/service/strm_generate_cleanup.go
+++ /dev/null
@@ -1,116 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "io/fs"
- "net/url"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *STRMService) upsertGeneratedRecord(ctx context.Context, media model.Media, filePath, playURL, mediaType string) error {
- protocol := ""
- if u, err := url.Parse(playURL); err == nil {
- protocol = strings.ToLower(u.Scheme)
- }
- if protocol == "" {
- protocol = "http"
- }
- record := model.STRMRecord{
- Title: media.Title,
- URL: playURL,
- FilePath: filePath,
- Protocol: protocol,
- MediaID: media.ID,
- MediaType: mediaType,
- SeasonNum: media.SeasonNum,
- EpisodeNum: media.EpisodeNum,
- }
- var existing model.STRMRecord
- err := s.repo.DB.WithContext(ctx).Where("media_id = ? AND file_path = ?", media.ID, filePath).First(&existing).Error
- if err == nil {
- existing.Title = record.Title
- existing.URL = record.URL
- existing.Protocol = record.Protocol
- existing.MediaType = record.MediaType
- existing.SeasonNum = record.SeasonNum
- existing.EpisodeNum = record.EpisodeNum
- return s.repo.DB.WithContext(ctx).Save(&existing).Error
- }
- return s.repo.DB.WithContext(ctx).Create(&record).Error
-}
-
-func (s *STRMService) cleanupStaleGeneratedSTRM(ctx context.Context, outputDir string, expected map[string]struct{}) (int, error) {
- outputDir = filepath.Clean(strings.TrimSpace(outputDir))
- if outputDir == "" || outputDir == "." {
- return 0, nil
- }
- cleaned, err := removeStaleSTRMFiles(outputDir, expected)
- if err != nil {
- return cleaned, err
- }
- recordsCleaned, err := s.removeStaleSTRMRecords(ctx, outputDir, expected)
- return cleaned + recordsCleaned, err
-}
-
-func removeStaleSTRMFiles(outputDir string, expected map[string]struct{}) (int, error) {
- cleaned := 0
- err := filepath.WalkDir(outputDir, func(path string, entry fs.DirEntry, walkErr error) error {
- if walkErr != nil {
- return nil
- }
- if entry.IsDir() || strings.ToLower(filepath.Ext(path)) != ".strm" {
- return nil
- }
- cleanPath := filepath.Clean(path)
- if _, ok := expected[cleanPath]; ok {
- return nil
- }
- if err := os.Remove(cleanPath); err != nil && !errors.Is(err, os.ErrNotExist) {
- return err
- }
- cleaned++
- return nil
- })
- if err != nil && !errors.Is(err, os.ErrNotExist) {
- return cleaned, err
- }
- return cleaned, nil
-}
-
-func (s *STRMService) removeStaleSTRMRecords(ctx context.Context, outputDir string, expected map[string]struct{}) (int, error) {
- if s == nil || s.repo == nil || s.repo.DB == nil {
- return 0, nil
- }
- var records []model.STRMRecord
- if err := s.repo.DB.WithContext(ctx).Find(&records).Error; err != nil {
- return 0, err
- }
- rootAbs, err := filepath.Abs(outputDir)
- if err != nil {
- return 0, nil
- }
- cleaned := 0
- for i := range records {
- filePath := filepath.Clean(strings.TrimSpace(records[i].FilePath))
- if filePath == "" {
- continue
- }
- fileAbs, err := filepath.Abs(filePath)
- if err != nil || !pathWithin(fileAbs, rootAbs) {
- continue
- }
- if _, ok := expected[filePath]; ok {
- continue
- }
- if err := s.repo.DB.WithContext(ctx).Delete(&records[i]).Error; err != nil {
- return cleaned, err
- }
- cleaned++
- }
- return cleaned, nil
-}
diff --git a/internal/service/strm_output_dir.go b/internal/service/strm_output_dir.go
deleted file mode 100644
index 81ec8ac..0000000
--- a/internal/service/strm_output_dir.go
+++ /dev/null
@@ -1,195 +0,0 @@
-package service
-
-import (
- "path/filepath"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func strmLibraryOutputSubdir(lib model.Library) string {
- parts := strmLibraryCategoryParts(lib)
- if len(parts) == 0 {
- return sanitizeFilename(lib.Name)
- }
- clean := make([]string, 0, len(parts))
- for _, part := range parts {
- if safe := sanitizeFilename(part); safe != "" {
- clean = append(clean, safe)
- }
- }
- if len(clean) == 0 {
- return sanitizeFilename(lib.Name)
- }
- return filepath.Join(clean...)
-}
-
-func strmLibrarySpecificOutputDir(base string, lib *model.Library) string {
- outputDir := filepath.Clean(strings.TrimSpace(base))
- if outputDir == "" || outputDir == "." || lib == nil {
- return outputDir
- }
- subdir := strmLibraryOutputSubdir(*lib)
- if subdir == "" || strmPathHasSuffix(outputDir, subdir) || pathAlreadyEndsWith(outputDir, filepath.Base(subdir)) {
- return outputDir
- }
- if tail := strmMissingCategoryTail(outputDir, subdir); tail != "" {
- return filepath.Join(outputDir, tail)
- }
- return filepath.Join(outputDir, subdir)
-}
-
-func strmMissingCategoryTail(outputDir, subdir string) string {
- parts := strmSlashParts(subdir)
- if len(parts) < 2 || !pathAlreadyEndsWith(outputDir, parts[0]) {
- return ""
- }
- return filepath.Join(parts[1:]...)
-}
-
-func strmPathHasSuffix(pathValue, suffix string) bool {
- pathValue = strings.Trim(filepath.ToSlash(filepath.Clean(strings.TrimSpace(pathValue))), "/")
- suffix = strings.Trim(filepath.ToSlash(filepath.Clean(strings.TrimSpace(suffix))), "/")
- if pathValue == "" || suffix == "" || suffix == "." {
- return false
- }
- if strings.EqualFold(pathValue, suffix) {
- return true
- }
- return strings.HasSuffix(strings.ToLower(pathValue), "/"+strings.ToLower(suffix))
-}
-
-func strmLibraryCategoryParts(lib model.Library) []string {
- if parts := strmCategoryPartsFromPath(strmLibraryPathParts(lib.Path)); len(parts) > 0 {
- return parts
- }
- if parts := strmCategoryPartsFromPath(strmNameParts(lib.Name)); len(parts) > 0 {
- return parts
- }
- if root := mediaTypeRootDir(lib.Type); root != "" {
- return []string{root}
- }
- return nil
-}
-
-func strmLibraryPathParts(raw string) []string {
- if info, ok := ParseCloudLibraryMount(raw); ok {
- return strmSlashParts(info.DisplayDir)
- }
- clean := cleanPathForVolumeMapping(raw)
- clean = strings.Trim(pathAfterWindowsDrivePrefix(clean), "/")
- return strmSlashParts(clean)
-}
-
-func strmNameParts(name string) []string {
- name = strings.NewReplacer("·", "/", ">", "/", "|", "/", "|", "/", "\\", "/").Replace(name)
- return strmSlashParts(name)
-}
-
-func strmSlashParts(raw string) []string {
- raw = strings.Trim(strings.TrimSpace(strings.ReplaceAll(raw, "\\", "/")), "/")
- if raw == "" || raw == "." {
- return nil
- }
- fields := strings.Split(raw, "/")
- parts := make([]string, 0, len(fields))
- for _, part := range fields {
- part = strings.TrimSpace(part)
- if part != "" && part != "." {
- parts = append(parts, part)
- }
- }
- return parts
-}
-
-func strmCategoryPartsFromPath(parts []string) []string {
- for i, part := range parts {
- if root := strmCanonicalRoot(part); root != "" {
- return append([]string{root}, strmSanitizedTail(parts[i+1:])...)
- }
- if root := strmCategoryRoot(part); root != "" {
- return []string{root, strmCanonicalCategory(part)}
- }
- }
- return nil
-}
-
-func strmSanitizedTail(parts []string) []string {
- out := make([]string, 0, len(parts))
- for _, part := range parts {
- if strings.TrimSpace(part) != "" {
- out = append(out, part)
- }
- }
- return out
-}
-
-func strmCanonicalRoot(part string) string {
- key := strings.ToLower(strings.TrimSpace(part))
- switch key {
- case "电影", "movie", "movies", "film", "films":
- return "电影"
- case "电视剧", "剧集", "tv", "tvs", "series", "show", "shows":
- return "电视剧"
- case "动漫", "动画", "anime", "bangumi":
- return "动漫"
- case "成人", "adult", "adults", "jav", "nsfw", "9kg":
- return "成人"
- default:
- return ""
- }
-}
-
-func strmCategoryRoot(part string) string {
- key := strings.ToLower(strings.TrimSpace(part))
- switch key {
- case "演唱会", "音乐会", "纪录片", "纪录", "动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "外国电影", "欧美电影", "日韩电影", "日本电影", "韩国电影":
- return "电影"
- case "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "儿童", "少儿", "未分类":
- return "电视剧"
- case "国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "韩漫", "韩国动漫", "韩国动画", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他", "其他动漫", "其它动漫":
- return "动漫"
- case "番号":
- return "成人"
- default:
- return ""
- }
-}
-
-func strmCanonicalCategory(part string) string {
- key := strings.ToLower(strings.TrimSpace(part))
- switch key {
- case "音乐会":
- return "演唱会"
- case "纪录":
- return "纪录片"
- case "动漫电影":
- return "动画电影"
- case "国产电影":
- return "华语电影"
- case "外语电影", "外国电影":
- return "欧美电影"
- case "日本电影", "韩国电影":
- return "日韩电影"
- case "日剧", "韩剧":
- return "日韩剧"
- case "真人秀":
- return "综艺"
- case "少儿":
- return "儿童"
- case "国产动漫":
- return "国漫"
- case "番剧", "日漫", "日本动漫", "日本动画":
- return "日番"
- case "韩国动漫", "韩国动画":
- return "韩漫"
- case "欧美动漫", "欧美动画", "西方动画":
- return "美漫"
- case "其他动漫", "其它动漫":
- return "其他"
- case "番号":
- return "成人"
- default:
- return strings.TrimSpace(part)
- }
-}
diff --git a/internal/service/strm_output_presets.go b/internal/service/strm_output_presets.go
deleted file mode 100644
index 79394e9..0000000
--- a/internal/service/strm_output_presets.go
+++ /dev/null
@@ -1,79 +0,0 @@
-package service
-
-import (
- "context"
- "path/filepath"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-type STRMOutputPreset struct {
- Label string `json:"label"`
- Path string `json:"path"`
- Kind string `json:"kind"`
-}
-
-func STRMOutputPresets(ctx context.Context, repo *repository.Container) ([]STRMOutputPreset, error) {
- presets := defaultSTRMOutputPresets()
- seen := make(map[string]struct{}, len(presets))
- for _, preset := range presets {
- seen[strmOutputPresetKey(preset.Path)] = struct{}{}
- }
- if repo == nil || repo.Library == nil {
- return presets, nil
- }
- libraries, err := repo.Library.List(ctx)
- if err != nil {
- return nil, err
- }
- for i := range libraries {
- lib := libraries[i]
- if !lib.Enabled {
- continue
- }
- roots, err := repo.Library.ListRoots(ctx, lib.ID)
- if err != nil {
- return nil, err
- }
- if len(roots) == 0 {
- roots = []model.LibraryRoot{{Path: lib.Path, Enabled: lib.Enabled}}
- }
- for j := range roots {
- root := roots[j]
- if !root.Enabled || strings.TrimSpace(root.Path) == "" {
- continue
- }
- if _, ok := ParseCloudLibraryMount(root.Path); ok {
- continue
- }
- pathValue := filepath.Clean(resolveMappedDestinationPath(root.Path))
- if pathValue == "" || pathValue == "." {
- continue
- }
- key := strmOutputPresetKey(pathValue)
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- presets = append(presets, STRMOutputPreset{
- Label: lib.Name,
- Path: pathValue,
- Kind: "library",
- })
- }
- }
- return presets, nil
-}
-
-func defaultSTRMOutputPresets() []STRMOutputPreset {
- return []STRMOutputPreset{
- {Label: "STRM 根目录", Path: filepath.Clean("data/strm"), Kind: "default"},
- {Label: "目录树 STRM", Path: filepath.Clean("data/strm/tree"), Kind: "default"},
- }
-}
-
-func strmOutputPresetKey(pathValue string) string {
- return strings.ToLower(filepath.Clean(strings.TrimSpace(pathValue)))
-}
diff --git a/internal/service/strm_output_presets_test.go b/internal/service/strm_output_presets_test.go
deleted file mode 100644
index 3648bd1..0000000
--- a/internal/service/strm_output_presets_test.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package service
-
-import (
- "path/filepath"
- "testing"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestSTRMOutputPresetsIncludesDefaultsAndLocalLibraries(t *testing.T) {
- db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{})
- repos := repository.New(db)
- base := t.TempDir()
- movieRoot := filepath.Join(base, "strm", "电影")
- tvRoot := filepath.Join(base, "strm", "电视剧")
- cloudRoot := BuildCloudLibraryPath("openlist", "/电影", "/电影")
- disabledRoot := filepath.Join(base, "strm", "动漫")
-
- libraries := []*model.Library{
- {Name: "电影 STRM", Path: movieRoot, Type: "movie", Enabled: true},
- {Name: "电视剧 STRM", Path: tvRoot, Type: "tv", Enabled: true},
- {Name: "云盘", Path: cloudRoot, Type: "movie", Enabled: true},
- {Name: "停用", Path: disabledRoot, Type: "tv", Enabled: false},
- }
- for _, lib := range libraries {
- if err := repos.Library.Create(t.Context(), lib); err != nil {
- t.Fatal(err)
- }
- }
- if err := repos.DB.Model(&model.Library{}).Where("id = ?", libraries[3].ID).Update("enabled", false).Error; err != nil {
- t.Fatal(err)
- }
-
- presets, err := STRMOutputPresets(t.Context(), repos)
- if err != nil {
- t.Fatal(err)
- }
- got := map[string]STRMOutputPreset{}
- for _, preset := range presets {
- got[preset.Path] = preset
- }
-
- if got[filepath.Clean("data/strm")].Kind != "default" || got[filepath.Clean("data/strm/tree")].Kind != "default" {
- t.Fatalf("defaults missing from presets: %#v", presets)
- }
- if got[movieRoot].Label != "电影 STRM" || got[movieRoot].Kind != "library" {
- t.Fatalf("movie preset = %#v, want local library preset", got[movieRoot])
- }
- if got[tvRoot].Label != "电视剧 STRM" || got[tvRoot].Kind != "library" {
- t.Fatalf("tv preset = %#v, want local library preset", got[tvRoot])
- }
- if _, ok := got[cloudRoot]; ok {
- t.Fatalf("cloud library should not be an output preset: %#v", presets)
- }
- if _, ok := got[disabledRoot]; ok {
- t.Fatalf("disabled library should not be an output preset: %#v", presets)
- }
-}
-
-func TestSTRMOutputPresetsDeduplicatesLibraryRoots(t *testing.T) {
- db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{})
- repos := repository.New(db)
- root := filepath.Join(t.TempDir(), "strm")
- lib := model.Library{Name: "STRM", Path: root, Type: "movie", Enabled: true}
- if err := repos.Library.CreateWithRoots(t.Context(), &lib, []model.LibraryRoot{
- {Path: root, Enabled: true},
- {Path: root, Enabled: true},
- }); err != nil {
- t.Fatal(err)
- }
-
- presets, err := STRMOutputPresets(t.Context(), repos)
- if err != nil {
- t.Fatal(err)
- }
- count := 0
- for _, preset := range presets {
- if preset.Path == root {
- count++
- }
- }
- if count != 1 {
- t.Fatalf("root preset count = %d, presets=%#v", count, presets)
- }
-}
diff --git a/internal/service/strm_proxy.go b/internal/service/strm_proxy.go
deleted file mode 100644
index 8d62677..0000000
--- a/internal/service/strm_proxy.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strings"
- "time"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-// ProxySTRM proxies a STRM target and preserves Range requests for players.
-func (s *STRMService) ProxySTRM(ctx context.Context, id string, req *http.Request, w http.ResponseWriter) error {
- record, err := s.repo.STRM.FindByID(ctx, id)
- if err != nil {
- return err
- }
- if record == nil {
- return ErrSTRMNotFound
- }
- if !model.IsAllowedProtocol(record.Protocol) {
- return ErrSTRMProtocolInvalid
- }
-
- targetURL, err := validateSTRMProxyURL(record.URL)
- if err != nil {
- return err
- }
- proxyReq, err := http.NewRequestWithContext(ctx, req.Method, targetURL.String(), nil)
- if err != nil {
- return fmt.Errorf("create proxy request: %w", err)
- }
- copySTRMRequestHeaders(req, proxyReq)
-
- client := &http.Client{Timeout: 60 * time.Second}
- resp, err := client.Do(proxyReq) // #nosec G107,G704 -- STRM proxy target is validated by validateSTRMProxyURL before request creation.
- if err != nil {
- return fmt.Errorf("proxy request failed: %w", err)
- }
- defer resp.Body.Close()
-
- copySTRMResponseHeaders(resp, w)
- w.WriteHeader(resp.StatusCode)
- _, err = io.Copy(w, resp.Body)
- return err
-}
-
-func validateSTRMProxyURL(raw string) (*url.URL, error) {
- u, err := url.Parse(strings.TrimSpace(raw))
- if err != nil || u.Scheme == "" || u.Host == "" {
- return nil, ErrSTRMURLInvalid
- }
- switch strings.ToLower(u.Scheme) {
- case "http", "https":
- default:
- return nil, ErrSTRMProtocolInvalid
- }
- if isPrivateHost(u.Hostname()) {
- return nil, ErrSTRMURLInvalid
- }
- return u, nil
-}
-
-func copySTRMRequestHeaders(src *http.Request, dst *http.Request) {
- for _, header := range []string{
- "Range", "If-Range", "If-Match", "If-None-Match",
- "If-Modified-Since", "If-Unmodified-Since",
- "Accept", "Accept-Encoding", "Accept-Language",
- } {
- if v := src.Header.Get(header); v != "" {
- dst.Header.Set(header, v)
- }
- }
-}
-
-func copySTRMResponseHeaders(src *http.Response, dst http.ResponseWriter) {
- for _, header := range []string{
- "Content-Type", "Content-Length", "Content-Range",
- "Accept-Ranges", "Last-Modified", "ETag",
- "Cache-Control", "Content-Disposition",
- } {
- if v := src.Header.Get(header); v != "" {
- dst.Header().Set(header, v)
- }
- }
-}
diff --git a/internal/service/strm_refresh.go b/internal/service/strm_refresh.go
deleted file mode 100644
index 8c06e0d..0000000
--- a/internal/service/strm_refresh.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package service
-
-import (
- "context"
- "path/filepath"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-type STRMRefreshResult struct {
- Requested bool `json:"requested"`
- Queued bool `json:"queued"`
- Reason string `json:"reason,omitempty"`
- ScrapeRequested bool `json:"scrape_requested,omitempty"`
- ScrapeQueued bool `json:"scrape_queued,omitempty"`
- ScrapeReason string `json:"scrape_reason,omitempty"`
- Targets []STRMRefreshTarget `json:"targets,omitempty"`
-}
-
-type STRMRefreshTarget struct {
- LibraryID string `json:"library_id"`
- RootID string `json:"root_id,omitempty"`
- Name string `json:"name"`
- Path string `json:"path"`
-}
-
-func FindSTRMRefreshTargets(ctx context.Context, repo *repository.Container, outputDir string) ([]STRMRefreshTarget, error) {
- if repo == nil || repo.Library == nil {
- return nil, nil
- }
- outputDir = resolveMappedDestinationPath(strings.TrimSpace(outputDir))
- if outputDir == "" || outputDir == "." {
- return nil, nil
- }
- libraries, err := repo.Library.List(ctx)
- if err != nil {
- return nil, err
- }
- targets := make([]STRMRefreshTarget, 0)
- seen := map[string]struct{}{}
- for i := range libraries {
- lib := libraries[i]
- if !lib.Enabled {
- continue
- }
- roots, err := repo.Library.ListRoots(ctx, lib.ID)
- if err != nil {
- return nil, err
- }
- if len(roots) == 0 && strings.TrimSpace(lib.Path) != "" {
- roots = []model.LibraryRoot{{LibraryID: lib.ID, Path: lib.Path, Enabled: lib.Enabled}}
- }
- for j := range roots {
- root := roots[j]
- if !root.Enabled || strings.TrimSpace(root.Path) == "" {
- continue
- }
- if _, ok := ParseCloudLibraryMount(root.Path); ok {
- continue
- }
- if !strmRefreshPathMatches(outputDir, root.Path) {
- continue
- }
- key := lib.ID + "\x00" + root.ID + "\x00" + strings.ToLower(filepath.Clean(root.Path))
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- targets = append(targets, STRMRefreshTarget{
- LibraryID: lib.ID,
- RootID: root.ID,
- Name: lib.Name,
- Path: filepath.Clean(root.Path),
- })
- }
- }
- return targets, nil
-}
-
-func strmRefreshPathMatches(outputDir, libraryRoot string) bool {
- outputDir = filepath.Clean(strings.TrimSpace(outputDir))
- libraryRoot = filepath.Clean(strings.TrimSpace(libraryRoot))
- if outputDir == "" || outputDir == "." || libraryRoot == "" || libraryRoot == "." {
- return false
- }
- return sameLibraryPath(outputDir, libraryRoot) || pathWithin(outputDir, libraryRoot) || pathWithin(libraryRoot, outputDir)
-}
diff --git a/internal/service/strm_refresh_test.go b/internal/service/strm_refresh_test.go
deleted file mode 100644
index 9f80c76..0000000
--- a/internal/service/strm_refresh_test.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package service
-
-import (
- "path/filepath"
- "testing"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestFindSTRMRefreshTargetsMatchesNestedLocalRoots(t *testing.T) {
- db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{})
- repos := repository.New(db)
- base := t.TempDir()
- movieRoot := filepath.Join(base, "strm", "电影")
- tvRoot := filepath.Join(base, "strm", "电视剧")
- otherRoot := filepath.Join(base, "other")
- cloudRoot := "cloud://openlist/电影"
-
- movie := model.Library{Name: "电影 STRM", Path: movieRoot, Type: "movie", Enabled: true}
- tv := model.Library{Name: "电视剧 STRM", Path: tvRoot, Type: "tv", Enabled: true}
- other := model.Library{Name: "其他", Path: otherRoot, Type: "movie", Enabled: true}
- cloud := model.Library{Name: "云盘", Path: cloudRoot, Type: "movie", Enabled: true}
- disabled := model.Library{Name: "停用", Path: filepath.Join(base, "strm", "动漫"), Type: "tv", Enabled: false}
- for _, lib := range []*model.Library{&movie, &tv, &other, &cloud, &disabled} {
- if err := repos.Library.Create(t.Context(), lib); err != nil {
- t.Fatal(err)
- }
- }
- if err := repos.DB.Model(&model.Library{}).Where("id = ?", disabled.ID).Update("enabled", false).Error; err != nil {
- t.Fatal(err)
- }
-
- targets, err := FindSTRMRefreshTargets(t.Context(), repos, filepath.Join(base, "strm"))
- if err != nil {
- t.Fatal(err)
- }
- if len(targets) != 2 {
- t.Fatalf("targets = %#v, want movie and tv only", targets)
- }
- got := map[string]bool{}
- for _, target := range targets {
- got[target.LibraryID] = true
- }
- if !got[movie.ID] || !got[tv.ID] || got[other.ID] || got[cloud.ID] || got[disabled.ID] {
- t.Fatalf("target libraries = %#v", targets)
- }
-}
-
-func TestFindSTRMRefreshTargetsDoesNotFallbackToAllLibraries(t *testing.T) {
- db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{})
- repos := repository.New(db)
- lib := model.Library{Name: "电影", Path: filepath.Join(t.TempDir(), "movies"), Type: "movie", Enabled: true}
- if err := repos.Library.Create(t.Context(), &lib); err != nil {
- t.Fatal(err)
- }
-
- targets, err := FindSTRMRefreshTargets(t.Context(), repos, filepath.Join(t.TempDir(), "strm"))
- if err != nil {
- t.Fatal(err)
- }
- if len(targets) != 0 {
- t.Fatalf("targets = %#v, want no fallback target", targets)
- }
-}
diff --git a/internal/service/strm_repair.go b/internal/service/strm_repair.go
deleted file mode 100644
index a5088fe..0000000
--- a/internal/service/strm_repair.go
+++ /dev/null
@@ -1,145 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "io/fs"
- "net/url"
- "os"
- "path/filepath"
- "strings"
-)
-
-type RepairSTRMOptions struct {
- OutputDir string `json:"output_dir"`
- BaseURL string `json:"base_url,omitempty"`
- DryRun bool `json:"dry_run,omitempty"`
-}
-
-type RepairSTRMResult struct {
- OutputDir string `json:"output_dir"`
- Repaired int `json:"repaired"`
- Previewed int `json:"previewed,omitempty"`
- Skipped int `json:"skipped"`
- Errors []string `json:"errors,omitempty"`
- Items []RepairSTRMItem `json:"items,omitempty"`
- Refresh *STRMRefreshResult `json:"refresh,omitempty"`
-}
-
-type RepairSTRMItem struct {
- FilePath string `json:"file_path"`
- Before string `json:"before,omitempty"`
- After string `json:"after,omitempty"`
- Action string `json:"action"`
- Reason string `json:"reason,omitempty"`
-}
-
-func (s *STRMService) RepairFiles(ctx context.Context, opts RepairSTRMOptions) (*RepairSTRMResult, error) {
- outputDir := resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir))
- if outputDir == "" || outputDir == "." {
- return nil, errors.New("output_dir required")
- }
- info, err := os.Stat(outputDir)
- if err != nil {
- return nil, err
- }
- if !info.IsDir() {
- return nil, errors.New("output_dir must be a directory")
- }
- result := &RepairSTRMResult{OutputDir: outputDir}
- baseURL := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/")
- err = filepath.WalkDir(outputDir, func(filePath string, entry fs.DirEntry, walkErr error) error {
- if walkErr != nil {
- result.Errors = append(result.Errors, filePath+": "+walkErr.Error())
- return nil
- }
- select {
- case <-ctx.Done():
- return ctx.Err()
- default:
- }
- if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".strm") {
- return nil
- }
- result.addRepairItem(repairSTRMFile(filePath, baseURL, opts.DryRun))
- return nil
- })
- if err != nil {
- return result, err
- }
- return result, nil
-}
-
-func repairSTRMFile(filePath, baseURL string, dryRun bool) RepairSTRMItem {
- item := RepairSTRMItem{FilePath: filePath}
- body, err := os.ReadFile(filePath) // #nosec G304 -- admin-selected STRM repair output directory.
- if err != nil {
- item.Action = "error"
- item.Reason = err.Error()
- return item
- }
- before := strings.TrimSpace(string(body))
- after, ok := repairedSTRMPlaybackURL(before, baseURL)
- if !ok {
- item.Action = "skipped"
- item.Reason = "unsupported strm target"
- return item
- }
- if after == before {
- item.Action = "skipped"
- item.Reason = "already current"
- return item
- }
- item.Before = before
- item.After = after
- if dryRun {
- item.Action = "preview"
- item.Reason = "repaired"
- return item
- }
- if err := os.WriteFile(filePath, []byte(after+"\n"), 0o644); err != nil { // #nosec G306 -- STRM files are player-readable sidecars.
- item.Action = "error"
- item.Reason = err.Error()
- return item
- }
- item.Action = "repaired"
- return item
-}
-
-func repairedSTRMPlaybackURL(raw, baseURL string) (string, bool) {
- raw = strings.TrimSpace(raw)
- if raw == "" || strings.HasPrefix(raw, "//") {
- return "", false
- }
- parsed, err := url.Parse(raw)
- if err != nil || !strmRepairOwnsAPIPath(parsed.Path) {
- return "", false
- }
- apiPath := parsed.EscapedPath()
- if apiPath == "" {
- apiPath = parsed.Path
- }
- if parsed.RawQuery != "" {
- apiPath += "?" + parsed.RawQuery
- }
- return buildAbsoluteSTRMAPIURL(baseURL, apiPath, nil), true
-}
-
-func strmRepairOwnsAPIPath(apiPath string) bool {
- value := strings.ToLower(strings.TrimSpace(apiPath))
- return strings.HasPrefix(value, "/api/stream/") || strings.HasPrefix(value, "/api/cloud/play/")
-}
-
-func (r *RepairSTRMResult) addRepairItem(item RepairSTRMItem) {
- r.Items = append(r.Items, item)
- switch item.Action {
- case "repaired":
- r.Repaired++
- case "preview":
- r.Previewed++
- case "skipped":
- r.Skipped++
- case "error":
- r.Errors = append(r.Errors, item.FilePath+": "+item.Reason)
- }
-}
diff --git a/internal/service/strm_repair_test.go b/internal/service/strm_repair_test.go
deleted file mode 100644
index b2daab4..0000000
--- a/internal/service/strm_repair_test.go
+++ /dev/null
@@ -1,77 +0,0 @@
-package service
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "go.uber.org/zap"
-)
-
-func TestRepairSTRMFilesRewritesOwnedPlaybackURLs(t *testing.T) {
- outDir := t.TempDir()
- cloudPath := filepath.Join(outDir, "Movies", "A.strm")
- streamPath := filepath.Join(outDir, "Shows", "S01E01.strm")
- externalPath := filepath.Join(outDir, "External.strm")
- for _, path := range []string{cloudPath, streamPath, externalPath} {
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- }
- if err := os.WriteFile(cloudPath, []byte("http://old.local/api/cloud/play/openlist?ref=%2FMovies%2FA.mkv\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(streamPath, []byte("/api/stream/media-1?token=old\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(externalPath, []byte("https://cdn.example.com/video.m3u8\n"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- svc := NewSTRMService(zap.NewNop(), nil, nil)
- res, err := svc.RepairFiles(t.Context(), RepairSTRMOptions{
- OutputDir: outDir,
- BaseURL: "https://media.example.com",
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Repaired != 2 || res.Skipped != 1 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want two repaired and one skipped", res)
- }
- if got := readSTRM(t, cloudPath); got != "https://media.example.com/api/cloud/play/openlist?ref=%2FMovies%2FA.mkv" {
- t.Fatalf("cloud strm = %q", got)
- }
- if got := readSTRM(t, streamPath); got != "https://media.example.com/api/stream/media-1?token=old" {
- t.Fatalf("stream strm = %q", got)
- }
- if got := readSTRM(t, externalPath); got != "https://cdn.example.com/video.m3u8" {
- t.Fatalf("external strm should not change: %q", got)
- }
-}
-
-func TestRepairSTRMFilesDryRunDoesNotWrite(t *testing.T) {
- outDir := t.TempDir()
- filePath := filepath.Join(outDir, "Movie.strm")
- original := "http://old.local/api/cloud/play/openlist?ref=%2FMovie.mkv\n"
- if err := os.WriteFile(filePath, []byte(original), 0o644); err != nil {
- t.Fatal(err)
- }
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.RepairFiles(t.Context(), RepairSTRMOptions{
- OutputDir: outDir,
- BaseURL: "https://media.example.com",
- DryRun: true,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Previewed != 1 || res.Repaired != 0 || len(res.Items) != 1 {
- t.Fatalf("result = %#v, want one repair preview", res)
- }
- if got := readSTRM(t, filePath); got != strings.TrimSpace(original) {
- t.Fatalf("dry run changed file: %q", got)
- }
-}
diff --git a/internal/service/strm_svc.go b/internal/service/strm_svc.go
deleted file mode 100644
index ebf0d05..0000000
--- a/internal/service/strm_svc.go
+++ /dev/null
@@ -1,171 +0,0 @@
-// Package service — STRM 文件管理服务。
-package service
-
-import (
- "context"
- "errors"
- "strings"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/config"
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-// STRM 错误定义。
-var (
- ErrSTRMNotFound = errors.New("strm record not found")
- ErrSTRMProtocolInvalid = errors.New("invalid strm protocol")
- ErrSTRMURLInvalid = errors.New("invalid strm url")
-)
-
-// STRMService STRM 文件管理服务。
-type STRMService struct {
- log *zap.Logger
- repo *repository.Container
- cfg *config.Config
-}
-
-// NewSTRMService 创建 STRM 服务。
-func NewSTRMService(log *zap.Logger, repo *repository.Container, cfg *config.Config) *STRMService {
- return &STRMService{log: log, repo: repo, cfg: cfg}
-}
-
-// Create 创建 STRM 记录。
-func (s *STRMService) Create(ctx context.Context, record *model.STRMRecord) (*model.STRMRecord, error) {
- if err := s.validateSTRM(record); err != nil {
- return nil, err
- }
-
- if err := s.repo.STRM.Create(ctx, record); err != nil {
- s.log.Error("create strm failed", zap.Error(err))
- return nil, err
- }
-
- return record, nil
-}
-
-// CreateBatch 批量创建 STRM 记录。
-func (s *STRMService) CreateBatch(ctx context.Context, records []model.STRMRecord) (int, error) {
- created := 0
- for i := range records {
- if err := s.validateSTRM(&records[i]); err != nil {
- s.log.Warn("skip invalid strm record",
- zap.String("title", records[i].Title),
- zap.Error(err),
- )
- continue
- }
- created++
- }
-
- validRecords := make([]model.STRMRecord, 0, created)
- for _, r := range records {
- if model.IsAllowedProtocol(r.Protocol) && r.URL != "" {
- validRecords = append(validRecords, r)
- }
- }
-
- if len(validRecords) == 0 {
- return 0, nil
- }
-
- if err := s.repo.STRM.CreateBatch(ctx, validRecords); err != nil {
- s.log.Error("batch create strm failed", zap.Error(err))
- return 0, err
- }
-
- return len(validRecords), nil
-}
-
-// GetByID 获取 STRM 记录。
-func (s *STRMService) GetByID(ctx context.Context, id string) (*model.STRMRecord, error) {
- record, err := s.repo.STRM.FindByID(ctx, id)
- if err != nil {
- return nil, err
- }
- if record == nil {
- return nil, ErrSTRMNotFound
- }
- return record, nil
-}
-
-// List 列出 STRM 记录(支持筛选和分页)。
-func (s *STRMService) List(ctx context.Context, filters map[string]string, page, pageSize int) ([]model.STRMRecord, int64, error) {
- offset := (page - 1) * pageSize
- if offset < 0 {
- offset = 0
- }
-
- records, total, err := s.repo.STRM.List(ctx, filters, offset, pageSize)
- if err != nil {
- return nil, 0, err
- }
-
- return records, total, nil
-}
-
-// Update 更新 STRM 记录。
-func (s *STRMService) Update(ctx context.Context, record *model.STRMRecord) (*model.STRMRecord, error) {
- existing, err := s.repo.STRM.FindByID(ctx, record.ID)
- if err != nil {
- return nil, err
- }
- if existing == nil {
- return nil, ErrSTRMNotFound
- }
-
- if record.Protocol != "" {
- if !model.IsAllowedProtocol(record.Protocol) {
- return nil, ErrSTRMProtocolInvalid
- }
- }
-
- if err := s.repo.STRM.Update(ctx, record); err != nil {
- s.log.Error("update strm failed", zap.Error(err))
- return nil, err
- }
-
- return record, nil
-}
-
-// Delete 删除 STRM 记录。
-func (s *STRMService) Delete(ctx context.Context, id string) error {
- existing, err := s.repo.STRM.FindByID(ctx, id)
- if err != nil {
- return err
- }
- if existing == nil {
- return ErrSTRMNotFound
- }
- return s.repo.STRM.Delete(ctx, id)
-}
-
-// GetProtocols 获取支持的协议列表。
-func (s *STRMService) GetProtocols() []string {
- return model.AllowedSTRMProtocols
-}
-
-// validateSTRM 验证 STRM 记录。
-func (s *STRMService) validateSTRM(record *model.STRMRecord) error {
- if record.Title == "" {
- return errors.New("title is required")
- }
- if record.URL == "" {
- return ErrSTRMURLInvalid
- }
- if !model.IsAllowedProtocol(record.Protocol) {
- return ErrSTRMProtocolInvalid
- }
-
- // 标准化协议名
- record.Protocol = strings.ToLower(record.Protocol)
-
- return nil
-}
-
-// ListByMediaID 获取关联到指定媒体的 STRM 记录。
-func (s *STRMService) ListByMediaID(ctx context.Context, mediaID string) ([]model.STRMRecord, error) {
- return s.repo.STRM.FindByMediaID(ctx, mediaID)
-}
diff --git a/internal/service/strm_svc_test.go b/internal/service/strm_svc_test.go
deleted file mode 100644
index 825b51d..0000000
--- a/internal/service/strm_svc_test.go
+++ /dev/null
@@ -1,504 +0,0 @@
-package service
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- "github.com/golang-jwt/jwt/v5"
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/config"
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestGenerateSTRMForLibraryWritesFilesAndRecords(t *testing.T) {
- db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{})
- repos := repository.New(db)
- lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true}
- if err := repos.Library.Create(t.Context(), &lib); err != nil {
- t.Fatal(err)
- }
- rows := []model.Media{
- {Base: model.Base{ID: "cloud-media"}, LibraryID: lib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"},
- {Base: model.Base{ID: "local-media"}, LibraryID: lib.ID, Title: "本地电影", Year: 2025, Path: filepath.Join(t.TempDir(), "本地电影.mkv")},
- }
- for i := range rows {
- if err := repos.DB.Create(&rows[i]).Error; err != nil {
- t.Fatal(err)
- }
- }
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), repos, &config.Config{})
-
- res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
- LibraryID: lib.ID,
- OutputDir: outDir,
- BaseURL: "http://nas.example:18080",
- IncludeLocal: true,
- PlaybackToken: "strm-token",
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 2 || res.Skipped != 0 {
- t.Fatalf("result = %#v, want generated=2 skipped=0", res)
- }
- libraryOutDir := filepath.Join(outDir, "电影")
- if res.OutputDir != libraryOutDir {
- t.Fatalf("output dir = %q, want %q", res.OutputDir, libraryOutDir)
- }
- cloudSTRM := filepath.Join(libraryOutDir, "云盘电影 (2026)", "云盘电影 (2026).strm")
- localSTRM := filepath.Join(libraryOutDir, "本地电影 (2025)", "本地电影 (2025).strm")
- assertFileContains(t, cloudSTRM, "http://nas.example:18080/api/stream/cloud-media?token=strm-token")
- assertFileContains(t, localSTRM, "http://nas.example:18080/api/stream/local-media?token=strm-token")
- if got, err := repos.Setting.Get(t.Context(), "app.server_url"); err != nil || got != "http://nas.example:18080" {
- t.Fatalf("app.server_url = %q, %v; want generated base url", got, err)
- }
- if got, err := repos.Setting.Get(t.Context(), "strm.base_url"); err != nil || got != "http://nas.example:18080" {
- t.Fatalf("strm.base_url = %q, %v; want generated base url", got, err)
- }
-
- var count int64
- if err := repos.DB.Model(&model.STRMRecord{}).Count(&count).Error; err != nil {
- t.Fatal(err)
- }
- if count != 2 {
- t.Fatalf("strm record count = %d, want 2", count)
- }
-
- res, err = svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
- LibraryID: lib.ID,
- OutputDir: outDir,
- BaseURL: "http://nas.example:18080",
- IncludeLocal: true,
- PlaybackToken: "strm-token",
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Skipped != 2 {
- t.Fatalf("second run skipped = %d, want 2", res.Skipped)
- }
-}
-
-func TestGenerateSTRMForLibrarySignsDefaultPlaybackToken(t *testing.T) {
- db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}, &model.User{})
- repos := repository.New(db)
- admin := model.User{Username: "admin", PasswordHash: "x", Role: "admin", Tier: "plus", IsActive: true}
- if err := repos.User.Create(t.Context(), &admin); err != nil {
- t.Fatal(err)
- }
- lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true}
- if err := repos.Library.Create(t.Context(), &lib); err != nil {
- t.Fatal(err)
- }
- media := model.Media{Base: model.Base{ID: "cloud-media"}, LibraryID: lib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"}
- if err := repos.DB.Create(&media).Error; err != nil {
- t.Fatal(err)
- }
-
- outDir := filepath.Join(t.TempDir(), "strm")
- const secret = "test-secret"
- svc := NewSTRMService(zap.NewNop(), repos, &config.Config{Secrets: config.SecretsConfig{JWTSecret: secret}})
- res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
- LibraryID: lib.ID,
- OutputDir: outDir,
- BaseURL: "http://nas.example:18080",
- IncludeLocal: true,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 1 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want generated=1 with no errors", res)
- }
- cloudSTRM := filepath.Join(outDir, "电影", "云盘电影 (2026)", "云盘电影 (2026).strm")
- got := readSTRM(t, cloudSTRM)
- if !strings.HasPrefix(got, "http://nas.example:18080/api/stream/cloud-media?token=") {
- t.Fatalf("generated url = %q, want tokenized /api/stream url", got)
- }
- token := strings.TrimPrefix(got, "http://nas.example:18080/api/stream/cloud-media?token=")
- claims := &Claims{}
- parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
- return []byte(secret), nil
- })
- if err != nil || !parsed.Valid {
- t.Fatalf("generated token did not validate: %v", err)
- }
- if claims.UserID != admin.ID || claims.Role != "admin" || claims.Tier != "plus" {
- t.Fatalf("claims = %#v, want admin identity", claims)
- }
- if ttl := time.Until(claims.ExpiresAt.Time); ttl < EmbyTokenDuration-time.Minute {
- t.Fatalf("token ttl = %v, want close to %v", ttl, EmbyTokenDuration)
- }
-}
-
-func TestGenerateSTRMForLibraryCleanupStaleFilesAndRecords(t *testing.T) {
- db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{})
- repos := repository.New(db)
- lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true}
- if err := repos.Library.Create(t.Context(), &lib); err != nil {
- t.Fatal(err)
- }
- media := model.Media{Base: model.Base{ID: "cloud-media"}, LibraryID: lib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"}
- if err := repos.DB.Create(&media).Error; err != nil {
- t.Fatal(err)
- }
- outDir := filepath.Join(t.TempDir(), "strm")
- libraryOutDir := filepath.Join(outDir, "电影")
- stalePath := filepath.Join(libraryOutDir, "旧电影", "旧电影.strm")
- if err := os.MkdirAll(filepath.Dir(stalePath), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(stalePath, []byte("http://old.example/stream\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- staleRecord := model.STRMRecord{Title: "旧电影", URL: "http://old.example/stream", FilePath: stalePath, Protocol: "http", MediaID: "missing-media"}
- if err := repos.DB.Create(&staleRecord).Error; err != nil {
- t.Fatal(err)
- }
-
- svc := NewSTRMService(zap.NewNop(), repos, &config.Config{})
- res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
- LibraryID: lib.ID,
- OutputDir: outDir,
- BaseURL: "http://nas.example:18080",
- IncludeLocal: true,
- Overwrite: true,
- PlaybackToken: "strm-token",
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Cleaned == 0 {
- t.Fatalf("cleaned = %d, want stale file/record cleaned", res.Cleaned)
- }
- if _, err := os.Stat(stalePath); !os.IsNotExist(err) {
- t.Fatalf("stale strm file should be removed, stat err=%v", err)
- }
- var count int64
- if err := repos.DB.Model(&model.STRMRecord{}).Where("media_id = ?", "missing-media").Count(&count).Error; err != nil {
- t.Fatal(err)
- }
- if count != 0 {
- t.Fatalf("stale strm record count = %d, want 0", count)
- }
- if err := repos.DB.Model(&model.STRMRecord{}).Where("media_id = ?", media.ID).Count(&count).Error; err != nil {
- t.Fatal(err)
- }
- if count != 1 {
- t.Fatalf("current strm record count = %d, want 1", count)
- }
- assertFileContains(t, filepath.Join(libraryOutDir, "云盘电影 (2026)", "云盘电影 (2026).strm"), "http://nas.example:18080/api/stream/cloud-media?token=strm-token")
-}
-
-func TestSTRMLibraryOutputSubdirUsesLibraryCategoryPath(t *testing.T) {
- tests := []struct {
- name string
- lib model.Library
- want string
- }{
- {
- name: "cloud nested tv category",
- lib: model.Library{Name: "OpenList · 欧美剧", Path: BuildCloudLibraryPath("openlist", "/电视剧/欧美剧", "/电视剧/欧美剧"), Type: "tv"},
- want: filepath.Join("电视剧", "欧美剧"),
- },
- {
- name: "cloud second-level category without root",
- lib: model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), Type: "tv"},
- want: filepath.Join("电视剧", "国产剧"),
- },
- {
- name: "local nested tv category",
- lib: model.Library{Name: "欧美剧", Path: `F:\media\电视剧\欧美剧`, Type: "tv"},
- want: filepath.Join("电视剧", "欧美剧"),
- },
- {
- name: "uncategorized tv category stays uncategorized",
- lib: model.Library{Name: "OpenList · 未分类", Path: BuildCloudLibraryPath("openlist", "/电视剧/未分类", "/电视剧/未分类"), Type: "tv"},
- want: filepath.Join("电视剧", "未分类"),
- },
- {
- name: "fallback to type root",
- lib: model.Library{Name: "Archive", Path: `F:\archive`, Type: "movie"},
- want: "电影",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := strmLibraryOutputSubdir(tt.lib); got != tt.want {
- t.Fatalf("strmLibraryOutputSubdir() = %q, want %q", got, tt.want)
- }
- })
- }
-}
-
-func TestSTRMLibrarySpecificOutputDirPreservesExplicitCategoryRoot(t *testing.T) {
- base := filepath.Join(t.TempDir(), "strm", "电视剧")
- lib := model.Library{
- Name: "国产剧",
- Path: BuildCloudLibraryPath("openlist", "/电视剧/国产剧", "/电视剧/国产剧"),
- Type: "tv",
- }
-
- got := strmLibrarySpecificOutputDir(base, &lib)
- want := filepath.Join(base, "国产剧")
- if got != want {
- t.Fatalf("strmLibrarySpecificOutputDir() = %q, want %q", got, want)
- }
-}
-
-func TestGenerateSTRMForLibraryUsesCategoryDefaultOutputDir(t *testing.T) {
- db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{})
- repos := repository.New(db)
- dataDir := t.TempDir()
- lib := model.Library{Name: "OpenList · 欧美剧", Path: BuildCloudLibraryPath("openlist", "/电视剧/欧美剧", "/电视剧/欧美剧"), Type: "tv", Enabled: true}
- if err := repos.Library.Create(t.Context(), &lib); err != nil {
- t.Fatal(err)
- }
- media := model.Media{Base: model.Base{ID: "show-1"}, LibraryID: lib.ID, Title: "第一集", Path: "cloud://openlist/电视剧/欧美剧/Show/S01E01.mkv", STRMURL: "/api/cloud/play/openlist?ref=show", SeasonNum: 1, EpisodeNum: 1}
- if err := repos.DB.Create(&media).Error; err != nil {
- t.Fatal(err)
- }
- svc := NewSTRMService(zap.NewNop(), repos, &config.Config{App: config.AppConfig{DataDir: dataDir}})
-
- res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
- LibraryID: lib.ID,
- BaseURL: "http://nas.example:18080",
- IncludeLocal: true,
- PlaybackToken: "strm-token",
- })
- if err != nil {
- t.Fatal(err)
- }
- wantDir := filepath.Join(dataDir, "strm", "电视剧", "欧美剧")
- if res.OutputDir != wantDir {
- t.Fatalf("output dir = %q, want %q", res.OutputDir, wantDir)
- }
- assertFileContains(t, filepath.Join(wantDir, "Show", "Season 01", "Show - S01E01.strm"), "http://nas.example:18080/api/stream/show-1?token=strm-token")
-}
-
-func TestGenerateSTRMRemapsLegacyAppDataOutputDir(t *testing.T) {
- db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{})
- repos := repository.New(db)
- dataDir := t.TempDir()
- lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true}
- if err := repos.Library.Create(t.Context(), &lib); err != nil {
- t.Fatal(err)
- }
- media := model.Media{
- Base: model.Base{ID: "cloud-media"},
- LibraryID: lib.ID,
- Title: "云盘电影",
- Year: 2026,
- Path: "cloud://openlist/电影/云盘电影.mkv",
- STRMURL: "/api/cloud/play/openlist?ref=movie",
- }
- if err := repos.DB.Create(&media).Error; err != nil {
- t.Fatal(err)
- }
- if err := repos.Setting.Set(t.Context(), "strm.output_dir", "/app/data/strm"); err != nil {
- t.Fatal(err)
- }
- svc := NewSTRMService(zap.NewNop(), repos, &config.Config{App: config.AppConfig{DataDir: dataDir}})
-
- res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
- LibraryID: lib.ID,
- BaseURL: "http://nas.example:18080",
- PlaybackToken: "strm-token",
- })
- if err != nil {
- t.Fatal(err)
- }
- wantDir := filepath.Join(dataDir, "strm", "电影")
- if res.OutputDir != wantDir {
- t.Fatalf("output dir = %q, want %q", res.OutputDir, wantDir)
- }
- if got, err := repos.Setting.Get(t.Context(), "strm.output_dir"); err != nil || got != wantDir {
- t.Fatalf("saved strm.output_dir = %q, %v; want %q", got, err, wantDir)
- }
- assertFileContains(t, filepath.Join(wantDir, "云盘电影 (2026)", "云盘电影 (2026).strm"), "http://nas.example:18080/api/stream/cloud-media?token=strm-token")
-}
-
-func TestGenerateSTRMForLibraryUsesPathEpisodeFallback(t *testing.T) {
- db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{})
- repos := repository.New(db)
- lib := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/电视剧/国产剧", "/电视剧/国产剧"), Type: "tv", Enabled: true}
- if err := repos.Library.Create(t.Context(), &lib); err != nil {
- t.Fatal(err)
- }
- rows := []model.Media{
- {Base: model.Base{ID: "ep-1"}, LibraryID: lib.ID, Title: "南部档案", Path: "cloud://openlist/电视剧/国产剧/南部档案/Season 01/Archives.The.Nanyang.Mystery.S01E01.mkv", STRMURL: "/api/cloud/play/openlist?ref=ep1"},
- {Base: model.Base{ID: "ep-2"}, LibraryID: lib.ID, Title: "南部档案", Path: "cloud://openlist/电视剧/国产剧/南部档案/Season 01/Archives.The.Nanyang.Mystery.S01E02.mkv", STRMURL: "/api/cloud/play/openlist?ref=ep2"},
- }
- for i := range rows {
- if err := repos.DB.Create(&rows[i]).Error; err != nil {
- t.Fatal(err)
- }
- }
-
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), repos, &config.Config{})
- res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
- LibraryID: lib.ID,
- OutputDir: outDir,
- BaseURL: "http://nas.example:18080",
- IncludeLocal: true,
- PlaybackToken: "strm-token",
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 2 || res.Skipped != 0 {
- t.Fatalf("result = %#v, want generated=2 skipped=0", res)
- }
- libraryOutDir := filepath.Join(outDir, "电视剧", "国产剧")
- assertFileContains(t, filepath.Join(libraryOutDir, "南部档案", "Season 01", "南部档案 - S01E01.strm"), "http://nas.example:18080/api/stream/ep-1?token=strm-token")
- assertFileContains(t, filepath.Join(libraryOutDir, "南部档案", "Season 01", "南部档案 - S01E02.strm"), "http://nas.example:18080/api/stream/ep-2?token=strm-token")
-}
-
-func TestGenerateSTRMForLibraryPreservesSourceTree(t *testing.T) {
- db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{})
- repos := repository.New(db)
- lib := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/电视剧/国产剧", "/电视剧/国产剧"), Type: "tv", Enabled: true}
- if err := repos.Library.Create(t.Context(), &lib); err != nil {
- t.Fatal(err)
- }
- media := model.Media{
- Base: model.Base{ID: "ep-1"},
- LibraryID: lib.ID,
- Title: "南部档案",
- Path: "cloud://openlist/电视剧/国产剧/南部档案/Season 01/Archives.The.Nanyang.Mystery.S01E01.mkv",
- STRMURL: "/api/cloud/play/openlist?ref=ep1",
- }
- if err := repos.DB.Create(&media).Error; err != nil {
- t.Fatal(err)
- }
-
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), repos, &config.Config{})
- res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
- LibraryID: lib.ID,
- OutputDir: outDir,
- BaseURL: "http://nas.example:18080",
- IncludeLocal: true,
- PreserveTree: true,
- PlaybackToken: "strm-token",
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 1 || res.Skipped != 0 {
- t.Fatalf("result = %#v, want generated=1 skipped=0", res)
- }
- wantPath := filepath.Join(outDir, "电视剧", "国产剧", "南部档案", "Season 01", "Archives.The.Nanyang.Mystery.S01E01.strm")
- assertFileContains(t, wantPath, "http://nas.example:18080/api/stream/ep-1?token=strm-token")
- if got, err := repos.Setting.Get(t.Context(), "strm.preserve_tree"); err != nil || got != "true" {
- t.Fatalf("strm.preserve_tree = %q, %v; want true", got, err)
- }
-}
-
-func TestGenerateSTRMForLibraryCanSkipLocalMedia(t *testing.T) {
- db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{})
- repos := repository.New(db)
- lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true}
- if err := repos.Library.Create(t.Context(), &lib); err != nil {
- t.Fatal(err)
- }
- rows := []model.Media{
- {Base: model.Base{ID: "cloud-media"}, LibraryID: lib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"},
- {Base: model.Base{ID: "local-media"}, LibraryID: lib.ID, Title: "本地电影", Year: 2025, Path: filepath.Join(t.TempDir(), "本地电影.mkv")},
- }
- for i := range rows {
- if err := repos.DB.Create(&rows[i]).Error; err != nil {
- t.Fatal(err)
- }
- }
-
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), repos, &config.Config{})
- res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{
- LibraryID: lib.ID,
- OutputDir: outDir,
- BaseURL: "http://nas.example:18080",
- IncludeLocal: false,
- PlaybackToken: "strm-token",
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 1 || res.Skipped != 1 {
- t.Fatalf("result = %#v, want generated=1 skipped=1", res)
- }
- assertFileContains(t, filepath.Join(outDir, "电影", "云盘电影 (2026)", "云盘电影 (2026).strm"), "http://nas.example:18080/api/stream/cloud-media?token=strm-token")
- if _, err := os.Stat(filepath.Join(outDir, "电影", "本地电影 (2025)", "本地电影 (2025).strm")); !os.IsNotExist(err) {
- t.Fatalf("local media strm should not exist, stat err=%v", err)
- }
-}
-
-func TestGenerateSTRMForAllLibrariesWritesPerLibraryFolders(t *testing.T) {
- db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{})
- repos := repository.New(db)
- movieLib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true}
- tvLib := model.Library{Name: "欧美剧", Path: BuildCloudLibraryPath("openlist", "/电视剧/欧美剧", "/电视剧/欧美剧"), Type: "tv", Enabled: true}
- if err := repos.Library.Create(t.Context(), &movieLib); err != nil {
- t.Fatal(err)
- }
- if err := repos.Library.Create(t.Context(), &tvLib); err != nil {
- t.Fatal(err)
- }
- rows := []model.Media{
- {Base: model.Base{ID: "movie-1"}, LibraryID: movieLib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"},
- {Base: model.Base{ID: "show-1"}, LibraryID: tvLib.ID, Title: "第一集", Path: "cloud://openlist/电视剧/欧美剧/Show/S01E01.mkv", STRMURL: "/api/cloud/play/openlist?ref=show", SeasonNum: 1, EpisodeNum: 1},
- }
- for i := range rows {
- if err := repos.DB.Create(&rows[i]).Error; err != nil {
- t.Fatal(err)
- }
- }
-
- outDir := filepath.Join(t.TempDir(), "strm-all")
- svc := NewSTRMService(zap.NewNop(), repos, &config.Config{})
- res, err := svc.GenerateForAllLibraries(t.Context(), GenerateSTRMOptions{
- OutputDir: outDir,
- BaseURL: "http://nas.example:18080",
- IncludeLocal: true,
- Overwrite: true,
- PlaybackToken: "strm-token",
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 2 {
- t.Fatalf("generated = %d, want 2", res.Generated)
- }
- assertFileContains(t, filepath.Join(outDir, "电影", "云盘电影 (2026)", "云盘电影 (2026).strm"), "http://nas.example:18080/api/stream/movie-1?token=strm-token")
- assertFileContains(t, filepath.Join(outDir, "电视剧", "欧美剧", "Show", "Season 01", "Show - S01E01.strm"), "http://nas.example:18080/api/stream/show-1?token=strm-token")
- var count int64
- if err := repos.DB.Model(&model.STRMRecord{}).Count(&count).Error; err != nil {
- t.Fatal(err)
- }
- if count != 2 {
- t.Fatalf("active strm record count = %d, want 2", count)
- }
-}
-
-func assertFileContains(t *testing.T, path, want string) {
- t.Helper()
- if got := readSTRM(t, path); got != want {
- t.Fatalf("%s = %q, want %q", path, got, want)
- }
-}
-
-func readSTRM(t *testing.T, path string) string {
- t.Helper()
- data, err := os.ReadFile(path)
- if err != nil {
- t.Fatal(err)
- }
- return strings.TrimSpace(string(data))
-}
diff --git a/internal/service/strm_tree_cleanup_test.go b/internal/service/strm_tree_cleanup_test.go
deleted file mode 100644
index da23821..0000000
--- a/internal/service/strm_tree_cleanup_test.go
+++ /dev/null
@@ -1,150 +0,0 @@
-package service
-
-import (
- "os"
- "path/filepath"
- "testing"
-
- "go.uber.org/zap"
-)
-
-func TestGenerateSTRMFromTreeOverwriteAndTraversal(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- existing := filepath.Join(outDir, "Movies", "Movie.strm")
- if err := os.MkdirAll(filepath.Dir(existing), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(existing, []byte("old\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- Paths: []string{"Movies/Movie.mkv", "../escape.mkv"},
- OutputDir: outDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 0 || res.Skipped != 1 || len(res.Errors) != 1 {
- t.Fatalf("result = %#v, want existing skipped and traversal rejected", res)
- }
- if got := readSTRM(t, existing); got != "old" {
- t.Fatalf("existing strm = %q, want unchanged", got)
- }
-
- res, err = svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- Paths: []string{"Movies/Movie.mkv"},
- OutputDir: outDir,
- Overwrite: true,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Updated != 1 {
- t.Fatalf("updated = %d, want 1", res.Updated)
- }
- if got := readSTRM(t, existing); got == "old" {
- t.Fatalf("existing strm should be overwritten, got %q", got)
- }
-}
-
-func TestGenerateSTRMFromTreeCleanupStaleFiles(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- current := filepath.Join(outDir, "Shows", "Show.S01E01.strm")
- stale := filepath.Join(outDir, "Shows", "Show.S01E02.strm")
- for _, file := range []string{current, stale} {
- if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(file, []byte("old\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- }
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- Paths: []string{"Shows/Show.S01E01.mkv"},
- OutputDir: outDir,
- Cleanup: true,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Skipped != 1 || res.Cleaned != 1 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want current skipped and one stale file cleaned", res)
- }
- if _, err := os.Stat(current); err != nil {
- t.Fatalf("current strm should remain: %v", err)
- }
- if _, err := os.Stat(stale); !os.IsNotExist(err) {
- t.Fatalf("stale strm should be removed, stat err=%v", err)
- }
-}
-
-func TestGenerateSTRMFromTreeCleanupWithOutputPrefixStaysInPrefix(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- current := filepath.Join(outDir, "电影", "欧美电影", "Movie.strm")
- staleInPrefix := filepath.Join(outDir, "电影", "欧美电影", "Old.strm")
- otherCategory := filepath.Join(outDir, "电视剧", "国产剧", "Show.strm")
- for _, file := range []string{current, staleInPrefix, otherCategory} {
- if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(file, []byte("old\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- }
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- Paths: []string{"Movie.mkv"},
- OutputPrefix: "电影/欧美电影",
- OutputDir: outDir,
- Cleanup: true,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Skipped != 1 || res.Cleaned != 1 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want one stale file cleaned inside prefix only", res)
- }
- if _, err := os.Stat(staleInPrefix); !os.IsNotExist(err) {
- t.Fatalf("stale prefixed strm should be removed, stat err=%v", err)
- }
- if _, err := os.Stat(otherCategory); err != nil {
- t.Fatalf("other category strm should remain: %v", err)
- }
-}
-
-func TestGenerateSTRMFromTreeCleanupSkipsWhenNoValidSources(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- existing := filepath.Join(outDir, "Movies", "Movie.strm")
- if err := os.MkdirAll(filepath.Dir(existing), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(existing, []byte("keep\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- Paths: []string{"Movies/poster.jpg"},
- OutputDir: outDir,
- Cleanup: true,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Cleaned != 0 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want cleanup skipped without valid media sources", res)
- }
- if got := readSTRM(t, existing); got != "keep" {
- t.Fatalf("existing strm = %q, want kept", got)
- }
-}
diff --git a/internal/service/strm_tree_import.go b/internal/service/strm_tree_import.go
deleted file mode 100644
index c285c83..0000000
--- a/internal/service/strm_tree_import.go
+++ /dev/null
@@ -1,283 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "os"
- "path"
- "path/filepath"
- "strings"
-)
-
-type GenerateSTRMTreeOptions struct {
- Provider string `json:"provider"`
- TreeText string `json:"tree_text,omitempty"`
- Paths []string `json:"paths,omitempty"`
- SourceRoot string `json:"source_root,omitempty"`
- OutputPrefix string `json:"output_prefix,omitempty"`
- OutputDir string `json:"output_dir"`
- BaseURL string `json:"base_url,omitempty"`
- Overwrite bool `json:"overwrite"`
- Cleanup bool `json:"cleanup"`
- DryRun bool `json:"dry_run"`
- BatchLimit int `json:"batch_limit,omitempty"`
- RecognizeRename bool `json:"recognize_rename,omitempty"`
- TransferSubtitles bool `json:"transfer_subtitles,omitempty"`
- MissingOnly bool `json:"missing_only,omitempty"`
-}
-
-type strmTreeSource struct {
- Provider string
- Path string
- RefPath string
- Kind string
-}
-
-const (
- strmTreeSourceKindVideo = "video"
- strmTreeSourceKindSubtitle = "subtitle"
-)
-
-type strmTreeSourceCollection struct {
- sources []strmTreeSource
- ignored []string
- ignoredCount int
-}
-
-type strmTreeSourceCollector struct {
- fallbackProvider string
- transferSubtitles bool
- sources []strmTreeSource
- subtitles []strmTreeSource
- ignored []string
- ignoredCount int
- seen map[string]struct{}
- seenIgnored map[string]struct{}
-}
-
-func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTreeOptions) (*GenerateSTRMResult, error) {
- provider := normalizeSTRMTreeProvider(opts.Provider)
- if provider == "" {
- return nil, errors.New("provider required")
- }
- outputDir := resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir))
- if outputDir == "" || outputDir == "." {
- return nil, errors.New("output_dir required")
- }
- if !opts.DryRun {
- if err := os.MkdirAll(outputDir, 0o755); err != nil { // #nosec G301 -- STRM output directories must be readable by media players.
- return nil, err
- }
- }
- result := &GenerateSTRMResult{LibraryID: provider, OutputDir: outputDir}
- collection := collectSTRMTreeSources(opts)
- sources := collection.sources
- result.Total = len(sources)
- result.Ignored = collection.ignoredCount
- result.IgnoredItems = collection.ignored
- existingRefs, err := s.strmTreeExistingCloudRefs(ctx, opts)
- if err != nil {
- return nil, err
- }
- expectedFiles := make(map[string]struct{})
- for i, source := range sources {
- select {
- case <-ctx.Done():
- return result, ctx.Err()
- default:
- }
- item := s.generateTreeSTRMItem(ctx, outputDir, source, opts, existingRefs)
- if item.FilePath != "" && item.Action != "error" {
- expectedFiles[filepath.Clean(item.FilePath)] = struct{}{}
- }
- result.addItem(item)
- if strmTreeBatchLimitReached(result, opts.BatchLimit) {
- result.Remaining = len(sources) - i - 1
- result.BatchLimited = result.Remaining > 0
- break
- }
- }
- if opts.Cleanup && !opts.DryRun && opts.BatchLimit <= 0 && len(expectedFiles) > 0 {
- cleanupDir := outputDir
- if prefix, err := strmTreeOutputPrefixPath(opts.OutputPrefix); err == nil && prefix != "" {
- cleanupDir = filepath.Join(outputDir, prefix)
- }
- cleaned, err := removeStaleSTRMFiles(cleanupDir, expectedFiles)
- result.Cleaned += cleaned
- if err != nil {
- result.Errors = append(result.Errors, err.Error())
- }
- }
- return result, nil
-}
-
-func strmTreeBatchLimitReached(result *GenerateSTRMResult, limit int) bool {
- if result == nil || limit <= 0 {
- return false
- }
- return result.Generated+result.Updated+result.Previewed >= limit
-}
-
-func (s *STRMService) generateTreeSTRMItem(ctx context.Context, outputDir string, source strmTreeSource, opts GenerateSTRMTreeOptions, existingRefs map[string]struct{}) GenerateSTRMItem {
- relSource := strmTreeRelativeSource(source.Path, opts.SourceRoot)
- relPath, err := strmTreeOutputRelativePath(relSource)
- if opts.RecognizeRename && source.Kind != strmTreeSourceKindSubtitle {
- if renamed, renameErr := s.strmTreeRecognizedOutputRelativePath(ctx, source.Path, opts.SourceRoot); renameErr == nil && renamed != "" {
- relPath = renamed
- err = nil
- }
- }
- if source.Kind == strmTreeSourceKindSubtitle {
- relPath, err = strmTreeOutputSubtitleLinkRelativePath(relSource)
- }
- item := GenerateSTRMItem{Title: strings.TrimSuffix(path.Base(source.Path), path.Ext(source.Path))}
- if err != nil {
- item.Action = "error"
- item.Reason = err.Error()
- return item
- }
- prefix, err := strmTreeOutputPrefixPath(opts.OutputPrefix)
- if err != nil {
- item.Action = "error"
- item.Reason = err.Error()
- return item
- }
- filePath := filepath.Join(outputDir, prefix, relPath)
- item.FilePath = filePath
- item.URL = absolutizeSTRMURL(BuildRelativeCloudPlayURL(source.Provider, strmTreeCloudRef(source.cloudRefPath(), opts.SourceRoot)), opts.BaseURL)
- if strmTreeSourceAlreadyInLibrary(source, opts, existingRefs) {
- item.Action = "skipped"
- item.Reason = "already in media library"
- return item
- }
- if _, err := os.Stat(filePath); err == nil && !opts.Overwrite {
- item.Action = "skipped"
- item.Reason = "target exists"
- return item
- }
- action := "generated"
- if _, err := os.Stat(filePath); err == nil {
- action = "updated"
- }
- if opts.DryRun {
- item.Action = "preview"
- item.Reason = action
- return item
- }
- if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { // #nosec G301 -- STRM output directories must be readable by media players.
- item.Action = "error"
- item.Reason = err.Error()
- return item
- }
- if err := os.WriteFile(filePath, []byte(item.URL+"\n"), 0o644); err != nil { // #nosec G306 -- STRM files are media sidecars intended to be readable by players.
- item.Action = "error"
- item.Reason = err.Error()
- return item
- }
- item.Action = action
- return item
-}
-
-func collectSTRMTreeSources(opts GenerateSTRMTreeOptions) strmTreeSourceCollection {
- collector := newSTRMTreeSourceCollector(opts)
- for _, value := range opts.Paths {
- collector.add(value)
- }
- treeSources, treeIgnored := parseSTRMTreeTextWithIgnored(opts.TreeText)
- for _, value := range treeSources {
- collector.add(value)
- }
- for _, value := range treeIgnored {
- collector.addIgnoredOrSubtitle(value)
- }
- collector.finalizeSubtitles()
- return collector.collection()
-}
-
-func newSTRMTreeSourceCollector(opts GenerateSTRMTreeOptions) *strmTreeSourceCollector {
- return &strmTreeSourceCollector{
- fallbackProvider: normalizeSTRMTreeProvider(opts.Provider),
- transferSubtitles: opts.TransferSubtitles,
- sources: make([]strmTreeSource, 0, len(opts.Paths)),
- subtitles: make([]strmTreeSource, 0),
- ignored: make([]string, 0),
- seen: map[string]struct{}{},
- seenIgnored: map[string]struct{}{},
- }
-}
-
-func (c *strmTreeSourceCollector) add(value string) {
- source := normalizeSTRMTreeSourceWithProvider(value, c.fallbackProvider)
- if source.Provider == "" || source.Path == "" || !strmTreeSourceIsVideo(source.Path) {
- c.addIgnoredOrSubtitle(value)
- return
- }
- source.Kind = strmTreeSourceKindVideo
- c.addSource(source)
-}
-
-func (c *strmTreeSourceCollector) addIgnoredOrSubtitle(value string) {
- if c.transferSubtitles && c.addSubtitleCandidate(value) {
- return
- }
- c.addIgnored(value)
-}
-
-func (c *strmTreeSourceCollector) addSource(source strmTreeSource) {
- if source.Kind == "" {
- source.Kind = strmTreeSourceKindVideo
- }
- key := strings.ToLower(source.Provider) + "\x00" + strings.ToLower(source.Kind) + "\x00" + strings.ToLower(source.Path) + "\x00" + strings.ToLower(source.cloudRefPath())
- if _, ok := c.seen[key]; ok {
- return
- }
- c.seen[key] = struct{}{}
- c.sources = append(c.sources, source)
-}
-
-func (c *strmTreeSourceCollector) addSubtitleCandidate(value string) bool {
- source := normalizeSTRMTreeSubtitleSourceWithProvider(value, c.fallbackProvider)
- if source.Provider == "" || source.Path == "" {
- return false
- }
- c.subtitles = append(c.subtitles, source)
- return true
-}
-
-func (c *strmTreeSourceCollector) addIgnored(value string) {
- if ignoredPath, ok := strmTreeIgnoredFileLikeSource(value); ok {
- key := strings.ToLower(ignoredPath)
- if _, exists := c.seenIgnored[key]; exists {
- return
- }
- c.seenIgnored[key] = struct{}{}
- c.ignoredCount++
- if len(c.ignored) < strmTreeIgnoredItemSampleLimit {
- c.ignored = append(c.ignored, ignoredPath)
- }
- }
-}
-
-func (c *strmTreeSourceCollector) finalizeSubtitles() {
- if !c.transferSubtitles {
- return
- }
- for _, source := range c.subtitles {
- if strmTreeSubtitleMatchesVideo(source, c.sources) {
- c.addSource(source)
- continue
- }
- c.addIgnored(source.Path)
- }
-}
-
-func (c *strmTreeSourceCollector) collection() strmTreeSourceCollection {
- return strmTreeSourceCollection{sources: c.sources, ignored: c.ignored, ignoredCount: c.ignoredCount}
-}
-
-func (s strmTreeSource) cloudRefPath() string {
- if strings.TrimSpace(s.RefPath) != "" {
- return s.RefPath
- }
- return s.Path
-}
diff --git a/internal/service/strm_tree_import_test.go b/internal/service/strm_tree_import_test.go
deleted file mode 100644
index baf4f07..0000000
--- a/internal/service/strm_tree_import_test.go
+++ /dev/null
@@ -1,340 +0,0 @@
-package service
-
-import (
- "os"
- "path/filepath"
- "strconv"
- "strings"
- "testing"
-
- "go.uber.org/zap"
-)
-
-func TestGenerateSTRMFromTreePaths(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "115",
- Paths: []string{"/电视剧/国产剧/南部档案/Season 01/Archives.S01E01.mkv", "/电视剧/国产剧/南部档案/poster.jpg", "/电视剧/国产剧/南部档案/Existing.strm"},
- TreeText: "电视剧\n└── 国产剧\n └── 南部档案\n └── Existing.Tree.strm",
- SourceRoot: "/电视剧",
- OutputDir: outDir,
- BaseURL: "https://media.example.com",
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 1 || res.Skipped != 0 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want one generated video and ignored sidecar", res)
- }
- path := filepath.Join(outDir, "国产剧", "南部档案", "Season 01", "Archives.S01E01.strm")
- got := readSTRM(t, path)
- if !strings.HasPrefix(got, "https://media.example.com/api/cloud/play/cloud115?") {
- t.Fatalf("strm url = %q, want cloud115 play url", got)
- }
- if !strings.Contains(got, "ref=%2F%E7%94%B5%E8%A7%86%E5%89%A7%2F%E5%9B%BD%E4%BA%A7%E5%89%A7%2F%E5%8D%97%E9%83%A8%E6%A1%A3%E6%A1%88%2FSeason+01%2FArchives.S01E01.mkv") {
- t.Fatalf("strm url = %q, missing encoded source ref", got)
- }
- if _, err := os.Stat(filepath.Join(outDir, "国产剧", "南部档案", "Existing.strm")); !os.IsNotExist(err) {
- t.Fatalf("existing .strm source should be ignored by tree generator, stat err=%v", err)
- }
- if _, err := os.Stat(filepath.Join(outDir, "电视剧", "国产剧", "南部档案", "Existing.Tree.strm")); !os.IsNotExist(err) {
- t.Fatalf("tree .strm source should be ignored by tree generator, stat err=%v", err)
- }
-}
-
-func TestGenerateSTRMFromTreeRecognizeRenameOutputPaths(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- Paths: []string{"/电视剧/国产剧/南部档案/Season 01/Archives.The.Nanyang.Mystery.S01E02.2160p.WEB-DL.mkv", "/电影/Dune.Part.Two.2024.2160p.WEB-DL.mkv"},
- SourceRoot: "/电视剧",
- OutputDir: outDir,
- RecognizeRename: true,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 2 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want two generated renamed STRM files", res)
- }
- episode := readSTRM(t, filepath.Join(outDir, "南部档案", "Season 01", "南部档案 S01E02.strm"))
- if !strings.Contains(episode, "ref=%2F%E7%94%B5%E8%A7%86%E5%89%A7%2F%E5%9B%BD%E4%BA%A7%E5%89%A7%2F%E5%8D%97%E9%83%A8%E6%A1%A3%E6%A1%88%2FSeason+01%2FArchives.The.Nanyang.Mystery.S01E02.2160p.WEB-DL.mkv") {
- t.Fatalf("episode strm URL = %q, want original cloud ref preserved", episode)
- }
- movie := readSTRM(t, filepath.Join(outDir, "Dune Part Two (2024)", "Dune Part Two (2024).strm"))
- if !strings.Contains(movie, "ref=%2F%E7%94%B5%E5%BD%B1%2FDune.Part.Two.2024.2160p.WEB-DL.mkv") {
- t.Fatalf("movie strm URL = %q, want original cloud ref preserved", movie)
- }
- if _, err := os.Stat(filepath.Join(outDir, "国产剧", "南部档案", "Season 01", "Archives.The.Nanyang.Mystery.S01E02.2160p.WEB-DL.strm")); !os.IsNotExist(err) {
- t.Fatalf("recognize rename should not leave original episode output path, stat err=%v", err)
- }
-}
-
-func TestGenerateSTRMFromTreeSupportsCommonVideoExtensions(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- Paths: []string{
- "/Movies/BluRay.Stream.2026.m2ts",
- "/Movies/Camera.Source.2026.MTS",
- "/Movies/DVD.Feature.2026.vob",
- "/Movies/Legacy.Video.2026.wmv",
- "/Movies/Web.Legacy.2026.flv",
- "/Movies/Disc.Image.2026.iso",
- },
- OutputDir: outDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 6 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want six common video sources generated", res)
- }
- for _, name := range []string{
- "BluRay.Stream.2026",
- "Camera.Source.2026",
- "DVD.Feature.2026",
- "Legacy.Video.2026",
- "Web.Legacy.2026",
- "Disc.Image.2026",
- } {
- got := readSTRM(t, filepath.Join(outDir, "Movies", name+".strm"))
- if !strings.Contains(got, "/api/cloud/play/openlist?") {
- t.Fatalf("%s strm url = %q, want cloud play url", name, got)
- }
- }
-}
-
-func TestGenerateSTRMFromTreeReportsIgnoredFileLikeRows(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- Paths: []string{
- "/Movies/A.mkv",
- "/Movies/poster.jpg",
- "/Movies/fanart.jpg (cover image)",
- "/Movies/Disc.Image.2026.iso",
- "/Movies/Existing.strm",
- },
- TreeText: strings.Join([]string{
- "电视剧",
- "└── Show.Name.2026",
- " ├── Show.S01E01.mp4",
- " └── Show.S01E01.nfo",
- " └── Show.S01E01.srt 72 KB",
- }, "\n"),
- OutputDir: outDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 3 || res.Ignored != 5 || len(res.IgnoredItems) != 5 {
- t.Fatalf("result = %#v, want three generated videos and five ignored sidecars", res)
- }
- for _, item := range res.IgnoredItems {
- if item == "Show.Name.2026" || item == "电视剧/Show.Name.2026" {
- t.Fatalf("directory-like dotted title should not be reported as ignored: %#v", res.IgnoredItems)
- }
- }
- if !strings.Contains(strings.Join(res.IgnoredItems, "\n"), "Show.S01E01.nfo") {
- t.Fatalf("directory-like dotted title should not be reported as ignored: %#v", res.IgnoredItems)
- }
-}
-
-func TestGenerateSTRMFromTreeTransfersMatchingSubtitleLinks(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- Paths: []string{
- "/Movies/A.mkv",
- "/Movies/A.zh.srt",
- "/Movies/Orphan.srt",
- },
- TreeText: strings.Join([]string{
- "电视剧",
- "└── Show.Name.2026",
- " ├── Show.S01E01.mp4",
- " ├── Show.S01E01.ass",
- " └── Show.S01E02.srt",
- }, "\n"),
- OutputDir: outDir,
- BaseURL: "https://media.example.com",
- TransferSubtitles: true,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 4 || res.Ignored != 2 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want two video links, two matching subtitle links, and two orphan subtitles ignored", res)
- }
- movieSubtitle := readSTRM(t, filepath.Join(outDir, "Movies", "A.zh.srt.strm"))
- if !strings.HasPrefix(movieSubtitle, "https://media.example.com/api/cloud/play/openlist?") ||
- !strings.Contains(movieSubtitle, "ref=%2FMovies%2FA.zh.srt") {
- t.Fatalf("subtitle link = %q, want cloud play URL for subtitle source", movieSubtitle)
- }
- showSubtitle := readSTRM(t, filepath.Join(outDir, "电视剧", "Show.Name.2026", "Show.S01E01.ass.strm"))
- if !strings.Contains(showSubtitle, "ref=%2F%E7%94%B5%E8%A7%86%E5%89%A7%2FShow.Name.2026%2FShow.S01E01.ass") {
- t.Fatalf("tree subtitle link = %q, want full tree subtitle ref", showSubtitle)
- }
- if _, err := os.Stat(filepath.Join(outDir, "Movies", "Orphan.srt.strm")); !os.IsNotExist(err) {
- t.Fatalf("orphan subtitle should not generate a link, stat err=%v", err)
- }
- if _, err := os.Stat(filepath.Join(outDir, "电视剧", "Show.Name.2026", "Show.S01E02.srt.strm")); !os.IsNotExist(err) {
- t.Fatalf("subtitle without matching episode should not generate a link, stat err=%v", err)
- }
-}
-
-func TestGenerateSTRMFromTreeLimitsIgnoredItemSamples(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- paths := make([]string, 0, 25)
- for i := 0; i < 25; i++ {
- paths = append(paths, filepath.ToSlash(filepath.Join("/Movies", "sidecar-"+strconv.Itoa(i)+".nfo")))
- }
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- Paths: paths,
- OutputDir: outDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Ignored != 25 || len(res.IgnoredItems) != strmTreeIgnoredItemSampleLimit {
- t.Fatalf("ignored = %d samples = %d, want 25/%d", res.Ignored, len(res.IgnoredItems), strmTreeIgnoredItemSampleLimit)
- }
-}
-
-func TestGenerateSTRMFromTreeDryRunDoesNotWriteOrCleanup(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- stale := filepath.Join(outDir, "Movies", "Old.Movie.strm")
- if err := os.MkdirAll(filepath.Dir(stale), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(stale, []byte("old\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- Paths: []string{"/Movies/New.Movie.2026.mkv"},
- OutputDir: outDir,
- Cleanup: true,
- DryRun: true,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Previewed != 1 || res.Generated != 0 || res.Updated != 0 || res.Cleaned != 0 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want one preview and no writes", res)
- }
- if len(res.Items) != 1 || res.Items[0].Action != "preview" || res.Items[0].Reason != "generated" {
- t.Fatalf("preview item = %#v, want generated preview", res.Items)
- }
- if _, err := os.Stat(filepath.Join(outDir, "Movies", "New.Movie.2026.strm")); !os.IsNotExist(err) {
- t.Fatalf("dry run should not write new strm, stat err=%v", err)
- }
- if got := readSTRM(t, stale); got != "old" {
- t.Fatalf("dry run cleanup touched stale file: %q", got)
- }
-}
-
-func TestGenerateSTRMFromTreeDryRunDoesNotCreateOutputDir(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "missing-strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- Paths: []string{"/Movies/New.Movie.2026.mkv"},
- OutputDir: outDir,
- DryRun: true,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Previewed != 1 {
- t.Fatalf("previewed = %d, want 1", res.Previewed)
- }
- if _, err := os.Stat(outDir); !os.IsNotExist(err) {
- t.Fatalf("dry run should not create output dir, stat err=%v", err)
- }
-}
-
-func TestGenerateSTRMFromTreeBatchLimitContinuesAfterExistingFiles(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
- opts := GenerateSTRMTreeOptions{
- Provider: "openlist",
- Paths: []string{"/Movies/A.mkv", "/Movies/B.mkv", "/Movies/C.mkv"},
- OutputDir: outDir,
- BatchLimit: 2,
- }
-
- first, err := svc.GenerateFromTree(t.Context(), opts)
- if err != nil {
- t.Fatal(err)
- }
- if first.Generated != 2 || first.Skipped != 0 {
- t.Fatalf("first batch = %#v, want two generated", first)
- }
- if first.Total != 3 || first.Remaining != 1 || !first.BatchLimited {
- t.Fatalf("first batch progress = total %d remaining %d limited %v, want 3/1/true", first.Total, first.Remaining, first.BatchLimited)
- }
- if _, err := os.Stat(filepath.Join(outDir, "Movies", "C.strm")); !os.IsNotExist(err) {
- t.Fatalf("first batch should not write third item, stat err=%v", err)
- }
-
- second, err := svc.GenerateFromTree(t.Context(), opts)
- if err != nil {
- t.Fatal(err)
- }
- if second.Generated != 1 || second.Skipped != 2 {
- t.Fatalf("second batch = %#v, want two existing skips then next generated", second)
- }
- if second.Total != 3 || second.Remaining != 0 || second.BatchLimited {
- t.Fatalf("second batch progress = total %d remaining %d limited %v, want 3/0/false", second.Total, second.Remaining, second.BatchLimited)
- }
- if got := readSTRM(t, filepath.Join(outDir, "Movies", "C.strm")); !strings.Contains(got, "C.mkv") {
- t.Fatalf("second batch C.strm = %q, want generated third item", got)
- }
-}
-
-func TestGenerateSTRMFromTreeBatchLimitSkipsCleanup(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- stale := filepath.Join(outDir, "Movies", "stale.strm")
- if err := os.MkdirAll(filepath.Dir(stale), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(stale, []byte("keep\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- Paths: []string{"/Movies/A.mkv", "/Movies/B.mkv"},
- OutputDir: outDir,
- BatchLimit: 1,
- Cleanup: true,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 1 || res.Cleaned != 0 {
- t.Fatalf("batch result = %#v, want one generated and no cleanup", res)
- }
- if got := readSTRM(t, stale); got != "keep" {
- t.Fatalf("batch cleanup should not touch stale file: %q", got)
- }
-}
diff --git a/internal/service/strm_tree_missing.go b/internal/service/strm_tree_missing.go
deleted file mode 100644
index a7265af..0000000
--- a/internal/service/strm_tree_missing.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "net/url"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *STRMService) strmTreeExistingCloudRefs(ctx context.Context, opts GenerateSTRMTreeOptions) (map[string]struct{}, error) {
- if !opts.MissingOnly {
- return nil, nil
- }
- if s == nil || s.repo == nil || s.repo.DB == nil {
- return nil, errors.New("media library unavailable")
- }
- var rows []model.Media
- if err := s.repo.DB.WithContext(ctx).
- Select("path", "strm_url").
- Where("strm_url <> '' OR path LIKE ?", "cloud://%").
- Find(&rows).Error; err != nil {
- return nil, err
- }
- refs := make(map[string]struct{}, len(rows))
- for _, row := range rows {
- if typ, ref, ok := parseCloudMediaPlaybackURL(row.STRMURL); ok {
- refs[strmTreeCloudRefKey(typ, ref)] = struct{}{}
- }
- if typ, ref, ok := strmTreeCloudPathTarget(row.Path); ok {
- refs[strmTreeCloudRefKey(typ, ref)] = struct{}{}
- }
- }
- return refs, nil
-}
-
-func strmTreeSourceAlreadyInLibrary(source strmTreeSource, opts GenerateSTRMTreeOptions, existingRefs map[string]struct{}) bool {
- if len(existingRefs) == 0 || source.Provider == "" || source.Path == "" || source.Kind == strmTreeSourceKindSubtitle {
- return false
- }
- ref := strmTreeCloudRef(source.cloudRefPath(), opts.SourceRoot)
- _, ok := existingRefs[strmTreeCloudRefKey(source.Provider, ref)]
- return ok
-}
-
-func strmTreeCloudPathTarget(raw string) (string, string, bool) {
- raw = strings.TrimSpace(raw)
- if !strings.HasPrefix(strings.ToLower(raw), "cloud://") {
- return "", "", false
- }
- rest := strings.TrimPrefix(raw, "cloud://")
- parts := strings.SplitN(rest, "/", 2)
- if len(parts) != 2 {
- return "", "", false
- }
- typ := strings.TrimSpace(parts[0])
- ref := strings.TrimSpace(parts[1])
- return typ, ref, typ != "" && ref != ""
-}
-
-func strmTreeCloudRefKey(provider, ref string) string {
- return strings.ToLower(normalizeSTRMTreeProvider(provider)) + "\x00" + strmTreeNormalizeCloudRef(ref)
-}
-
-func strmTreeNormalizeCloudRef(ref string) string {
- ref = strings.TrimSpace(ref)
- if decoded, err := url.PathUnescape(ref); err == nil {
- ref = decoded
- }
- ref = strings.TrimSpace(strings.ReplaceAll(ref, "\\", "/"))
- return strings.ToLower(strings.TrimLeft(ref, "/"))
-}
diff --git a/internal/service/strm_tree_normalize.go b/internal/service/strm_tree_normalize.go
deleted file mode 100644
index 2702ae8..0000000
--- a/internal/service/strm_tree_normalize.go
+++ /dev/null
@@ -1,380 +0,0 @@
-package service
-
-import (
- "net/url"
- "path"
- "regexp"
- "strings"
-)
-
-var strmTreeListPrefixRE = regexp.MustCompile(`^\s*(?:[-*+•·]\s+|\d{1,4}[\.)、]\s+)`)
-
-var strmTreeURLPathQueryKeys = []string{"path", "ref", "file", "filepath", "filename", "name"}
-
-var strmTreeKnownNonVideoExtensions = map[string]struct{}{
- ".ass": {},
- ".bmp": {},
- ".gif": {},
- ".idx": {},
- ".jpeg": {},
- ".jpg": {},
- ".nfo": {},
- ".png": {},
- ".srt": {},
- ".ssa": {},
- ".strm": {},
- ".sub": {},
- ".txt": {},
- ".url": {},
- ".vtt": {},
- ".webp": {},
-}
-
-var strmTreeSubtitleExtensions = map[string]struct{}{
- ".ass": {},
- ".idx": {},
- ".srt": {},
- ".ssa": {},
- ".sub": {},
- ".vtt": {},
-}
-
-func normalizeSTRMTreeProvider(raw string) string {
- switch strings.ToLower(strings.TrimSpace(raw)) {
- case "115", "115pan", "pan115", "cloud115":
- return "cloud115"
- case "alist", "openlist", "webdav", "clouddrive2", "s3":
- return strings.ToLower(strings.TrimSpace(raw))
- default:
- return strings.ToLower(strings.TrimSpace(raw))
- }
-}
-
-func normalizeSTRMTreeSource(raw string) string {
- value := strings.TrimSpace(strings.Trim(raw, `"'`))
- if value == "" {
- return ""
- }
- if strings.HasPrefix(strings.ToLower(value), "cloud://") {
- parts := strmLibraryPathParts(value)
- if len(parts) == 0 {
- return ""
- }
- return "/" + path.Join(parts...)
- }
- if parsed, err := url.Parse(value); err == nil && parsed.Scheme != "" && parsed.Scheme != "file" {
- if ref := strmTreeSourceFromURL(parsed); ref != "" {
- value = ref
- }
- }
- value = trimSTRMTreeMediaMetadata(value)
- value = strings.ReplaceAll(value, "\\", "/")
- value = pathAfterWindowsDrivePrefix(value)
- value = cleanSTRMTreePathDecorations(value)
- value = strings.TrimSpace(value)
- if value == "" {
- return ""
- }
- if strmTreeHasUnsafeSegment(value) {
- return strings.Trim(value, "/")
- }
- if strings.HasPrefix(value, "/") {
- return path.Clean(value)
- }
- return strings.TrimPrefix(path.Clean("/"+value), "/")
-}
-
-func normalizeSTRMTreeSourceWithProvider(raw, fallbackProvider string) strmTreeSource {
- provider := normalizeSTRMTreeProvider(fallbackProvider)
- value := strings.TrimSpace(strings.Trim(raw, `"'`))
- if value == "" {
- return strmTreeSource{}
- }
- if info, ok := ParseCloudLibraryMount(value); ok {
- source := normalizeSTRMTreeSource(info.DisplayDir)
- if source == "" {
- return strmTreeSource{}
- }
- ref := normalizeSTRMTreeSource(info.ScanDir)
- if ref == "" {
- ref = source
- }
- return strmTreeSource{Provider: normalizeSTRMTreeProvider(info.Provider), Path: source, RefPath: ref}
- }
- if parsed, err := url.Parse(value); err == nil {
- if cloudProvider, ref := strmTreeCloudPlaySourceFromURL(parsed); cloudProvider != "" && ref != "" {
- source := normalizeSTRMTreeSource(ref)
- if source == "" {
- return strmTreeSource{}
- }
- return strmTreeSource{Provider: normalizeSTRMTreeProvider(cloudProvider), Path: source}
- }
- }
- source := normalizeSTRMTreeSource(value)
- if source == "" {
- return strmTreeSource{}
- }
- return strmTreeSource{Provider: provider, Path: source}
-}
-
-func normalizeSTRMTreeSubtitleSourceWithProvider(raw, fallbackProvider string) strmTreeSource {
- provider := normalizeSTRMTreeProvider(fallbackProvider)
- value := strings.TrimSpace(strings.Trim(raw, `"'`))
- if value == "" {
- return strmTreeSource{}
- }
- if info, ok := ParseCloudLibraryMount(value); ok {
- source, sourceOK := strmTreeSubtitleFileLikeSource(info.DisplayDir)
- if !sourceOK {
- return strmTreeSource{}
- }
- ref, refOK := strmTreeSubtitleFileLikeSource(info.ScanDir)
- if !refOK {
- ref = source
- }
- return strmTreeSource{Provider: normalizeSTRMTreeProvider(info.Provider), Path: source, RefPath: ref, Kind: strmTreeSourceKindSubtitle}
- }
- source, ok := strmTreeSubtitleFileLikeSource(value)
- if !ok {
- return strmTreeSource{}
- }
- return strmTreeSource{Provider: provider, Path: source, Kind: strmTreeSourceKindSubtitle}
-}
-
-func strmTreeCloudPlaySourceFromURL(parsed *url.URL) (string, string) {
- if parsed == nil {
- return "", ""
- }
- parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
- if len(parts) < 4 || !strings.EqualFold(parts[0], "api") || !strings.EqualFold(parts[1], "cloud") || !strings.EqualFold(parts[2], "play") {
- return "", ""
- }
- provider := strings.TrimSpace(parts[3])
- if decoded, err := url.PathUnescape(provider); err == nil {
- provider = decoded
- }
- // /api/cloud/play is generated by this app with url.Values, where '+'
- // represents a space. Decode the standard query view first so re-importing
- // existing STRM links does not turn "My Movie.mkv" into "My+Movie.mkv".
- for _, value := range parsed.Query()["ref"] {
- if ref := strmTreeURLQueryMediaSource(value); ref != "" {
- return provider, ref
- }
- }
- for _, value := range strmTreeRawQueryValues(parsed.RawQuery, "ref") {
- if ref := strmTreeURLQueryMediaSource(value); ref != "" {
- return provider, ref
- }
- }
- return "", ""
-}
-
-func strmTreeSourceFromURL(parsed *url.URL) string {
- if parsed == nil {
- return ""
- }
- for _, key := range strmTreeURLPathQueryKeys {
- for _, value := range strmTreeRawQueryValues(parsed.RawQuery, key) {
- if ref := strmTreeURLQueryMediaSource(value); ref != "" {
- return ref
- }
- }
- for _, value := range parsed.Query()[key] {
- if ref := strmTreeURLQueryMediaSource(value); ref != "" {
- return ref
- }
- }
- }
- if parsed.Path != "" && strmTreeURLQueryMediaSource(parsed.Path) != "" {
- return parsed.Path
- }
- return ""
-}
-
-func strmTreeRawQueryValues(rawQuery, key string) []string {
- key = strings.ToLower(strings.TrimSpace(key))
- if rawQuery == "" || key == "" {
- return nil
- }
- values := make([]string, 0, 1)
- for _, pair := range strings.Split(rawQuery, "&") {
- if pair == "" {
- continue
- }
- rawKey, rawValue, _ := strings.Cut(pair, "=")
- decodedKey, err := url.QueryUnescape(rawKey)
- if err != nil {
- decodedKey = rawKey
- }
- if strings.ToLower(strings.TrimSpace(decodedKey)) != key {
- continue
- }
- decodedValue, err := url.PathUnescape(rawValue)
- if err != nil {
- decodedValue = rawValue
- }
- values = append(values, decodedValue)
- }
- return values
-}
-
-func strmTreeURLQueryMediaSource(raw string) string {
- value := strings.TrimSpace(strings.Trim(raw, `"'`))
- if value == "" {
- return ""
- }
- if parsed, err := url.Parse(value); err == nil && parsed.Scheme != "" && parsed.Scheme != "file" {
- value = parsed.Path
- }
- value = trimSTRMTreeMediaMetadata(value)
- if strmTreeSourceIsVideo(value) {
- return value
- }
- return ""
-}
-
-func strmTreeHasUnsafeSegment(value string) bool {
- for _, part := range strings.Split(strings.ReplaceAll(value, "\\", "/"), "/") {
- if strings.TrimSpace(part) == ".." {
- return true
- }
- }
- return false
-}
-
-func strmTreeSourceIsVideo(source string) bool {
- ext := strings.ToLower(path.Ext(trimSTRMTreeMediaMetadata(cleanSTRMTreeItemName(source))))
- if ext == ".strm" {
- return false
- }
- _, ok := videoExtensions[ext]
- return ok
-}
-
-func strmTreeIgnoredFileLikeSource(raw string) (string, bool) {
- source := normalizeSTRMTreeSource(raw)
- if source == "" {
- return "", false
- }
- source = trimSTRMTreeKnownFileMetadata(cleanSTRMTreeItemName(source))
- ext := strings.ToLower(path.Ext(source))
- if ext == "" {
- return "", false
- }
- if ext == ".strm" {
- return source, true
- }
- if _, ok := videoExtensions[ext]; ok {
- return "", false
- }
- if _, ok := strmTreeKnownNonVideoExtensions[ext]; ok {
- return source, true
- }
- return "", false
-}
-
-func strmTreeSubtitleFileLikeSource(raw string) (string, bool) {
- source, ok := strmTreeIgnoredFileLikeSource(raw)
- if !ok {
- return "", false
- }
- ext := strings.ToLower(path.Ext(source))
- if _, ok := strmTreeSubtitleExtensions[ext]; !ok {
- return "", false
- }
- return source, true
-}
-
-func cleanSTRMTreePathDecorations(value string) string {
- if value == "" {
- return ""
- }
- leadingSlash := strings.HasPrefix(value, "/")
- parts := strings.Split(value, "/")
- for i, part := range parts {
- if part == "" {
- continue
- }
- parts[i] = cleanSTRMTreeItemName(part)
- }
- cleaned := strings.Join(parts, "/")
- if leadingSlash && !strings.HasPrefix(cleaned, "/") {
- cleaned = "/" + cleaned
- }
- return cleaned
-}
-
-func cleanSTRMTreeItemName(value string) string {
- value = strings.TrimSpace(strings.Trim(value, `"'`))
- for {
- before := value
- value = strings.TrimSpace(strmTreeListPrefixRE.ReplaceAllString(value, ""))
- value = strings.TrimSpace(strings.TrimLeft(value, "\ufeff📁📂📄📃🎬🎞🎥📺🗂️"))
- lower := strings.ToLower(value)
- for _, marker := range []string{
- "[file]", "[f]", "[文件]", "[檔案]", "[档案]",
- "[dir]", "[d]", "[folder]", "[目录]", "[目錄]", "[文件夹]", "[資料夾]",
- "(file)", "(f)", "(文件)", "(檔案)", "(档案)",
- "(dir)", "(d)", "(folder)", "(目录)", "(目錄)", "(文件夹)", "(資料夾)",
- } {
- if strings.HasPrefix(lower, marker) {
- value = strings.TrimLeft(strings.TrimSpace(value[len(marker):]), "-_:: ")
- break
- }
- }
- if value == before {
- return value
- }
- }
-}
-
-func trimSTRMTreeMediaMetadata(value string) string {
- return trimSTRMTreeMetadataByExtensions(value, videoExtensions)
-}
-
-func trimSTRMTreeKnownFileMetadata(value string) string {
- return trimSTRMTreeMetadataByExtensions(value, videoExtensions, strmTreeKnownNonVideoExtensions)
-}
-
-func trimSTRMTreeMetadataByExtensions(value string, extensionSets ...map[string]struct{}) string {
- value = strings.TrimSpace(value)
- lower := strings.ToLower(value)
- bestEnd := -1
- for _, extensions := range extensionSets {
- for ext := range extensions {
- from := 0
- for {
- idx := strings.Index(lower[from:], ext)
- if idx < 0 {
- break
- }
- idx += from
- end := idx + len(ext)
- if strmTreeMediaExtBoundary(value, end) && end > bestEnd {
- bestEnd = end
- }
- from = idx + 1
- if from >= len(lower) {
- break
- }
- }
- }
- }
- if bestEnd > 0 && bestEnd < len(value) {
- return strings.TrimSpace(value[:bestEnd])
- }
- return value
-}
-
-func strmTreeMediaExtBoundary(value string, end int) bool {
- if end >= len(value) {
- return true
- }
- for _, r := range value[end:] {
- if r == '(' || r == '[' || r == '{' || r == '<' || r == '(' || r == '【' || r == ',' || r == ',' {
- return true
- }
- return r == ' ' || r == '\t' || r == '\r' || r == '\n'
- }
- return true
-}
diff --git a/internal/service/strm_tree_parse.go b/internal/service/strm_tree_parse.go
deleted file mode 100644
index 5f8c5ef..0000000
--- a/internal/service/strm_tree_parse.go
+++ /dev/null
@@ -1,196 +0,0 @@
-package service
-
-import (
- "path"
- "strings"
-)
-
-func parseSTRMTreeText(raw string) []string {
- out, _ := parseSTRMTreeTextWithIgnored(raw)
- return out
-}
-
-func parseSTRMTreeTextWithIgnored(raw string) ([]string, []string) {
- var out []string
- var ignored []string
- stack := make([]string, 0, 8)
- plainIndents := make([]int, 0, 8)
- rootOffset := 0
- for _, line := range strings.Split(raw, "\n") {
- line = strings.TrimRight(line, " \t\r")
- if strings.TrimSpace(line) == "" {
- continue
- }
- if item, level, ok := parseSTRMTreeLine(line); ok {
- plainIndents = plainIndents[:0]
- level += rootOffset
- stack = stack[:min(level, len(stack))]
- if strmTreeItemIsVideoSource(item) {
- out = append(out, strmTreeJoinedSource(stack, item))
- continue
- }
- if strmTreeItemIsIgnoredFileSource(item) {
- ignored = append(ignored, strmTreeJoinedSource(stack, item))
- continue
- }
- stack = append(stack, item)
- continue
- }
- if item, level, ok := parseSTRMTreeContinuationLine(line, len(plainIndents) > 0); ok {
- plainIndents = plainIndents[:0]
- level += rootOffset
- stack = stack[:min(level, len(stack))]
- if strmTreeItemIsVideoSource(item) {
- out = append(out, path.Join(append(append([]string{}, stack...), item)...))
- } else if strmTreeItemIsIgnoredFileSource(item) {
- ignored = append(ignored, path.Join(append(append([]string{}, stack...), item)...))
- }
- continue
- }
- item := cleanSTRMTreeItemName(line)
- if indent := strmTreePlainIndent(line); indent > 0 && !strings.ContainsAny(item, `/\`) {
- for len(plainIndents) > 0 && indent <= plainIndents[len(plainIndents)-1] {
- plainIndents = plainIndents[:len(plainIndents)-1]
- }
- level := rootOffset + len(plainIndents)
- stack = stack[:min(level, len(stack))]
- if strmTreeItemIsVideoSource(item) {
- out = append(out, strmTreeJoinedSource(stack, item))
- continue
- }
- if strmTreeItemIsIgnoredFileSource(item) {
- ignored = append(ignored, strmTreeJoinedSource(stack, item))
- continue
- }
- stack = append(stack, item)
- plainIndents = append(plainIndents, indent)
- continue
- }
- if strmTreeItemIsVideoSource(item) || strings.ContainsAny(item, `/\`) {
- plainIndents = plainIndents[:0]
- if strmTreeItemIsVideoSource(item) {
- out = append(out, item)
- } else if strmTreeItemIsIgnoredFileSource(item) {
- ignored = append(ignored, item)
- }
- continue
- }
- if strmTreeItemIsIgnoredFileSource(item) {
- ignored = append(ignored, item)
- continue
- }
- stack = []string{item}
- plainIndents = plainIndents[:0]
- rootOffset = 1
- }
- return out, ignored
-}
-
-func parseSTRMTreeLine(line string) (string, int, bool) {
- if idx := strings.Index(line, "──"); idx >= 0 {
- prefix := line[:idx]
- level := strmTreeIndentLevelWithWidth(prefix, 4)
- item := cleanSTRMTreeItemName(strings.Trim(strings.TrimSpace(line[idx+len("──"):]), "─- "))
- return item, level, item != ""
- }
- if idx := strings.Index(line, "─"); idx >= 0 {
- prefix := line[:idx]
- level := strmTreeIndentLevelWithWidth(prefix, 3)
- item := cleanSTRMTreeItemName(strings.Trim(strings.TrimSpace(line[idx+len("─"):]), "─- "))
- return item, level, item != ""
- }
- for _, marker := range []string{"|--", "+--", "`--"} {
- if idx := strings.Index(line, marker); idx >= 0 {
- item := cleanSTRMTreeItemName(line[idx+len(marker):])
- return item, strmTreeIndentLevel(line[:idx]), item != ""
- }
- }
- return "", 0, false
-}
-
-func parseSTRMTreeContinuationLine(line string, plainTreeActive bool) (string, int, bool) {
- prefixLen := 0
- hasGuide := false
- for _, r := range line {
- switch r {
- case ' ', '\t', '│', '|':
- if r == '│' || r == '|' {
- hasGuide = true
- }
- prefixLen += len(string(r))
- default:
- if plainTreeActive && !hasGuide {
- return "", 0, false
- }
- item := cleanSTRMTreeItemName(line[prefixLen:])
- if item == "" || (!strmTreeItemIsVideoSource(item) && !strmTreeItemIsIgnoredFileSource(item)) {
- return "", 0, false
- }
- return item, strmTreeIndentLevel(line[:prefixLen]), true
- }
- }
- return "", 0, false
-}
-
-func strmTreeIndentLevel(prefix string) int {
- return strmTreeIndentLevelWithWidth(prefix, 4)
-}
-
-func strmTreeIndentLevelWithWidth(prefix string, width int) int {
- if prefix == "" {
- return 0
- }
- if width <= 0 {
- width = 4
- }
- verticals := strings.Count(prefix, "│") + strings.Count(prefix, "|")
- runeLen := len([]rune(strings.ReplaceAll(strings.ReplaceAll(prefix, "│", " "), "|", " ")))
- byWidth := 0
- if runeLen > 0 {
- byWidth = (runeLen - 1) / width
- }
- if verticals > byWidth {
- return verticals
- }
- return byWidth
-}
-
-func strmTreePlainIndent(line string) int {
- indent := 0
- for _, r := range line {
- switch r {
- case ' ':
- indent++
- case '\t':
- indent += 4
- default:
- return indent
- }
- }
- return indent
-}
-
-func strmTreeItemIsVideoSource(item string) bool {
- if strmTreeSourceIsVideo(item) {
- return true
- }
- source := normalizeSTRMTreeSourceWithProvider(item, "openlist")
- return source.Path != "" && strmTreeSourceIsVideo(source.Path)
-}
-
-func strmTreeItemIsIgnoredFileSource(item string) bool {
- _, ok := strmTreeIgnoredFileLikeSource(item)
- return ok
-}
-
-func strmTreeJoinedSource(stack []string, item string) string {
- if strmTreeItemIsAbsoluteSource(item) {
- return item
- }
- return path.Join(append(append([]string{}, stack...), item)...)
-}
-
-func strmTreeItemIsAbsoluteSource(item string) bool {
- value := strings.ToLower(strings.TrimSpace(item))
- return strings.Contains(value, "://") || strings.HasPrefix(value, "/api/")
-}
diff --git a/internal/service/strm_tree_parse_test.go b/internal/service/strm_tree_parse_test.go
deleted file mode 100644
index 36235f8..0000000
--- a/internal/service/strm_tree_parse_test.go
+++ /dev/null
@@ -1,234 +0,0 @@
-package service
-
-import (
- "path/filepath"
- "strings"
- "testing"
-
- "go.uber.org/zap"
-)
-
-func TestGenerateSTRMFromTreeTextPreservesRootTree(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
- tree := strings.Join([]string{
- "电视剧",
- "├── 国产剧",
- "│ └── 南部档案",
- "│ ├── Archives.S01E01.mkv",
- "│ └── Archives.S01E01.nfo",
- }, "\n")
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- TreeText: tree,
- OutputDir: outDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 1 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want one generated video", res)
- }
- got := readSTRM(t, filepath.Join(outDir, "电视剧", "国产剧", "南部档案", "Archives.S01E01.strm"))
- if got != "/api/cloud/play/openlist?ref=%2F%E7%94%B5%E8%A7%86%E5%89%A7%2F%E5%9B%BD%E4%BA%A7%E5%89%A7%2F%E5%8D%97%E9%83%A8%E6%A1%A3%E6%A1%88%2FArchives.S01E01.mkv" {
- t.Fatalf("strm url = %q", got)
- }
-}
-
-func TestGenerateSTRMFromTreeTextSupportsSingleLineTreeMarkers(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
- tree := strings.Join([]string{
- "动漫",
- "├─ 国漫",
- "│ └─ 凡人修仙传",
- "│ └─ Season 01",
- "│ └─ Mortal.Journey.S01E01.mp4",
- }, "\n")
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "115",
- TreeText: tree,
- OutputDir: outDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 1 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want one generated video", res)
- }
- got := readSTRM(t, filepath.Join(outDir, "动漫", "国漫", "凡人修仙传", "Season 01", "Mortal.Journey.S01E01.strm"))
- if !strings.Contains(got, "/api/cloud/play/cloud115?") {
- t.Fatalf("strm url = %q, want cloud115 play url", got)
- }
-}
-
-func TestGenerateSTRMFromTreeTextSupportsConnectorURLSources(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
- tree := strings.Join([]string{
- "电影",
- "├── https://media.example.com/api/cloud/play/openlist?ref=%2FMovies%2FLinked.Movie.2026.mkv",
- "└── cloud://openlist/%E7%94%B5%E5%BD%B1/%E5%88%AB%E5%90%8D/Cloud.Query.2026.mkv?dir=%2Factual%2Fcloud%2FCloud.Query.2026.mkv",
- }, "\n")
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "115",
- TreeText: tree,
- OutputDir: outDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 2 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want two generated videos from connector URL rows", res)
- }
- linked := readSTRM(t, filepath.Join(outDir, "Movies", "Linked.Movie.2026.strm"))
- if !strings.Contains(linked, "/api/cloud/play/openlist?") || !strings.Contains(linked, "ref=%2FMovies%2FLinked.Movie.2026.mkv") {
- t.Fatalf("cloud play connector url = %q, want preserved provider/ref", linked)
- }
- cloud := readSTRM(t, filepath.Join(outDir, "电影", "别名", "Cloud.Query.2026.strm"))
- if !strings.Contains(cloud, "/api/cloud/play/openlist?") || !strings.Contains(cloud, "ref=%2Factual%2Fcloud%2FCloud.Query.2026.mkv") {
- t.Fatalf("cloud mount connector url = %q, want display path output and scan dir ref", cloud)
- }
-}
-
-func TestGenerateSTRMFromTreeTextSupportsPlainIndentedTree(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
- tree := strings.Join([]string{
- "电视剧",
- " 欧美剧",
- " House of the Dragon",
- " Season 03",
- " House.of.the.Dragon.S03E01.mkv",
- " House.of.the.Dragon.S03E02.mkv",
- " The Last of Us",
- " Season 02",
- " The.Last.of.Us.S02E01.mkv",
- }, "\n")
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- TreeText: tree,
- OutputDir: outDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 3 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want three generated videos from plain indented tree", res)
- }
- got := readSTRM(t, filepath.Join(outDir, "电视剧", "欧美剧", "House of the Dragon", "Season 03", "House.of.the.Dragon.S03E01.strm"))
- if !strings.Contains(got, "House.of.the.Dragon.S03E01.mkv") {
- t.Fatalf("strm url = %q, want first plain-indented source ref", got)
- }
- got = readSTRM(t, filepath.Join(outDir, "电视剧", "欧美剧", "The Last of Us", "Season 02", "The.Last.of.Us.S02E01.strm"))
- if !strings.Contains(got, "The.Last.of.Us.S02E01.mkv") {
- t.Fatalf("strm url = %q, want sibling folder source ref", got)
- }
-}
-
-func TestGenerateSTRMFromTreeTextSupportsWindowsTreeFileRows(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
- tree := strings.Join([]string{
- "电视剧",
- "├─欧美剧",
- "│ ├─House of the Dragon",
- "│ │ House.of.the.Dragon.S03E01.mkv",
- "│ │ House.of.the.Dragon.S03E02.mkv",
- "│ └─The Last of Us",
- "│ The.Last.of.Us.S02E01.mkv",
- }, "\n")
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- TreeText: tree,
- OutputDir: outDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 3 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want three generated videos from Windows tree rows", res)
- }
- got := readSTRM(t, filepath.Join(outDir, "电视剧", "欧美剧", "House of the Dragon", "House.of.the.Dragon.S03E02.strm"))
- if !strings.Contains(got, "House.of.the.Dragon.S03E02.mkv") {
- t.Fatalf("strm url = %q, want vertical-prefix file row ref", got)
- }
- got = readSTRM(t, filepath.Join(outDir, "电视剧", "欧美剧", "The Last of Us", "The.Last.of.Us.S02E01.strm"))
- if !strings.Contains(got, "The.Last.of.Us.S02E01.mkv") {
- t.Fatalf("strm url = %q, want blank-prefix sibling file row ref", got)
- }
-}
-
-func TestGenerateSTRMFromTreeStripsDecoratedTreeNames(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
- tree := strings.Join([]string{
- "📁 电视剧",
- "├── [目录] 欧美剧",
- "│ └── (folder) House of the Dragon",
- "│ │ 📄 House.of.the.Dragon.S03E01.mkv",
- }, "\n")
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- TreeText: tree,
- Paths: []string{"/[目录] 动漫/[folder] 日番/[文件] Frieren.S01E01.mp4"},
- SourceRoot: "/动漫",
- OutputDir: outDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 2 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want two generated videos with decorations stripped", res)
- }
- showPath := filepath.Join(outDir, "电视剧", "欧美剧", "House of the Dragon", "House.of.the.Dragon.S03E01.strm")
- show := readSTRM(t, showPath)
- if strings.Contains(showPath, "目录") || strings.Contains(showPath, "folder") || strings.Contains(showPath, "📄") {
- t.Fatalf("decorated local path was not cleaned: %q", showPath)
- }
- if !strings.Contains(show, "House.of.the.Dragon.S03E01.mkv") || strings.Contains(show, "%5B") || strings.Contains(show, "%F0%9F") {
- t.Fatalf("decorated tree ref was not cleaned: %q", show)
- }
- episode := readSTRM(t, filepath.Join(outDir, "日番", "Frieren.S01E01.strm"))
- if !strings.Contains(episode, "ref=%2F%E5%8A%A8%E6%BC%AB%2F%E6%97%A5%E7%95%AA%2FFrieren.S01E01.mp4") {
- t.Fatalf("decorated direct path ref was not cleaned: %q", episode)
- }
-}
-
-func TestGenerateSTRMFromTreeStripsExportedFileMetadata(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
- tree := strings.Join([]string{
- "电影",
- "└── 欧美电影",
- " └── Dune.Part.Two.2024.2160p.WEB-DL.mkv 18.6 GB 2024-04-01 12:30",
- }, "\n")
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- TreeText: tree,
- Paths: []string{"/电视剧/欧美剧/Show/Season 01/Show.S01E01.mp4 (2.1 GB)"},
- SourceRoot: "/电视剧",
- OutputDir: outDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 2 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want two generated videos with metadata suffix stripped", res)
- }
- movie := readSTRM(t, filepath.Join(outDir, "电影", "欧美电影", "Dune.Part.Two.2024.2160p.WEB-DL.strm"))
- if !strings.Contains(movie, "Dune.Part.Two.2024.2160p.WEB-DL.mkv") || strings.Contains(movie, "18.6") {
- t.Fatalf("movie strm url = %q, want clean media ref without size metadata", movie)
- }
- episode := readSTRM(t, filepath.Join(outDir, "欧美剧", "Show", "Season 01", "Show.S01E01.strm"))
- if !strings.Contains(episode, "Show.S01E01.mp4") || strings.Contains(episode, "2.1") {
- t.Fatalf("episode strm url = %q, want clean media ref without size metadata", episode)
- }
-}
diff --git a/internal/service/strm_tree_paths.go b/internal/service/strm_tree_paths.go
deleted file mode 100644
index 1df4e5c..0000000
--- a/internal/service/strm_tree_paths.go
+++ /dev/null
@@ -1,187 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "path"
- "path/filepath"
- "strconv"
- "strings"
-)
-
-func strmTreeRelativeSource(source, sourceRoot string) string {
- source = normalizeSTRMTreeSource(source)
- root := normalizeRemotePath(sourceRoot)
- if root != "/" && strings.HasPrefix(source, root+"/") {
- return strings.TrimPrefix(strings.TrimPrefix(source, root), "/")
- }
- return strings.TrimPrefix(source, "/")
-}
-
-func strmTreeCloudRef(source, sourceRoot string) string {
- source = normalizeSTRMTreeSource(source)
- if strings.HasPrefix(source, "/") {
- return source
- }
- if strings.TrimSpace(sourceRoot) != "" {
- return joinRemotePath(sourceRoot, source)
- }
- return normalizeRemotePath(source)
-}
-
-func strmTreeOutputRelativePath(source string) (string, error) {
- return strmTreeOutputRelativePathWithLinkExtension(source, videoExtensions, ".strm", false)
-}
-
-func (s *STRMService) strmTreeRecognizedOutputRelativePath(ctx context.Context, source, sourceRoot string) (string, error) {
- source = normalizeSTRMTreeSource(source)
- season, episode := ParseEpisode(source)
- title, year := s.strmTreeCleanQuery(ctx, source)
- if season > 0 || episode > 0 {
- show := s.strmTreeRecognizedSeriesTitle(ctx, source, sourceRoot, title)
- if show == "" {
- return "", errors.New("empty recognized series title")
- }
- if season <= 0 {
- season = 1
- }
- if episode <= 0 {
- return "", errors.New("missing recognized episode number")
- }
- name := fmt.Sprintf("%s S%02dE%02d.strm", show, season, episode)
- return filepath.Join(sanitizeFilename(show), fmt.Sprintf("Season %02d", season), sanitizeFilename(name)), nil
- }
- if title == "" {
- return "", errors.New("empty recognized movie title")
- }
- movie := sanitizeFilename(titleCaseWords(title))
- if movie == "" {
- return "", errors.New("empty recognized movie filename")
- }
- folder := movie
- if year > 0 && !strings.Contains(folder, strconv.Itoa(year)) {
- folder = fmt.Sprintf("%s (%d)", movie, year)
- }
- return filepath.Join(sanitizeFilename(folder), sanitizeFilename(folder)+".strm"), nil
-}
-
-func (s *STRMService) strmTreeRecognizedSeriesTitle(ctx context.Context, source, sourceRoot, fallback string) string {
- rel := strmTreeRelativeSource(source, sourceRoot)
- parts := strings.Split(strings.Trim(strings.ReplaceAll(rel, "\\", "/"), "/"), "/")
- if len(parts) >= 2 {
- dir := parts[len(parts)-2]
- if strings.TrimSpace(dir) != "" && !seriesSeasonDirRE.MatchString(dir) {
- if title, _ := s.strmTreeCleanQuery(ctx, dir); title != "" {
- return sanitizeFilename(titleCaseWords(title))
- }
- return sanitizeFilename(strings.TrimSpace(dir))
- }
- }
- if len(parts) >= 3 {
- dir := parts[len(parts)-3]
- if title, _ := s.strmTreeCleanQuery(ctx, dir); title != "" {
- return sanitizeFilename(titleCaseWords(title))
- }
- return sanitizeFilename(strings.TrimSpace(dir))
- }
- if fallback != "" {
- return sanitizeFilename(titleCaseWords(fallback))
- }
- return ""
-}
-
-func (s *STRMService) strmTreeCleanQuery(ctx context.Context, raw string) (string, int) {
- if s == nil {
- return CleanQuery(raw)
- }
- return CleanQueryWithRecognition(ctx, s.repo, raw)
-}
-
-func strmTreeOutputSubtitleLinkRelativePath(source string) (string, error) {
- return strmTreeOutputRelativePathWithLinkExtension(source, strmTreeSubtitleExtensions, ".strm", true)
-}
-
-func strmTreeOutputRelativePathWithLinkExtension(source string, allowedExtensions map[string]struct{}, linkExtension string, appendLinkExtension bool) (string, error) {
- parts := strings.Split(strings.Trim(strings.ReplaceAll(source, "\\", "/"), "/"), "/")
- if len(parts) == 0 {
- return "", errors.New("empty source path")
- }
- out := make([]string, 0, len(parts))
- for i, part := range parts {
- part = strings.TrimSpace(part)
- if part == "" || part == "." || part == ".." {
- return "", fmt.Errorf("unsafe path segment %q", part)
- }
- if i == len(parts)-1 {
- ext := strings.ToLower(path.Ext(part))
- if _, ok := allowedExtensions[ext]; !ok {
- return "", fmt.Errorf("unsupported media extension %q", ext)
- }
- if linkExtension != "" {
- if appendLinkExtension {
- part += linkExtension
- } else {
- part = strings.TrimSuffix(part, path.Ext(part)) + linkExtension
- }
- }
- }
- safe := sanitizeFilename(part)
- if safe == "" {
- return "", errors.New("empty sanitized path segment")
- }
- out = append(out, safe)
- }
- return filepath.Join(out...), nil
-}
-
-func strmTreeSubtitleMatchesVideo(subtitle strmTreeSource, videos []strmTreeSource) bool {
- subDir, subBase := strmTreeDirAndBase(subtitle.Path)
- if subBase == "" {
- return false
- }
- for _, video := range videos {
- if video.Kind != "" && video.Kind != strmTreeSourceKindVideo {
- continue
- }
- if !strings.EqualFold(subtitle.Provider, video.Provider) {
- continue
- }
- videoDir, videoBase := strmTreeDirAndBase(video.Path)
- if !strings.EqualFold(subDir, videoDir) || videoBase == "" {
- continue
- }
- if strings.EqualFold(subBase, videoBase) || strings.HasPrefix(strings.ToLower(subBase), strings.ToLower(videoBase)+".") {
- return true
- }
- }
- return false
-}
-
-func strmTreeDirAndBase(source string) (string, string) {
- source = normalizeSTRMTreeSource(source)
- dir := path.Dir(source)
- name := path.Base(source)
- base := strings.TrimSuffix(name, path.Ext(name))
- return strings.ToLower(strings.Trim(dir, "/")), strings.ToLower(base)
-}
-
-func strmTreeOutputPrefixPath(prefix string) (string, error) {
- parts := strings.Split(strings.Trim(strings.ReplaceAll(prefix, "\\", "/"), "/"), "/")
- out := make([]string, 0, len(parts))
- for _, part := range parts {
- part = strings.TrimSpace(part)
- if part == "" {
- continue
- }
- if part == "." || part == ".." {
- return "", fmt.Errorf("unsafe output prefix segment %q", part)
- }
- safe := sanitizeFilename(part)
- if safe == "" {
- return "", errors.New("empty sanitized output prefix segment")
- }
- out = append(out, safe)
- }
- return filepath.Join(out...), nil
-}
diff --git a/internal/service/strm_tree_url_test.go b/internal/service/strm_tree_url_test.go
deleted file mode 100644
index 2690e97..0000000
--- a/internal/service/strm_tree_url_test.go
+++ /dev/null
@@ -1,312 +0,0 @@
-package service
-
-import (
- "net/url"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestGenerateSTRMFromTreeUsesMediaPathFromURLQuery(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- Paths: []string{
- "https://openlist.example.com/api/fs/get?path=%2FMovies%2FDune.Part.Two.2024.mkv",
- "https://openlist.example.com/api/fs/get?path=%2FMovies%2FA%2BB.2026.mkv",
- "https://openlist.example.com/api/fs/get?path=/Movies/A+B.Raw.2026.mkv",
- "https://openlist.example.com/api/raw?ref=/Shows/Some.Show/S01E01.mp4",
- "https://cdn.example.com/media/Movies/Direct.Movie.2026.mkv?token=secret",
- "https://cdn.example.com/media/Movies/A+B.Direct.2026.mkv?token=secret",
- "https://openlist.example.com/api/fs/get?id=12345",
- },
- OutputDir: outDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 6 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want six generated videos and non-media API URL ignored", res)
- }
- movie := readSTRM(t, filepath.Join(outDir, "Movies", "Dune.Part.Two.2024.strm"))
- if !strings.Contains(movie, "ref=%2FMovies%2FDune.Part.Two.2024.mkv") || strings.Contains(movie, "api%2Ffs%2Fget") {
- t.Fatalf("movie strm url = %q, want query media path as cloud ref", movie)
- }
- plusMovie := readSTRM(t, filepath.Join(outDir, "Movies", "A+B.2026.strm"))
- if !strings.Contains(plusMovie, "ref=%2FMovies%2FA%2BB.2026.mkv") || strings.Contains(plusMovie, "A+B.2026.mkv") {
- t.Fatalf("plus movie strm url = %q, want literal plus preserved and encoded in ref", plusMovie)
- }
- rawPlusMovie := readSTRM(t, filepath.Join(outDir, "Movies", "A+B.Raw.2026.strm"))
- if !strings.Contains(rawPlusMovie, "ref=%2FMovies%2FA%2BB.Raw.2026.mkv") || strings.Contains(rawPlusMovie, "A+B.Raw.2026.mkv") {
- t.Fatalf("raw plus movie strm url = %q, want raw query plus preserved and encoded in ref", rawPlusMovie)
- }
- show := readSTRM(t, filepath.Join(outDir, "Shows", "Some.Show", "S01E01.strm"))
- if !strings.Contains(show, "ref=%2FShows%2FSome.Show%2FS01E01.mp4") {
- t.Fatalf("show strm url = %q, want ref query media path", show)
- }
- direct := readSTRM(t, filepath.Join(outDir, "media", "Movies", "Direct.Movie.2026.strm"))
- if !strings.Contains(direct, "ref=%2Fmedia%2FMovies%2FDirect.Movie.2026.mkv") {
- t.Fatalf("direct url strm = %q, want normal URL path media ref", direct)
- }
- plusDirect := readSTRM(t, filepath.Join(outDir, "media", "Movies", "A+B.Direct.2026.strm"))
- if !strings.Contains(plusDirect, "ref=%2Fmedia%2FMovies%2FA%2BB.Direct.2026.mkv") {
- t.Fatalf("plus direct url strm = %q, want URL path plus preserved", plusDirect)
- }
- if _, err := os.Stat(filepath.Join(outDir, "api", "fs", "get.strm")); !os.IsNotExist(err) {
- t.Fatalf("non-media API URL should not generate STRM, stat err=%v", err)
- }
-}
-
-func TestGenerateSTRMFromTreePreservesSourceProvider(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "115",
- Paths: []string{
- "cloud://openlist/Movies/OpenList.Movie.2026.mkv",
- "/api/cloud/play/cloud115?ref=%2FShows%2FCloud115.Show.S01E01.mkv",
- "/api/cloud/play/openlist?ref=%2FMovies%2FMy+Space.Movie.2026.mkv",
- "https://media.example.com/api/cloud/play/openlist?ref=%2FMovies%2FRemote.OpenList.Movie.2026.mkv",
- "/Movies/Fallback.115.Movie.2026.mkv",
- },
- OutputDir: outDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 5 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want five generated videos", res)
- }
- openlist := readSTRM(t, filepath.Join(outDir, "Movies", "OpenList.Movie.2026.strm"))
- if !strings.Contains(openlist, "/api/cloud/play/openlist?") || !strings.Contains(openlist, "ref=%2FMovies%2FOpenList.Movie.2026.mkv") {
- t.Fatalf("cloud:// source url = %q, want openlist provider and original ref", openlist)
- }
- cloud115 := readSTRM(t, filepath.Join(outDir, "Shows", "Cloud115.Show.S01E01.strm"))
- if !strings.Contains(cloud115, "/api/cloud/play/cloud115?") || !strings.Contains(cloud115, "ref=%2FShows%2FCloud115.Show.S01E01.mkv") {
- t.Fatalf("cloud play source url = %q, want cloud115 provider preserved", cloud115)
- }
- spaceMoviePath := filepath.Join(outDir, "Movies", "My Space.Movie.2026.strm")
- spaceMovie := readSTRM(t, spaceMoviePath)
- if _, err := os.Stat(filepath.Join(outDir, "Movies", "My+Space.Movie.2026.strm")); !os.IsNotExist(err) {
- t.Fatalf("cloud play source should not create literal-plus local path, stat err=%v", err)
- }
- spaceURL, err := url.Parse(spaceMovie)
- if err != nil {
- t.Fatalf("parse cloud play source url %q: %v", spaceMovie, err)
- }
- if got := spaceURL.Query().Get("ref"); got != "/Movies/My Space.Movie.2026.mkv" {
- t.Fatalf("cloud play source ref = %q, want decoded space path", got)
- }
- remoteOpenlist := readSTRM(t, filepath.Join(outDir, "Movies", "Remote.OpenList.Movie.2026.strm"))
- if !strings.Contains(remoteOpenlist, "/api/cloud/play/openlist?") || !strings.Contains(remoteOpenlist, "ref=%2FMovies%2FRemote.OpenList.Movie.2026.mkv") {
- t.Fatalf("absolute cloud play source url = %q, want openlist provider preserved", remoteOpenlist)
- }
- fallback := readSTRM(t, filepath.Join(outDir, "Movies", "Fallback.115.Movie.2026.strm"))
- if !strings.Contains(fallback, "/api/cloud/play/cloud115?") || !strings.Contains(fallback, "ref=%2FMovies%2FFallback.115.Movie.2026.mkv") {
- t.Fatalf("plain source url = %q, want fallback cloud115 provider", fallback)
- }
-}
-
-func TestGenerateSTRMFromTreeCloudMountUsesScanDirForRef(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "115",
- Paths: []string{
- "cloud://openlist/%E7%94%B5%E5%BD%B1/%E5%88%AB%E5%90%8D%E7%9B%AE%E5%BD%95/Alias.Movie.2026.mkv?dir=%2Factual%2Fcloud%2Fmovies%2FAlias.Movie.2026.mkv",
- },
- OutputDir: outDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 1 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want one generated video", res)
- }
- got := readSTRM(t, filepath.Join(outDir, "电影", "别名目录", "Alias.Movie.2026.strm"))
- if !strings.Contains(got, "/api/cloud/play/openlist?") {
- t.Fatalf("strm url = %q, want provider from cloud mount", got)
- }
- if !strings.Contains(got, "ref=%2Factual%2Fcloud%2Fmovies%2FAlias.Movie.2026.mkv") {
- t.Fatalf("strm url = %q, want dir scan path as playable ref", got)
- }
- if strings.Contains(got, "%E5%88%AB%E5%90%8D%E7%9B%AE%E5%BD%95") {
- t.Fatalf("strm url = %q, display path leaked into playable ref", got)
- }
-}
-
-func TestGenerateSTRMFromTreeDoesNotDedupeDifferentProviders(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "115",
- Paths: []string{"cloud://openlist/Movies/Same.Movie.mkv", "cloud://cloud115/Movies/Same.Movie.mkv"},
- OutputDir: outDir,
- Overwrite: true,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 1 || res.Updated != 1 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want both provider-specific sources processed", res)
- }
- got := readSTRM(t, filepath.Join(outDir, "Movies", "Same.Movie.strm"))
- if !strings.Contains(got, "/api/cloud/play/cloud115?") || !strings.Contains(got, "ref=%2FMovies%2FSame.Movie.mkv") {
- t.Fatalf("final strm url = %q, want second provider write to prove it was not deduped", got)
- }
-}
-
-func TestGenerateSTRMFromTreeMissingOnlySkipsMediaAlreadyInLibrary(t *testing.T) {
- db := newServiceTestDB(t, &model.Media{})
- repos := repository.New(db)
- rows := []model.Media{
- {
- Base: model.Base{ID: "existing-strm"},
- Title: "Existing STRM",
- Path: "cloud://openlist/Movies/Existing.Path.Copy.mkv",
- STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FExisting.Movie.mkv",
- },
- {
- Base: model.Base{ID: "existing-cloud-path"},
- Title: "Existing Cloud Path",
- Path: "cloud://cloud115/Shows/Existing.Show.S01E01.mkv",
- },
- {
- Base: model.Base{ID: "different-provider"},
- Title: "Different Provider",
- Path: "cloud://cloud115/Shows/Same.Provider.mkv",
- },
- }
- for i := range rows {
- if err := repos.DB.Create(&rows[i]).Error; err != nil {
- t.Fatal(err)
- }
- }
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), repos, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- Paths: []string{
- "cloud://openlist/Movies/Existing.Movie.mkv",
- "cloud://openlist/Movies/New.Movie.mkv",
- "cloud://cloud115/Shows/Existing.Show.S01E01.mkv",
- "cloud://openlist/Shows/Same.Provider.mkv",
- },
- OutputDir: outDir,
- MissingOnly: true,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 2 || res.Skipped != 2 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want generated=2 skipped=2", res)
- }
- if _, err := os.Stat(filepath.Join(outDir, "Movies", "Existing.Movie.strm")); !os.IsNotExist(err) {
- t.Fatalf("existing media should not generate STRM, stat err=%v", err)
- }
- assertFileContains(t, filepath.Join(outDir, "Movies", "New.Movie.strm"), "/api/cloud/play/openlist?ref=%2FMovies%2FNew.Movie.mkv")
- assertFileContains(t, filepath.Join(outDir, "Shows", "Same.Provider.strm"), "/api/cloud/play/openlist?ref=%2FShows%2FSame.Provider.mkv")
- for _, item := range res.Items {
- if item.Title == "Existing.Movie" && item.Reason != "already in media library" {
- t.Fatalf("existing media skip reason = %q", item.Reason)
- }
- }
-}
-
-func TestGenerateSTRMFromTreeStripsPathListPrefixes(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
- tree := strings.Join([]string{
- "- /Movies/Dune.Part.Two.2024.mkv",
- "* https://openlist.example.com/api/fs/get?path=%2FShows%2FSome.Show%2FS01E01.mp4",
- "1. /Anime/Frieren/Frieren.S01E01.mp4",
- "2) /Anime/Frieren/Frieren.S01E02.mp4",
- "• /Documentaries/Earth.2026.mkv",
- }, "\n")
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- TreeText: tree,
- OutputDir: outDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 5 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want five generated videos with list prefixes stripped", res)
- }
- moviePath := filepath.Join(outDir, "Movies", "Dune.Part.Two.2024.strm")
- movie := readSTRM(t, moviePath)
- if strings.Contains(moviePath, "- ") || !strings.Contains(movie, "ref=%2FMovies%2FDune.Part.Two.2024.mkv") {
- t.Fatalf("markdown bullet prefix leaked into movie path/ref: path=%q url=%q", moviePath, movie)
- }
- show := readSTRM(t, filepath.Join(outDir, "Shows", "Some.Show", "S01E01.strm"))
- if strings.Contains(show, "api%2Ffs%2Fget") || !strings.Contains(show, "ref=%2FShows%2FSome.Show%2FS01E01.mp4") {
- t.Fatalf("bullet URL query source was not cleaned: %q", show)
- }
- if _, err := os.Stat(filepath.Join(outDir, "1. ", "Anime", "Frieren", "Frieren.S01E01.strm")); !os.IsNotExist(err) {
- t.Fatalf("numbered prefix should not create a literal prefix directory, stat err=%v", err)
- }
- if got := readSTRM(t, filepath.Join(outDir, "Documentaries", "Earth.2026.strm")); !strings.Contains(got, "ref=%2FDocumentaries%2FEarth.2026.mkv") {
- t.Fatalf("round bullet source ref = %q", got)
- }
-}
-
-func TestGenerateSTRMFromTreeOutputPrefixOnlyAffectsLocalPath(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- Paths: []string{"/cloud/Dune.Part.Two.2024.mkv"},
- SourceRoot: "/cloud",
- OutputPrefix: "电影/欧美电影",
- OutputDir: outDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 1 || len(res.Errors) != 0 {
- t.Fatalf("result = %#v, want one generated video", res)
- }
- got := readSTRM(t, filepath.Join(outDir, "电影", "欧美电影", "Dune.Part.Two.2024.strm"))
- if strings.Contains(got, "%E7%94%B5%E5%BD%B1") || strings.Contains(got, "%E6%AC%A7%E7%BE%8E%E7%94%B5%E5%BD%B1") {
- t.Fatalf("strm url = %q, output prefix should not be injected into cloud ref", got)
- }
- if !strings.Contains(got, "ref=%2Fcloud%2FDune.Part.Two.2024.mkv") {
- t.Fatalf("strm url = %q, want original cloud ref", got)
- }
-}
-
-func TestGenerateSTRMFromTreeRejectsUnsafeOutputPrefix(t *testing.T) {
- outDir := filepath.Join(t.TempDir(), "strm")
- svc := NewSTRMService(zap.NewNop(), nil, nil)
-
- res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{
- Provider: "openlist",
- Paths: []string{"Movies/Movie.mkv"},
- OutputPrefix: "../escape",
- OutputDir: outDir,
- })
- if err != nil {
- t.Fatal(err)
- }
- if res.Generated != 0 || len(res.Errors) != 1 {
- t.Fatalf("result = %#v, want unsafe prefix rejected", res)
- }
- if _, err := os.Stat(filepath.Join(outDir, "..", "escape", "Movies", "Movie.strm")); !os.IsNotExist(err) {
- t.Fatalf("unsafe prefixed strm should not be written, stat err=%v", err)
- }
-}
diff --git a/internal/service/strm_url.go b/internal/service/strm_url.go
deleted file mode 100644
index 6481d0f..0000000
--- a/internal/service/strm_url.go
+++ /dev/null
@@ -1,196 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "net/url"
- "path/filepath"
- "strconv"
- "strings"
- "time"
-
- "github.com/golang-jwt/jwt/v5"
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *STRMService) strmPlaybackURL(ctx context.Context, media model.Media, baseURL, playbackToken string) string {
- if media.ID == "" {
- return ""
- }
- query := url.Values{}
- token := strings.TrimSpace(playbackToken)
- if token == "" {
- token = s.defaultSTRMPlaybackToken(ctx)
- }
- if token != "" {
- query.Set("token", token)
- }
- return buildAbsoluteSTRMAPIURL(firstNonEmpty(baseURL, PublicServerURL(ctx, s.repo, s.cfg)), "/api/stream/"+url.PathEscape(media.ID), query)
-}
-
-func (s *STRMService) defaultSTRMPlaybackToken(ctx context.Context) string {
- if s == nil || s.repo == nil || s.repo.User == nil || s.cfg == nil || strings.TrimSpace(s.cfg.Secrets.JWTSecret) == "" {
- return ""
- }
- admin, err := s.repo.User.FirstAdmin(ctx)
- if err != nil || admin == nil {
- if err != nil && s.log != nil {
- s.log.Warn("generate strm playback token failed", zap.Error(err))
- }
- return ""
- }
- token, err := signSTRMPlaybackToken(admin, s.cfg.Secrets.JWTSecret)
- if err != nil {
- if s.log != nil {
- s.log.Warn("sign strm playback token failed", zap.Error(err))
- }
- return ""
- }
- return token
-}
-
-func signSTRMPlaybackToken(u *model.User, secret string) (string, error) {
- if u == nil || strings.TrimSpace(u.ID) == "" || strings.TrimSpace(secret) == "" {
- return "", ErrSTRMURLInvalid
- }
- claims := Claims{
- UserID: u.ID,
- Role: u.Role,
- Tier: u.Tier,
- RegisteredClaims: jwt.RegisteredClaims{
- IssuedAt: jwt.NewNumericDate(time.Now()),
- ExpiresAt: jwt.NewNumericDate(time.Now().Add(EmbyTokenDuration)),
- Issuer: "mediastationgo",
- Subject: u.ID,
- },
- }
- t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
- return t.SignedString([]byte(secret))
-}
-
-func (s *STRMService) strmRelativePath(lib model.Library, media model.Media) string {
- title := strings.TrimSpace(media.Title)
- if title == "" {
- title = strings.TrimSuffix(filepath.Base(media.Path), filepath.Ext(media.Path))
- }
- if title == "" {
- return ""
- }
- seriesLike := isSeriesLibraryType(lib.Type) || media.SeasonNum > 0 || media.EpisodeNum > 0
- if seriesLike {
- show := inferSeriesNameFromPath(media.Path)
- if show == "" {
- show = title
- }
- season := media.SeasonNum
- episode := media.EpisodeNum
- if season <= 0 || episode <= 0 {
- parsedSeason, parsedEpisode := ParseEpisode(media.Path)
- if season <= 0 {
- season = parsedSeason
- }
- if episode <= 0 {
- episode = parsedEpisode
- }
- }
- if season <= 0 {
- season = 1
- }
- name := strings.TrimSuffix(filepath.Base(media.Path), filepath.Ext(media.Path))
- if episode > 0 {
- name = fmt.Sprintf("%s - S%02dE%02d", show, season, episode)
- } else if strings.TrimSpace(name) == "" {
- name = title
- }
- return filepath.Join(sanitizeFilename(show), fmt.Sprintf("Season %02d", season), sanitizeFilename(name)+".strm")
- }
- folder := title
- if media.Year > 0 && !strings.Contains(folder, strconv.Itoa(media.Year)) {
- folder = fmt.Sprintf("%s (%d)", folder, media.Year)
- }
- safe := sanitizeFilename(folder)
- return filepath.Join(safe, safe+".strm")
-}
-
-func (s *STRMService) strmTreeRelativePath(media model.Media) string {
- parts := strmLibraryPathParts(media.Path)
- if len(parts) == 0 {
- return ""
- }
- parts = strmDropCategoryPrefix(parts)
- if len(parts) == 0 {
- return ""
- }
- last := parts[len(parts)-1]
- ext := filepath.Ext(last)
- if ext == "" {
- return ""
- }
- parts[len(parts)-1] = strings.TrimSuffix(last, ext) + ".strm"
- clean := make([]string, 0, len(parts))
- for _, part := range parts {
- if safe := sanitizeFilename(part); safe != "" {
- clean = append(clean, safe)
- }
- }
- if len(clean) == 0 {
- return ""
- }
- return filepath.Join(clean...)
-}
-
-func strmDropCategoryPrefix(parts []string) []string {
- if len(parts) == 0 {
- return nil
- }
- for i, part := range parts {
- if strmCanonicalRoot(part) == "" && strmCategoryRoot(part) == "" {
- continue
- }
- next := i + 1
- if strmCanonicalRoot(part) != "" && next < len(parts) && strmCategoryRoot(parts[next]) != "" {
- next++
- }
- if next < len(parts) {
- return append([]string(nil), parts[next:]...)
- }
- }
- return append([]string(nil), parts...)
-}
-
-func absolutizeSTRMURL(raw, baseURL string) string {
- raw = strings.TrimSpace(raw)
- if raw == "" || strings.HasPrefix(raw, "//") {
- return raw
- }
- u, err := url.Parse(raw)
- if err == nil && u.IsAbs() {
- return raw
- }
- return buildAbsoluteSTRMAPIURL(baseURL, raw, nil)
-}
-
-func buildAbsoluteSTRMAPIURL(baseURL, apiPath string, query url.Values) string {
- apiPath = "/" + strings.TrimLeft(strings.TrimSpace(apiPath), "/")
- if query != nil && len(query) > 0 {
- apiPath += "?" + query.Encode()
- }
- baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
- if baseURL == "" {
- return apiPath
- }
- base, err := url.Parse(baseURL)
- if err != nil || base.Scheme == "" || base.Host == "" {
- return apiPath
- }
- target, err := url.Parse(apiPath)
- if err != nil {
- return apiPath
- }
- base.Path = strings.TrimRight(base.Path, "/") + "/" + strings.TrimLeft(target.Path, "/")
- base.RawQuery = target.RawQuery
- base.Fragment = ""
- return base.String()
-}
diff --git a/internal/service/subscription.go b/internal/service/subscription.go
deleted file mode 100644
index f4e788e..0000000
--- a/internal/service/subscription.go
+++ /dev/null
@@ -1,274 +0,0 @@
-// Package service — RSS subscriptions for automated downloads.
-//
-// SubscriptionService periodically polls every Subscription row, fetches
-// the configured RSS / Atom feed, and queues new items into the
-// DownloadService. Items are deduplicated by GUID stored as a Setting key
-// "subscription..last_guid" so the same episode is never re-queued.
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "strconv"
- "strings"
- "sync"
- "time"
-
- "go.uber.org/zap"
- "gorm.io/gorm"
-
- "github.com/ShukeBta/MediaStationGo/internal/config"
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-// SubscriptionService runs the polling loop.
-type SubscriptionService struct {
- cfg *config.Config
- log *zap.Logger
- repo *repository.Container
- downloads *DownloadService
- site *SiteService
- scraper *ScraperService
- hub *Hub
- notify *NotifyChannelService
- mu sync.Mutex
- stop chan struct{}
- running bool
-}
-
-const (
- defaultSubscriptionPollInterval = 3 * time.Hour
- minSubscriptionPollInterval = 3 * time.Hour
- subscriptionStartupDelay = defaultSubscriptionPollInterval
-)
-
-// NewSubscriptionService is the constructor.
-func NewSubscriptionService(cfg *config.Config, log *zap.Logger, repo *repository.Container, downloads *DownloadService, site *SiteService, hub *Hub) *SubscriptionService {
- return &SubscriptionService{
- cfg: cfg,
- log: log,
- repo: repo,
- downloads: downloads,
- site: site,
- hub: hub,
- }
-}
-
-func (s *SubscriptionService) SetScraper(scraper *ScraperService) {
- s.scraper = scraper
-}
-
-func (s *SubscriptionService) SetNotifyChannels(notify *NotifyChannelService) {
- s.notify = notify
-}
-
-// Start runs the polling loop in the background.
-func (s *SubscriptionService) Start(ctx context.Context) {
- s.mu.Lock()
- if s.running {
- s.mu.Unlock()
- return
- }
- stop := make(chan struct{})
- s.stop = stop
- s.running = true
- s.mu.Unlock()
- go s.loop(ctx, stop)
-}
-
-// Stop shuts the loop down.
-func (s *SubscriptionService) Stop() {
- s.mu.Lock()
- if !s.running {
- s.mu.Unlock()
- return
- }
- stop := s.stop
- s.stop = nil
- s.running = false
- s.mu.Unlock()
- close(stop)
-}
-
-// Create persists a new subscription.
-func (s *SubscriptionService) Create(ctx context.Context, sub *model.Subscription) error {
- if sub.Name == "" || sub.FeedURL == "" {
- return errors.New("name and feed_url required")
- }
- normalizeSubscriptionDefaults(sub)
- model.RefreshSubscriptionIdentity(sub)
- if duplicate, err := s.subscriptionDuplicate(ctx, sub, ""); err != nil {
- return err
- } else if duplicate != nil {
- return newSubscriptionAlreadyExistsError(duplicate.ID)
- }
- enabled := sub.Enabled
- if err := s.repo.Subscription.Create(ctx, sub); err != nil {
- if duplicate, lookupErr := s.subscriptionDuplicate(ctx, sub, ""); lookupErr == nil && duplicate != nil {
- return newSubscriptionAlreadyExistsError(duplicate.ID)
- }
- return err
- }
- if !enabled {
- if err := s.repo.DB.WithContext(ctx).Model(sub).Update("enabled", false).Error; err != nil {
- return err
- }
- sub.Enabled = false
- }
- return nil
-}
-
-func normalizeSubscriptionDefaults(sub *model.Subscription) {
- if strings.TrimSpace(sub.SearchMode) == "" {
- sub.SearchMode = "keyword"
- }
- if strings.TrimSpace(sub.Resolution) == "" {
- sub.Resolution = "best"
- }
- if strings.TrimSpace(sub.WashPriority) == "" {
- sub.WashPriority = "balanced"
- }
- if sub.Priority == 0 {
- sub.Priority = 50
- }
-}
-
-// List returns every subscription rule.
-func (s *SubscriptionService) List(ctx context.Context) ([]model.Subscription, error) {
- return s.repo.Subscription.List(ctx)
-}
-
-// Delete removes a subscription.
-func (s *SubscriptionService) Delete(ctx context.Context, id string) error {
- var sub model.Subscription
- if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&sub).Error; err != nil {
- if !errors.Is(err, gorm.ErrRecordNotFound) {
- return err
- }
- if err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).First(&sub).Error; err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return nil
- }
- return err
- }
- }
- if err := s.deleteSubscriptionDownloads(ctx, &sub); err != nil {
- return err
- }
- if s.repo.Setting != nil {
- _ = s.repo.Setting.Delete(ctx, fmt.Sprintf("subscription.%s.seen", id))
- }
- return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
- if err := tx.Unscoped().Model(&model.Subscription{}).Where("id = ?", id).Update("enabled", false).Error; err != nil {
- return err
- }
- if sub.DeletedAt.Valid {
- return nil
- }
- return tx.Where("id = ?", id).Delete(&model.Subscription{}).Error
- })
-}
-
-// RunNow forces a poll for one subscription, ignoring its schedule. Used
-// by the admin UI's "test now" button.
-func (s *SubscriptionService) RunNow(ctx context.Context, id string) (int, error) {
- var sub model.Subscription
- if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&sub).Error; err != nil {
- return 0, err
- }
- if sub.ArchivedAt != nil {
- if s.log != nil {
- s.log.Info("subscription run skipped because it is archived",
- zap.String("subscription_id", sub.ID),
- zap.String("subscription", sub.Name),
- zap.String("archive_reason", sub.ArchiveReason))
- }
- return 0, nil
- }
- return s.runOne(ctx, &sub)
-}
-
-// loop polls subscription feeds and site-search subscriptions at a conservative
-// cadence so tracker APIs are not hammered by every alias keyword.
-func (s *SubscriptionService) loop(ctx context.Context, stop <-chan struct{}) {
- defer s.markLoopStopped(stop)
- interval := s.pollInterval(ctx)
- delay := subscriptionStartupDelay
- if interval < delay {
- delay = interval
- }
- for {
- timer := time.NewTimer(delay)
- select {
- case <-ctx.Done():
- timer.Stop()
- return
- case <-stop:
- timer.Stop()
- return
- case <-timer.C:
- }
- s.runAll(ctx)
- // Re-read after every run so changes from the settings page take effect
- // without restarting the service.
- delay = s.pollInterval(ctx)
- }
-}
-
-func (s *SubscriptionService) markLoopStopped(stop <-chan struct{}) {
- s.mu.Lock()
- defer s.mu.Unlock()
- if s.stop == stop {
- s.stop = nil
- s.running = false
- }
-}
-
-func (s *SubscriptionService) pollInterval(ctx context.Context) time.Duration {
- if s == nil || s.repo == nil || s.repo.Setting == nil {
- return defaultSubscriptionPollInterval
- }
- raw, err := s.repo.Setting.Get(ctx, "subscription.interval_seconds")
- if err != nil {
- return defaultSubscriptionPollInterval
- }
- seconds, err := strconv.Atoi(strings.TrimSpace(raw))
- if err != nil || seconds <= 0 {
- return defaultSubscriptionPollInterval
- }
- interval := time.Duration(seconds) * time.Second
- if interval < minSubscriptionPollInterval {
- return minSubscriptionPollInterval
- }
- return interval
-}
-
-func (s *SubscriptionService) runAll(ctx context.Context) {
- subs, err := s.repo.Subscription.List(ctx)
- if err != nil {
- s.log.Warn("subscription list failed", zap.Error(err))
- return
- }
- if s.log != nil {
- s.log.Info("subscription sweep started", zap.Int("count", len(subs)))
- }
- for i := range subs {
- if !subs[i].Enabled {
- continue
- }
- if n, err := s.runOne(ctx, &subs[i]); err != nil {
- s.log.Warn("subscription run failed",
- zap.String("name", subs[i].Name), zap.Error(err))
- if subscriptionSiteSearchShouldStopOnError(err) {
- s.log.Warn("subscription sweep stopped after upstream failure",
- zap.String("name", subs[i].Name), zap.Error(err))
- return
- }
- } else if n > 0 {
- s.log.Info("subscription queued items",
- zap.String("name", subs[i].Name), zap.Int("count", n))
- }
- }
-}
diff --git a/internal/service/subscription_archive.go b/internal/service/subscription_archive.go
deleted file mode 100644
index 76b8a29..0000000
--- a/internal/service/subscription_archive.go
+++ /dev/null
@@ -1,173 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-// History returns completed/archived subscription rules.
-func (s *SubscriptionService) History(ctx context.Context) ([]model.Subscription, error) {
- return s.repo.Subscription.History(ctx)
-}
-
-// Restore moves an archived subscription back to the active management list.
-// It also clears the per-subscription seen state so an unfinished historical
-// rule can match resources again when it is run next.
-func (s *SubscriptionService) Restore(ctx context.Context, id string) (*model.Subscription, error) {
- var sub model.Subscription
- if err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).First(&sub).Error; err != nil {
- return nil, err
- }
- sub.Enabled = true
- sub.ArchivedAt = nil
- sub.ArchiveReason = ""
- sub.DeletedAt.Valid = false
- sub.TotalEpisodes = 0
- normalizeSubscriptionDefaults(&sub)
- model.RefreshSubscriptionIdentity(&sub)
- if duplicate, err := s.subscriptionDuplicate(ctx, &sub, sub.ID); err != nil {
- return nil, err
- } else if duplicate != nil {
- return nil, newSubscriptionAlreadyExistsError(duplicate.ID)
- }
- if err := s.repo.DB.WithContext(ctx).Unscoped().Model(&model.Subscription{}).
- Where("id = ?", id).
- Updates(map[string]any{
- "enabled": true,
- "archived_at": nil,
- "archive_reason": "",
- "deleted_at": nil,
- "identity_key": sub.IdentityKey,
- // 重置为 0:此前可能被 feed 低估并锁死(updateSubscriptionTotalEpisodes
- // 只增不减,resolveSubscriptionTotalEpisodes 见 >0 即不再回查元数据)。
- // 归零后下次 run 会从 TMDb/豆瓣等权威源重算真实总集数,避免恢复后
- // 因"误判已无缺集"而不再搜索资源。
- "total_episodes": 0,
- }).Error; err != nil {
- if duplicate, lookupErr := s.subscriptionDuplicate(ctx, &sub, sub.ID); lookupErr == nil && duplicate != nil {
- return nil, newSubscriptionAlreadyExistsError(duplicate.ID)
- }
- return nil, err
- }
- if s.repo.Setting != nil {
- _ = s.repo.Setting.Delete(ctx, fmt.Sprintf("subscription.%s.seen", id))
- }
- var restored model.Subscription
- if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&restored).Error; err != nil {
- return nil, err
- }
- return &restored, nil
-}
-
-func (s *SubscriptionService) archiveCompletedSubscription(ctx context.Context, sub *model.Subscription, availability LocalAvailability) error {
- if s == nil || s.repo == nil || s.repo.Subscription == nil || sub == nil {
- return nil
- }
- if !subscriptionShouldArchive(sub, availability) {
- return nil
- }
- now := time.Now()
- reason := subscriptionArchiveReason(sub, availability)
- if err := s.repo.Subscription.Archive(ctx, sub.ID, reason, now); err != nil {
- return err
- }
- sub.Enabled = false
- sub.ArchivedAt = &now
- sub.ArchiveReason = reason
- if s.log != nil {
- s.log.Info("subscription completed, moved to history",
- zap.String("id", sub.ID),
- zap.String("name", sub.Name),
- zap.String("reason", reason))
- }
- if s.hub != nil {
- s.hub.Publish("subscription", map[string]any{
- "id": sub.ID,
- "name": sub.Name,
- "archived": true,
- "reason": reason,
- })
- }
- return nil
-}
-
-func subscriptionShouldArchive(sub *model.Subscription, availability LocalAvailability) bool {
- if sub == nil || subscriptionAllowsWash(sub) || sub.ArchivedAt != nil {
- return false
- }
- mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "")
- seriesLike := isSubscriptionSeriesType(mediaType) || len(availability.ExistingEpisodeKeys) > 0 || len(availability.MissingEpisodeKeys) > 0
- if !seriesLike {
- return availability.InLibrary || availability.LocalMediaCount > 0 || availability.DownloadedEpisodes > 0
- }
- total := trustedSeriesArchiveTotal(sub, availability)
- if availability.HasSeriesPack {
- if len(availability.ExistingEpisodeKeys) == 0 {
- return true
- }
- return total > 0 && availability.DownloadedEpisodes >= total && len(availability.MissingEpisodes) == 0
- }
- if total > 0 {
- return availability.DownloadedEpisodes >= total && len(availability.MissingEpisodes) == 0
- }
- return subscriptionLooksSingleEpisode(sub) && availability.DownloadedEpisodes > 0
-}
-
-func trustedSeriesArchiveTotal(sub *model.Subscription, availability LocalAvailability) int {
- total := 0
- if sub != nil {
- total = sub.TotalEpisodes
- }
- if total <= 0 {
- total = availability.TotalEpisodes
- }
- if maxEpisode := maxAvailabilityEpisode(availability.ExistingEpisodeKeys); total > 0 && maxEpisode > total {
- return 0
- }
- return total
-}
-
-func maxAvailabilityEpisode(keys map[string]struct{}) int {
- maxEpisode := 0
- for key := range keys {
- var season, episode int
- if _, err := fmt.Sscanf(key, "%02dE%03d", &season, &episode); err == nil && episode > maxEpisode {
- maxEpisode = episode
- }
- }
- return maxEpisode
-}
-
-func subscriptionArchiveReason(sub *model.Subscription, availability LocalAvailability) string {
- if subscriptionAllowsWash(sub) {
- return ""
- }
- if availability.HasSeriesPack {
- return "整季资源已加入下载/入库"
- }
- if availability.TotalEpisodes > 0 {
- return fmt.Sprintf("订阅完成:%d/%d", availability.DownloadedEpisodes, availability.TotalEpisodes)
- }
- if availability.DownloadedEpisodes > 0 {
- return "单集订阅已加入下载/入库"
- }
- return "订阅媒体已加入下载/入库"
-}
-
-func subscriptionLooksSingleEpisode(sub *model.Subscription) bool {
- if sub == nil {
- return false
- }
- for _, value := range []string{sub.Name, sub.Filter} {
- _, episode := ParseEpisode(value)
- if episode > 0 {
- return true
- }
- }
- return false
-}
diff --git a/internal/service/subscription_archive_test.go b/internal/service/subscription_archive_test.go
deleted file mode 100644
index b9cef12..0000000
--- a/internal/service/subscription_archive_test.go
+++ /dev/null
@@ -1,318 +0,0 @@
-package service
-
-import (
- "net/http"
- "net/http/httptest"
- "sync/atomic"
- "testing"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestSubscriptionRunOneArchivesCompletedMovieRSS(t *testing.T) {
- rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/rss+xml")
- _, _ = w.Write([]byte(`
-
- -
- Dune 2021 1080p WEB-DL
- dune-1080-web
- magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&dn=Dune+2021+1080p+WEB-DL
-
-`))
- }))
- defer rss.Close()
-
- var addCalls int32
- var added bool
- qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/api/v2/auth/login":
- _, _ = w.Write([]byte("Ok."))
- case "/api/v2/torrents/info":
- if added {
- _, _ = w.Write([]byte(`[{"hash":"dunehash","name":"Dune 2021 1080p WEB-DL","state":"downloading","progress":0.1}]`))
- return
- }
- _, _ = w.Write([]byte(`[]`))
- case "/api/v2/torrents/add":
- added = true
- atomic.AddInt32(&addCalls, 1)
- _, _ = w.Write([]byte("Ok."))
- default:
- http.NotFound(w, r)
- }
- }))
- defer qb.Close()
-
- db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{})
- repos := repository.New(db)
- configureTestDefaultQB(t, repos, qb.URL)
- downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
- svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop()))
-
- sub := &model.Subscription{
- Name: "Dune 自动订阅",
- FeedURL: rss.URL,
- Filter: "Dune 2021",
- MediaType: "movie",
- SavePath: "/downloads/movies",
- }
- if err := repos.Subscription.Create(t.Context(), sub); err != nil {
- t.Fatal(err)
- }
- queued, err := svc.runOne(t.Context(), sub)
- if err != nil {
- t.Fatal(err)
- }
- if queued != 1 {
- t.Fatalf("queued = %d, want 1", queued)
- }
- if got := atomic.LoadInt32(&addCalls); got != 1 {
- t.Fatalf("qb add calls = %d, want 1", got)
- }
- active, err := repos.Subscription.List(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(active) != 0 {
- t.Fatalf("active subscriptions = %d, want 0 after completion", len(active))
- }
- history, err := repos.Subscription.History(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(history) != 1 || history[0].ArchivedAt == nil {
- t.Fatalf("history = %#v, want one archived subscription", history)
- }
-}
-
-func TestSubscriptionArchiveCompletedSingleEpisodeTV(t *testing.T) {
- db := newServiceTestDB(t, &model.Subscription{})
- repos := repository.New(db)
- svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop()))
- sub := &model.Subscription{
- Name: "Some Show S01E01 自动订阅",
- FeedURL: "site-search://search?keyword=Some%20Show%20S01E01",
- Filter: "Some Show S01E01",
- MediaType: "tv",
- Enabled: true,
- }
- if err := repos.Subscription.Create(t.Context(), sub); err != nil {
- t.Fatal(err)
- }
-
- if err := svc.archiveCompletedSubscription(t.Context(), sub, LocalAvailability{
- DownloadedEpisodes: 1,
- LocalMediaCount: 1,
- InLibrary: true,
- ExistingEpisodeKeys: map[string]struct{}{
- episodeKey(1, 1): {},
- },
- }); err != nil {
- t.Fatal(err)
- }
- active, err := repos.Subscription.List(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(active) != 0 {
- t.Fatalf("active subscriptions = %d, want 0", len(active))
- }
- history, err := repos.Subscription.History(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(history) != 1 || history[0].ArchiveReason == "" {
- t.Fatalf("history = %#v, want archived single episode", history)
- }
-}
-
-func TestSubscriptionArchiveKeepsGenericUnknownTotalSeriesActive(t *testing.T) {
- sub := &model.Subscription{
- Name: "Some Show 自动订阅",
- Filter: "Some Show",
- MediaType: "tv",
- }
- availability := LocalAvailability{
- DownloadedEpisodes: 1,
- LocalMediaCount: 1,
- InLibrary: true,
- ExistingEpisodeKeys: map[string]struct{}{
- episodeKey(1, 1): {},
- },
- }
- if subscriptionShouldArchive(sub, availability) {
- t.Fatal("generic series with unknown total should stay active for incremental episodes")
- }
-}
-
-func TestSubscriptionArchiveKeepsPartialSeriesWithParentRowActive(t *testing.T) {
- sub := &model.Subscription{
- Name: "南部档案 自动订阅",
- Filter: "南部档案",
- }
- availability := LocalAvailability{
- DownloadedEpisodes: 6,
- TotalEpisodes: 1,
- LocalMediaCount: 7,
- InLibrary: true,
- HasSeriesPack: true,
- ExistingEpisodeKeys: map[string]struct{}{
- episodeKey(1, 1): {},
- episodeKey(1, 2): {},
- episodeKey(1, 3): {},
- episodeKey(1, 4): {},
- episodeKey(1, 5): {},
- episodeKey(1, 6): {},
- },
- }
- if subscriptionShouldArchive(sub, availability) {
- t.Fatal("partial series with a parent/collection row should stay active")
- }
-}
-
-func TestSubscriptionArchiveKeepsWashSubscriptionActive(t *testing.T) {
- db := newServiceTestDB(t, &model.Subscription{})
- repos := repository.New(db)
- svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop()))
- sub := &model.Subscription{
- Name: "Dune 自动订阅",
- FeedURL: "site-search://search?keyword=Dune",
- Filter: "Dune 2021",
- MediaType: "movie",
- Resolution: "2160p",
- WashEnabled: true,
- Enabled: true,
- }
- if err := repos.Subscription.Create(t.Context(), sub); err != nil {
- t.Fatal(err)
- }
-
- if err := svc.archiveCompletedSubscription(t.Context(), sub, LocalAvailability{
- DownloadedEpisodes: 1,
- LocalMediaCount: 1,
- InLibrary: true,
- }); err != nil {
- t.Fatal(err)
- }
- active, err := repos.Subscription.List(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(active) != 1 {
- t.Fatalf("active subscriptions = %d, want wash subscription to stay active", len(active))
- }
- history, err := repos.Subscription.History(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(history) != 0 {
- t.Fatalf("history subscriptions = %d, want 0", len(history))
- }
-}
-
-func TestRestoreArchivedSubscriptionReturnsToActiveAndClearsSeenState(t *testing.T) {
- db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{})
- repos := repository.New(db)
- svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop()))
- sub := &model.Subscription{
- Name: "南部档案 自动订阅",
- FeedURL: "https://rss.example/feed",
- Filter: "南部档案",
- MediaType: "tv",
- TotalEpisodes: 33,
- }
- if err := repos.Subscription.Create(t.Context(), sub); err != nil {
- t.Fatal(err)
- }
- archivedAt := time.Now()
- if err := repos.Subscription.Archive(t.Context(), sub.ID, "已下载 1/33 集,缺 33 集", archivedAt); err != nil {
- t.Fatal(err)
- }
- if err := repos.Setting.Set(t.Context(), "subscription."+sub.ID+".seen", "old-guid"); err != nil {
- t.Fatal(err)
- }
- restored, err := svc.Restore(t.Context(), sub.ID)
- if err != nil {
- t.Fatal(err)
- }
- if restored.ArchivedAt != nil || restored.ArchiveReason != "" || !restored.Enabled {
- t.Fatalf("restored subscription not active: archived=%v reason=%q enabled=%v", restored.ArchivedAt, restored.ArchiveReason, restored.Enabled)
- }
- if restored.TotalEpisodes != 0 {
- t.Fatalf("restored total_episodes = %d, want 0 so it gets recomputed from authoritative metadata", restored.TotalEpisodes)
- }
- active, err := repos.Subscription.List(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(active) != 1 || active[0].ID != sub.ID {
- t.Fatalf("active subscriptions = %#v, want restored subscription", active)
- }
- history, err := repos.Subscription.History(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(history) != 0 {
- t.Fatalf("history subscriptions = %d, want 0 after restore", len(history))
- }
- seen, err := repos.Setting.Get(t.Context(), "subscription."+sub.ID+".seen")
- if err != nil {
- t.Fatal(err)
- }
- if seen != "" {
- t.Fatalf("seen state = %q, want cleared", seen)
- }
-}
-
-func TestRestoreSoftDeletedArchivedSubscriptionReturnsToActive(t *testing.T) {
- db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{})
- repos := repository.New(db)
- svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop()))
- sub := &model.Subscription{
- Name: "Legacy Hidden History 自动订阅",
- FeedURL: "https://rss.example/feed",
- Filter: "Legacy Hidden History",
- MediaType: "tv",
- TotalEpisodes: 12,
- }
- if err := repos.Subscription.Create(t.Context(), sub); err != nil {
- t.Fatal(err)
- }
- archivedAt := time.Now()
- if err := repos.Subscription.Archive(t.Context(), sub.ID, "订阅完成:12/12", archivedAt); err != nil {
- t.Fatal(err)
- }
- if err := db.Where("id = ?", sub.ID).Delete(&model.Subscription{}).Error; err != nil {
- t.Fatal(err)
- }
-
- restored, err := svc.Restore(t.Context(), sub.ID)
- if err != nil {
- t.Fatal(err)
- }
- if restored.ArchivedAt != nil || restored.ArchiveReason != "" || !restored.Enabled || restored.TotalEpisodes != 0 {
- t.Fatalf("restored subscription not reset: %#v", restored)
- }
- active, err := repos.Subscription.List(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(active) != 1 || active[0].ID != sub.ID {
- t.Fatalf("active subscriptions = %#v, want restored legacy subscription", active)
- }
- var deletedCount int64
- if err := db.Unscoped().Model(&model.Subscription{}).
- Where("id = ? AND deleted_at IS NOT NULL", sub.ID).
- Count(&deletedCount).Error; err != nil {
- t.Fatal(err)
- }
- if deletedCount != 0 {
- t.Fatal("restored subscription kept deleted_at set")
- }
-}
diff --git a/internal/service/subscription_availability.go b/internal/service/subscription_availability.go
deleted file mode 100644
index 90d2e52..0000000
--- a/internal/service/subscription_availability.go
+++ /dev/null
@@ -1,263 +0,0 @@
-// Package service — subscription local and pending-download availability helpers.
-package service
-
-import (
- "context"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *SubscriptionService) pendingDownloadAvailability(ctx context.Context, sub *model.Subscription) LocalAvailability {
- out := LocalAvailability{
- ExistingEpisodeKeys: map[string]struct{}{},
- MissingEpisodeKeys: map[string]struct{}{},
- }
- if sub != nil {
- out.TotalEpisodes = sub.TotalEpisodes
- }
- queries := subscriptionAvailabilityQueries(sub)
- if len(queries) == 0 {
- return s.finalizePendingAvailability(sub, out)
- }
- root := s.subscriptionBaseSavePath(ctx, sub)
- if root != "" {
- _ = scanDownloadPathAny(ctx, root, queries, func(path string, season, episode int) bool {
- out.LocalMediaCount++
- if refs := episodeRefsFromTitle(path); len(refs) > 0 {
- for _, ref := range refs {
- out.ExistingEpisodeKeys[episodeKey(ref.Season, ref.Episode)] = struct{}{}
- }
- } else if episode > 0 {
- out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{}
- }
- return true
- })
- }
- s.addDownloadTaskAvailability(ctx, sub, queries, &out)
- s.addLiveTorrentAvailability(ctx, queries, &out)
- return s.finalizePendingAvailability(sub, out)
-}
-
-func (s *SubscriptionService) EnrichProgress(ctx context.Context, items []model.Subscription) {
- for i := range items {
- availability := mergeLocalAvailability(
- SubscriptionLocalAvailability(ctx, s.repo, &items[i]),
- s.pendingDownloadAvailability(ctx, &items[i]),
- )
- applySubscriptionAvailability(&items[i], availability)
- }
-}
-
-func (s *SubscriptionService) EnrichManagementProgress(ctx context.Context, items []model.Subscription) {
- rows := s.downloadTaskRowsForAvailability(ctx)
- for i := range items {
- availability := mergeLocalAvailability(
- SubscriptionLocalAvailability(ctx, s.repo, &items[i]),
- s.pendingDownloadTaskAvailability(ctx, &items[i], rows, false),
- )
- applySubscriptionAvailability(&items[i], availability)
- }
-}
-
-func applySubscriptionAvailability(sub *model.Subscription, availability LocalAvailability) {
- if sub == nil {
- return
- }
- sub.DownloadedEpisodes = availability.DownloadedEpisodes
- sub.LocalMediaCount = availability.LocalMediaCount
- sub.MissingEpisodes = availability.MissingEpisodes
- sub.InLibrary = availability.InLibrary
- if sub.TotalEpisodes == 0 {
- sub.TotalEpisodes = availability.TotalEpisodes
- }
-}
-
-func (s *SubscriptionService) downloadTaskRowsForAvailability(ctx context.Context) []model.DownloadTask {
- if s == nil || s.repo == nil || s.repo.Download == nil {
- return nil
- }
- rows, err := s.repo.Download.List(ctx)
- if err != nil {
- return nil
- }
- return rows
-}
-
-func (s *SubscriptionService) addDownloadTaskAvailability(ctx context.Context, sub *model.Subscription, queries []string, out *LocalAvailability) {
- rows := s.downloadTaskRowsForAvailability(ctx)
- s.addDownloadTaskRowsAvailability(ctx, sub, queries, rows, true, out)
-}
-
-func (s *SubscriptionService) pendingDownloadTaskAvailability(ctx context.Context, sub *model.Subscription, rows []model.DownloadTask, verifyLive bool) LocalAvailability {
- out := LocalAvailability{
- ExistingEpisodeKeys: map[string]struct{}{},
- MissingEpisodeKeys: map[string]struct{}{},
- }
- if sub != nil {
- out.TotalEpisodes = sub.TotalEpisodes
- }
- queries := subscriptionAvailabilityQueries(sub)
- if len(queries) == 0 {
- return s.finalizePendingAvailability(sub, out)
- }
- s.addDownloadTaskRowsAvailability(ctx, sub, queries, rows, verifyLive, &out)
- return s.finalizePendingAvailability(sub, out)
-}
-
-func (s *SubscriptionService) addDownloadTaskRowsAvailability(ctx context.Context, sub *model.Subscription, queries []string, rows []model.DownloadTask, verifyLive bool, out *LocalAvailability) {
- if out == nil {
- return
- }
- baseSavePath := s.subscriptionBaseSavePath(ctx, sub)
- for _, row := range rows {
- if !downloadTaskBlocksReadd(row.Status) {
- continue
- }
- if verifyLive && !s.downloadTaskCountsAsPending(ctx, row) {
- continue
- }
- linkedToSubscription := sub != nil && strings.TrimSpace(row.SubscriptionID) != "" && row.SubscriptionID == sub.ID
- if !linkedToSubscription && baseSavePath != "" && row.SavePath != "" && !sameOrChildPath(row.SavePath, baseSavePath) && !sameOrChildPath(baseSavePath, row.SavePath) {
- continue
- }
- if linkedToSubscription {
- addTrustedAvailabilityTitle(row.Title, 0, 0, false, out)
- continue
- }
- addAvailabilityTitleAny(row.Title, queries, out)
- }
-}
-
-func (s *SubscriptionService) downloadTaskCountsAsPending(ctx context.Context, row model.DownloadTask) bool {
- if s == nil || s.downloads == nil {
- return true
- }
- return s.downloads.subscriptionDownloadTaskStillLive(ctx, row)
-}
-
-func (s *SubscriptionService) addLiveTorrentAvailability(ctx context.Context, queries []string, out *LocalAvailability) {
- if s == nil || s.downloads == nil || out == nil {
- return
- }
- live, err := s.downloads.listLiveTorrents(ctx, "")
- if err != nil && len(live) == 0 {
- return
- }
- for _, torrent := range live {
- addAvailabilityTitleAny(torrent.Name, queries, out)
- }
-}
-
-func (s *SubscriptionService) finalizePendingAvailability(sub *model.Subscription, out LocalAvailability) LocalAvailability {
- mediaType := ""
- if sub != nil {
- mediaType = sub.MediaType
- }
- if isSubscriptionSeriesType(mediaType) || len(out.ExistingEpisodeKeys) > 0 {
- out.DownloadedEpisodes = len(out.ExistingEpisodeKeys)
- out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes)
- for _, episode := range out.MissingEpisodes {
- out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{}
- }
- } else if out.LocalMediaCount > 0 {
- out.DownloadedEpisodes = 1
- if out.TotalEpisodes == 0 {
- out.TotalEpisodes = 1
- }
- }
- return out
-}
-
-func (s *SubscriptionService) subscriptionBaseSavePath(ctx context.Context, sub *model.Subscription) string {
- if sub == nil {
- return ""
- }
- base := strings.TrimSpace(sub.SavePath)
- if base == "" && s != nil && s.repo != nil && s.repo.Setting != nil {
- base, _ = s.repo.Setting.Get(ctx, "qbittorrent.savepath")
- }
- return base
-}
-
-func subscriptionName(sub *model.Subscription) string {
- if sub == nil {
- return ""
- }
- return sub.Name
-}
-
-func subscriptionFilter(sub *model.Subscription) string {
- if sub == nil {
- return ""
- }
- return sub.Filter
-}
-
-func subscriptionAvailabilityQueries(sub *model.Subscription) []string {
- if sub == nil {
- return nil
- }
- values := []string{availabilityQuery(subscriptionName(sub), subscriptionFilter(sub))}
- for _, keyword := range siteSearchKeywords(sub) {
- values = append(values, cleanAvailabilityTitle(keyword))
- }
- if original := cleanAvailabilityTitle(sub.OriginalName); original != "" {
- values = append(values, original)
- }
- return compactUniqueStrings(values...)
-}
-
-func subscriptionMediaType(sub *model.Subscription) string {
- if sub == nil {
- return ""
- }
- return sub.MediaType
-}
-
-func (s *SubscriptionService) downloadPathHasCandidate(ctx context.Context, sub *model.Subscription, title, savePath string) bool {
- savePath = strings.TrimSpace(savePath)
- if savePath == "" {
- savePath = s.subscriptionBaseSavePath(ctx, sub)
- }
- query := availabilityQuery(title, subscriptionFilter(sub))
- if savePath == "" || query == "" {
- return false
- }
- wanted := episodeRefsFromTitle(title)
- if len(wanted) == 0 {
- wantSeason, wantEpisode := ParseEpisode(title)
- if wantEpisode > 0 {
- wanted = []episodeRef{{Season: wantSeason, Episode: wantEpisode}}
- }
- }
- found := false
- foundEpisodes := map[string]struct{}{}
- _ = scanDownloadPath(ctx, savePath, query, func(path string, season, episode int) bool {
- if len(wanted) == 0 {
- found = true
- return false
- }
- if episode <= 0 {
- return true
- }
- if season <= 0 {
- season = 1
- }
- if refs := episodeRefsFromTitle(path); len(refs) > 0 {
- for _, ref := range refs {
- foundEpisodes[episodeKey(ref.Season, ref.Episode)] = struct{}{}
- }
- } else {
- foundEpisodes[episodeKey(season, episode)] = struct{}{}
- }
- for _, ref := range wanted {
- if _, ok := foundEpisodes[episodeKey(ref.Season, ref.Episode)]; !ok {
- return true
- }
- }
- found = true
- return false
- })
- return found
-}
diff --git a/internal/service/subscription_availability_scan.go b/internal/service/subscription_availability_scan.go
deleted file mode 100644
index a4b910a..0000000
--- a/internal/service/subscription_availability_scan.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package service
-
-import (
- "context"
- "os"
- "path/filepath"
- "strings"
- "unicode"
-)
-
-func sameOrChildPath(pathValue, root string) bool {
- pathValue = filepath.Clean(strings.TrimSpace(pathValue))
- root = filepath.Clean(strings.TrimSpace(root))
- if pathValue == "" || root == "" || pathValue == "." || root == "." {
- return false
- }
- if strings.EqualFold(pathValue, root) {
- return true
- }
- rel, err := filepath.Rel(root, pathValue)
- if err != nil {
- return false
- }
- return rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel)
-}
-
-func scanDownloadPath(ctx context.Context, root, query string, visit func(path string, season, episode int) bool) error {
- return scanDownloadPathAny(ctx, root, []string{query}, visit)
-}
-
-func scanDownloadPathAny(ctx context.Context, root string, queries []string, visit func(path string, season, episode int) bool) error {
- if strings.TrimSpace(root) == "" {
- return nil
- }
- info, err := os.Stat(root)
- if err != nil || !info.IsDir() {
- return nil
- }
- if len(queries) == 0 {
- return nil
- }
- visited := 0
- return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
- if err != nil {
- return nil
- }
- if ctx.Err() != nil {
- return ctx.Err()
- }
- if d.IsDir() {
- if path != root && strings.HasPrefix(filepath.Base(path), ".") {
- return filepath.SkipDir
- }
- return nil
- }
- if !isDownloadMediaPath(path) {
- return nil
- }
- visited++
- if visited > 10000 {
- return filepath.SkipAll
- }
- if !availabilityTitleMatchesAny(path, queries) {
- return nil
- }
- season, episode := ParseEpisode(path)
- if !visit(path, season, episode) {
- return filepath.SkipAll
- }
- return nil
- })
-}
-
-func isDownloadMediaPath(path string) bool {
- ext := strings.ToLower(filepath.Ext(path))
- switch ext {
- case ".!qb", ".part", ".aria2", ".crdownload":
- path = strings.TrimSuffix(path, filepath.Ext(path))
- ext = strings.ToLower(filepath.Ext(path))
- }
- _, ok := videoExtensions[ext]
- return ok
-}
-
-func normalizeAvailabilityComparable(value string) string {
- var b strings.Builder
- for _, r := range strings.ToLower(value) {
- if unicode.IsLetter(r) || unicode.IsDigit(r) {
- b.WriteRune(r)
- }
- }
- return b.String()
-}
diff --git a/internal/service/subscription_availability_test.go b/internal/service/subscription_availability_test.go
deleted file mode 100644
index 5591b3a..0000000
--- a/internal/service/subscription_availability_test.go
+++ /dev/null
@@ -1,336 +0,0 @@
-package service
-
-import (
- "context"
- "net/http"
- "net/http/httptest"
- "sync/atomic"
- "testing"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestSubscriptionEnrichProgressIncludesPendingDownloads(t *testing.T) {
- db := newServiceTestDB(t, &model.DownloadTask{}, &model.Media{})
- repos := repository.New(db)
- if err := repos.Download.Create(t.Context(), &model.DownloadTask{
- Source: "qbittorrent",
- URL: "magnet:?xt=urn:btih:4444444444444444444444444444444444444444",
- Title: "Inception 2010 1080p",
- SavePath: "/downloads/movies",
- Status: "completed",
- Progress: 1,
- }); err != nil {
- t.Fatal(err)
- }
- svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil)
- items := []model.Subscription{{
- Name: "Inception 2010",
- Filter: "Inception 2010",
- MediaType: "movie",
- SavePath: "/downloads/movies",
- }}
-
- svc.EnrichProgress(t.Context(), items)
- if items[0].InLibrary {
- t.Fatal("pending download should not be reported as in-library media")
- }
- if items[0].DownloadedEpisodes != 1 || items[0].LocalMediaCount != 1 || items[0].TotalEpisodes != 1 {
- t.Fatalf("unexpected enriched progress: %+v", items[0])
- }
-}
-
-func TestSubscriptionEnrichManagementProgressSkipsLiveQB(t *testing.T) {
- var qbCalls int32
- qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- atomic.AddInt32(&qbCalls, 1)
- http.Error(w, "management enrichment should not call qb", http.StatusInternalServerError)
- }))
- defer qb.Close()
-
- db := newServiceTestDB(t, &model.DownloadTask{}, &model.Media{})
- repos := repository.New(db)
- sub := model.Subscription{
- Base: model.Base{ID: "sub-spy-family"},
- Name: "间谍过家家 自动订阅",
- Filter: "间谍过家家",
- MediaType: "tv",
- SavePath: "/downloads/tv",
- TotalEpisodes: 2,
- }
- if err := repos.Download.Create(t.Context(), &model.DownloadTask{
- SubscriptionID: sub.ID,
- Source: "qbittorrent",
- URL: "magnet:?xt=urn:btih:5555555555555555555555555555555555555555",
- Title: "间谍过家家 S01E01 1080p",
- SavePath: "/downloads/tv",
- Status: "downloading",
- Progress: 0.4,
- }); err != nil {
- t.Fatal(err)
- }
- downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
- downloads.qb.Configure(QBitConfig{BaseURL: qb.URL, Username: "admin", Password: "admin"})
- svc := NewSubscriptionService(nil, nil, repos, downloads, nil, nil)
- items := []model.Subscription{sub}
-
- svc.EnrichManagementProgress(t.Context(), items)
- if got := atomic.LoadInt32(&qbCalls); got != 0 {
- t.Fatalf("management progress called qb %d times, want 0", got)
- }
- if items[0].DownloadedEpisodes != 1 || items[0].LocalMediaCount != 1 {
- t.Fatalf("unexpected management progress: %+v", items[0])
- }
-}
-
-func TestSubscriptionPollIntervalDefaultsAndClampsMinimum(t *testing.T) {
- if subscriptionStartupDelay != defaultSubscriptionPollInterval {
- t.Fatalf("startup delay = %v, want default poll interval %v", subscriptionStartupDelay, defaultSubscriptionPollInterval)
- }
-
- db := newServiceTestDB(t, &model.Setting{})
- repos := repository.New(db)
- svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil)
- if got := svc.pollInterval(t.Context()); got != defaultSubscriptionPollInterval {
- t.Fatalf("default poll interval = %v, want %v", got, defaultSubscriptionPollInterval)
- }
-
- if err := repos.Setting.Set(t.Context(), "subscription.interval_seconds", "1800"); err != nil {
- t.Fatal(err)
- }
- if got := svc.pollInterval(t.Context()); got != minSubscriptionPollInterval {
- t.Fatalf("clamped poll interval = %v, want %v", got, minSubscriptionPollInterval)
- }
-
- if err := repos.Setting.Set(t.Context(), "subscription.interval_seconds", "14400"); err != nil {
- t.Fatal(err)
- }
- if got := svc.pollInterval(t.Context()); got != 4*time.Hour {
- t.Fatalf("configured poll interval = %v, want 4h", got)
- }
-}
-
-func TestSubscriptionServiceStartIsSingleLoopAndRestartable(t *testing.T) {
- ctx, cancel := context.WithCancel(t.Context())
- defer cancel()
- svc := NewSubscriptionService(nil, zap.NewNop(), nil, nil, nil, nil)
-
- svc.Start(ctx)
- firstStop := subscriptionStopChannel(svc)
- if firstStop == nil {
- t.Fatal("first Start did not create a stop channel")
- }
- svc.Start(ctx)
- if got := subscriptionStopChannel(svc); got != firstStop {
- t.Fatal("second Start should reuse the running loop instead of starting another")
- }
-
- svc.Stop()
- svc.Stop()
- svc.Start(ctx)
- secondStop := subscriptionStopChannel(svc)
- if secondStop == nil {
- t.Fatal("restart did not create a stop channel")
- }
- if secondStop == firstStop {
- t.Fatal("restart should create a fresh loop after Stop")
- }
- svc.Stop()
-}
-
-func subscriptionStopChannel(svc *SubscriptionService) chan struct{} {
- svc.mu.Lock()
- defer svc.mu.Unlock()
- return svc.stop
-}
-
-func TestMergeLocalAvailabilityKeepsLargerSeriesTotal(t *testing.T) {
- existing := map[string]struct{}{}
- for episode := 1; episode <= 6; episode++ {
- existing[episodeKey(1, episode)] = struct{}{}
- }
-
- got := mergeLocalAvailability(
- LocalAvailability{TotalEpisodes: 1, LocalMediaCount: 1},
- LocalAvailability{TotalEpisodes: 33, LocalMediaCount: 6, ExistingEpisodeKeys: existing},
- )
- if got.TotalEpisodes != 33 {
- t.Fatalf("TotalEpisodes = %d, want 33", got.TotalEpisodes)
- }
- if got.DownloadedEpisodes != 6 {
- t.Fatalf("DownloadedEpisodes = %d, want 6", got.DownloadedEpisodes)
- }
- if len(got.MissingEpisodes) != 27 {
- t.Fatalf("missing episodes = %d, want 27", len(got.MissingEpisodes))
- }
-}
-
-func TestSubscriptionLocalAvailabilityMatchesMediaPath(t *testing.T) {
- db := newServiceTestDB(t, &model.Media{})
- repos := repository.New(db)
- if err := db.Create(&model.Media{
- Title: "Scraped English Title",
- Path: "/media/电视剧/国产剧/凡人修仙传/Season 01/凡人修仙传 - S01E146.mkv",
- SeasonNum: 1,
- EpisodeNum: 146,
- }).Error; err != nil {
- t.Fatal(err)
- }
- sub := &model.Subscription{
- Name: "凡人修仙传 年番",
- Filter: "凡人修仙传",
- MediaType: "tv",
- TotalEpisodes: 146,
- }
-
- availability := SubscriptionLocalAvailability(t.Context(), repos, sub)
- if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 146)]; !ok {
- t.Fatalf("missing path-matched E146 key: %#v", availability.ExistingEpisodeKeys)
- }
- results := []SearchResult{
- {Title: "凡人修仙传 年番 - 146 1080p", DownloadURL: "https://pt/download/146", Seeders: 80},
- }
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability)
- if len(got) != 0 {
- t.Fatalf("selected %#v, want none because path-matched local episode exists", got)
- }
-}
-
-func TestSubscriptionPendingDownloadAvailabilityIgnoresDeletedTasks(t *testing.T) {
- db := newServiceTestDB(t, &model.DownloadTask{})
- repos := repository.New(db)
- if err := repos.Download.Create(t.Context(), &model.DownloadTask{
- Source: "qbittorrent",
- URL: "magnet:?xt=urn:btih:3333333333333333333333333333333333333333",
- Title: "间谍过家家 S01E02 1080p",
- SavePath: "/downloads/tv",
- Status: "deleted",
- }); err != nil {
- t.Fatal(err)
- }
- svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil)
- sub := &model.Subscription{
- Name: "间谍过家家 自动订阅",
- Filter: "间谍过家家",
- MediaType: "tv",
- SavePath: "/downloads/tv",
- TotalEpisodes: 3,
- }
-
- availability := svc.pendingDownloadAvailability(t.Context(), sub)
- if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 2)]; ok {
- t.Fatalf("deleted E02 task should not count as available: %#v", availability.ExistingEpisodeKeys)
- }
- results := []SearchResult{
- {Title: "间谍过家家 S01E02 1080p WEB-DL", DownloadURL: "https://pt/download/2", Seeders: 80},
- {Title: "间谍过家家 S01E03 1080p WEB-DL", DownloadURL: "https://pt/download/3", Seeders: 70},
- }
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability)
- if len(got) != 2 || got[0].Episode != 2 || got[1].Episode != 3 {
- t.Fatalf("selected %#v, want deleted episode 2 and new episode 3", got)
- }
-}
-
-func TestSubscriptionPendingDownloadAvailabilityIncludesLiveQBTorrents(t *testing.T) {
- qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/api/v2/auth/login":
- _, _ = w.Write([]byte("Ok."))
- case "/api/v2/torrents/info":
- _, _ = w.Write([]byte(`[{"hash":"abc123","name":"间谍过家家 S01E01 1080p","state":"downloading","progress":0.2}]`))
- default:
- http.NotFound(w, r)
- }
- }))
- defer qb.Close()
-
- db := newServiceTestDB(t, &model.DownloadTask{})
- repos := repository.New(db)
- downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
- downloads.qb.Configure(QBitConfig{BaseURL: qb.URL, Username: "admin", Password: "admin"})
- svc := NewSubscriptionService(nil, nil, repos, downloads, nil, nil)
- sub := &model.Subscription{
- Name: "间谍过家家 自动订阅",
- Filter: "间谍过家家",
- MediaType: "tv",
- SavePath: "/downloads/tv",
- TotalEpisodes: 2,
- }
-
- availability := svc.pendingDownloadAvailability(t.Context(), sub)
- if availability.DownloadedEpisodes != 1 {
- t.Fatalf("downloaded episodes = %d, want 1", availability.DownloadedEpisodes)
- }
- if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 1)]; !ok {
- t.Fatalf("missing live qB E01 key: %#v", availability.ExistingEpisodeKeys)
- }
-}
-
-func TestSiteSearchDownloadDedupMarksCandidateAvailable(t *testing.T) {
- db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{})
- repos := repository.New(db)
- sub := &model.Subscription{
- Base: model.Base{ID: "sub-nanyang"},
- UserID: "u1",
- Name: "南部档案 自动订阅",
- Filter: "南部档案",
- MediaType: "tv",
- SavePath: "/downloads/tv",
- TotalEpisodes: 33,
- }
- if err := repos.Download.Create(t.Context(), &model.DownloadTask{
- SubscriptionID: sub.ID,
- Source: "qbittorrent",
- URL: "https://pt/download/existing",
- Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL",
- SavePath: "/downloads/tv",
- Status: "queued",
- Progress: 0,
- }); err != nil {
- t.Fatal(err)
- }
- downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
- siteSvc := NewSiteService(zap.NewNop(), repos, "")
- svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, siteSvc, NewHub(zap.NewNop()))
- state := &siteSearchRunState{
- Keyword: "南部档案",
- SeenSet: map[string]struct{}{},
- Availability: LocalAvailability{
- TotalEpisodes: 33,
- ExistingEpisodeKeys: map[string]struct{}{},
- MissingEpisodeKeys: map[string]struct{}{},
- },
- }
-
- title, err := svc.enqueueSiteSearchCandidate(t.Context(), sub, siteSearchCandidate{
- Item: SearchResult{
- Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL",
- DownloadURL: "https://pt/download/existing",
- },
- Download: "https://pt/download/existing",
- GUID: "site|mteam|nanyang-7-8",
- Season: 1,
- Episode: 7,
- Episodes: []int{7, 8},
- Pack: true,
- Score: 80,
- }, state)
- if err != nil {
- t.Fatalf("enqueueSiteSearchCandidate returned %v, want dedup skipped without error", err)
- }
- if title != "" {
- t.Fatalf("queued title = %q, want empty on dedup", title)
- }
- for _, episode := range []int{7, 8} {
- if _, ok := state.Availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok {
- t.Fatalf("deduped candidate should mark E%d available: %#v", episode, state.Availability.ExistingEpisodeKeys)
- }
- }
- if len(state.Seen) != 0 {
- t.Fatalf("deduped candidate should stay retryable instead of being marked seen: %#v", state.Seen)
- }
-}
diff --git a/internal/service/subscription_availability_titles.go b/internal/service/subscription_availability_titles.go
deleted file mode 100644
index 60d495b..0000000
--- a/internal/service/subscription_availability_titles.go
+++ /dev/null
@@ -1,182 +0,0 @@
-package service
-
-import (
- "context"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func addAvailabilityTitle(title, query string, out *LocalAvailability) {
- if out == nil || strings.TrimSpace(title) == "" || strings.TrimSpace(query) == "" {
- return
- }
- if !availabilityTitleMatchesAny(title, []string{query}) {
- return
- }
- out.LocalMediaCount++
- if refs := episodeRefsFromTitle(title); len(refs) > 0 {
- if out.ExistingEpisodeKeys == nil {
- out.ExistingEpisodeKeys = map[string]struct{}{}
- }
- for _, ref := range refs {
- out.ExistingEpisodeKeys[episodeKey(ref.Season, ref.Episode)] = struct{}{}
- }
- return
- }
- if isSeriesPackTitle(title) {
- out.HasSeriesPack = true
- }
-}
-
-func addAvailabilityTitleAny(title string, queries []string, out *LocalAvailability) {
- if !availabilityTitleMatchesAny(title, queries) {
- return
- }
- addTrustedAvailabilityTitle(title, 0, 0, false, out)
-}
-
-func availabilityTitleMatchesAny(title string, queries []string) bool {
- titleKey := normalizeAvailabilityComparable(title)
- if titleKey == "" {
- return false
- }
- for _, query := range queries {
- queryKey := normalizeAvailabilityComparable(query)
- if queryKey == "" {
- continue
- }
- if strings.Contains(titleKey, queryKey) {
- return true
- }
- }
- return false
-}
-
-func addSiteSearchCandidateAvailability(candidate siteSearchCandidate, out *LocalAvailability) {
- addTrustedAvailabilityTitle(subscriptionSearchResultText(candidate.Item), candidate.Season, candidate.Episode, candidate.Pack, out)
-}
-
-func (s *SubscriptionService) subscriptionCandidateConfirmedAvailable(ctx context.Context, sub *model.Subscription, candidate siteSearchCandidate) bool {
- availability := mergeLocalAvailability(
- SubscriptionLocalAvailability(ctx, s.repo, sub),
- s.pendingDownloadAvailability(ctx, sub),
- )
- return candidateAvailableInAvailability(sub, candidate, availability)
-}
-
-func candidateAvailableInAvailability(sub *model.Subscription, candidate siteSearchCandidate, availability LocalAvailability) bool {
- mediaType := normalizeMediaType(subscriptionMediaType(sub), subscriptionName(sub)+" "+subscriptionFilter(sub), "")
- if !isSubscriptionSeriesType(mediaType) {
- return availability.LocalMediaCount > 0 || availability.InLibrary
- }
- episodes := candidateEpisodeNumbers(candidate)
- if len(episodes) == 0 {
- return availability.HasSeriesPack
- }
- season := candidate.Season
- if season <= 0 {
- season = 1
- }
- for _, episode := range episodes {
- if _, ok := availability.ExistingEpisodeKeys[episodeKey(season, episode)]; !ok {
- return false
- }
- }
- return true
-}
-
-func addTrustedAvailabilityTitle(title string, season, episode int, pack bool, out *LocalAvailability) {
- if out == nil {
- return
- }
- if strings.TrimSpace(title) == "" && episode <= 0 && !pack {
- return
- }
- out.LocalMediaCount++
- refs := episodeRefsFromTitle(title)
- if len(refs) == 0 && episode > 0 {
- if season <= 0 {
- season = 1
- }
- refs = []episodeRef{{Season: season, Episode: episode}}
- }
- if len(refs) > 0 {
- if out.ExistingEpisodeKeys == nil {
- out.ExistingEpisodeKeys = map[string]struct{}{}
- }
- for _, ref := range refs {
- out.ExistingEpisodeKeys[episodeKey(ref.Season, ref.Episode)] = struct{}{}
- }
- return
- }
- if episode <= 0 {
- season, episode = ParseEpisode(title)
- }
- if episode > 0 {
- if out.ExistingEpisodeKeys == nil {
- out.ExistingEpisodeKeys = map[string]struct{}{}
- }
- out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{}
- return
- }
- if pack || isSeriesPackTitle(title) {
- out.HasSeriesPack = true
- }
-}
-
-func mergeLocalAvailability(values ...LocalAvailability) LocalAvailability {
- out := LocalAvailability{
- ExistingEpisodeKeys: map[string]struct{}{},
- MissingEpisodeKeys: map[string]struct{}{},
- }
- for _, value := range values {
- if value.TotalEpisodes > out.TotalEpisodes {
- out.TotalEpisodes = value.TotalEpisodes
- }
- out.LocalMediaCount += value.LocalMediaCount
- out.InLibrary = out.InLibrary || value.InLibrary
- out.HasSeriesPack = out.HasSeriesPack || value.HasSeriesPack
- for key := range value.ExistingEpisodeKeys {
- out.ExistingEpisodeKeys[key] = struct{}{}
- }
- }
- out.DownloadedEpisodes = len(out.ExistingEpisodeKeys)
- if out.TotalEpisodes > 0 {
- out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes)
- for _, episode := range out.MissingEpisodes {
- out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{}
- }
- }
- if out.DownloadedEpisodes == 0 && out.LocalMediaCount > 0 {
- out.DownloadedEpisodes = out.LocalMediaCount
- if out.TotalEpisodes == 0 {
- out.TotalEpisodes = 1
- }
- }
- return out
-}
-
-// subscriptionItemAlreadyAvailable 判断某个订阅条目(按其标题解析出的季/集)是否已在媒体库存在。
-// 电影/无集号条目:媒体库已有该片即视为已存在;剧集条目:对应季集已入库即视为已存在。
-func subscriptionItemAlreadyAvailable(sub *model.Subscription, avail LocalAvailability, title string) bool {
- if avail.LocalMediaCount == 0 && !avail.HasSeriesPack {
- return false
- }
- if !isSubscriptionSeriesType(subscriptionMediaType(sub)) {
- return true
- }
- if avail.HasSeriesPack {
- return true
- }
- wantSeason, wantEpisode := ParseEpisode(title)
- if wantEpisode <= 0 {
- // 整季合集 / 无法解析集号:库里已有内容时保守跳过,避免重复整季下载。
- return true
- }
- if wantSeason <= 0 {
- wantSeason = 1
- }
- _, exists := avail.ExistingEpisodeKeys[episodeKey(wantSeason, wantEpisode)]
- return exists
-}
diff --git a/internal/service/subscription_candidate_availability_test.go b/internal/service/subscription_candidate_availability_test.go
deleted file mode 100644
index 33711d0..0000000
--- a/internal/service/subscription_candidate_availability_test.go
+++ /dev/null
@@ -1,340 +0,0 @@
-package service
-
-import (
- "testing"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func TestSelectSiteSearchCandidatesOnlyQueuesMissingLocalEpisodes(t *testing.T) {
- sub := &model.Subscription{Name: "间谍过家家 自动订阅", Filter: "间谍过家家", MediaType: "tv", TotalEpisodes: 3}
- results := []SearchResult{
- {Title: "间谍过家家 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 100},
- {Title: "间谍过家家 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 90},
- {Title: "间谍过家家 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 80},
- {Title: "间谍过家家 S01E03 1080p", DownloadURL: "https://pt/download/3", Seeders: 70},
- }
- availability := LocalAvailability{
- TotalEpisodes: 3,
- LocalMediaCount: 2,
- MissingEpisodes: []int{3},
- ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}, episodeKey(1, 2): {}},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability)
- if len(got) != 1 || got[0].Episode != 3 {
- t.Fatalf("selected %#v, want only missing episode 3", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesWithUnknownTotalSkipsExistingEpisodes(t *testing.T) {
- sub := &model.Subscription{Name: "葬送的芙莉莲 自动订阅", Filter: "葬送的芙莉莲", MediaType: "anime"}
- results := []SearchResult{
- {Title: "葬送的芙莉莲 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 100},
- {Title: "葬送的芙莉莲 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 90},
- {Title: "葬送的芙莉莲 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 80},
- {Title: "葬送的芙莉莲 S01E03 1080p", DownloadURL: "https://pt/download/3", Seeders: 70},
- }
- availability := LocalAvailability{
- LocalMediaCount: 2,
- ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}, episodeKey(1, 2): {}},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability)
- if len(got) != 1 || got[0].Episode != 3 {
- t.Fatalf("selected %#v, want only not-yet-local episode 3", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesSingleExistingEpisodeIsSkipped(t *testing.T) {
- sub := &model.Subscription{Name: "葬送的芙莉莲 自动订阅", Filter: "葬送的芙莉莲", MediaType: "anime", TotalEpisodes: 3}
- results := []SearchResult{
- {Title: "葬送的芙莉莲 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 90},
- }
- availability := LocalAvailability{
- TotalEpisodes: 3,
- LocalMediaCount: 1,
- MissingEpisodes: []int{2, 3},
- ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability)
- if len(got) != 0 {
- t.Fatalf("selected %#v, want none because E01 already exists", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesFullPackUsedAsFallbackWhenLibraryPartiallyExists(t *testing.T) {
- // 本地缺第 3 集,站点只有整季全集包(无单集种)。剧集完结后站点常只挂全集包,
- // 此时必须用全集包兜底补缺集,否则"补全缺失集"永远匹配为空(用户报告的 bug)。
- sub := &model.Subscription{Name: "间谍过家家 自动订阅", Filter: "间谍过家家", MediaType: "tv", TotalEpisodes: 3}
- results := []SearchResult{
- {Title: "间谍过家家 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 100},
- }
- availability := LocalAvailability{
- TotalEpisodes: 3,
- LocalMediaCount: 2,
- MissingEpisodes: []int{3},
- ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}, episodeKey(1, 2): {}},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability)
- if len(got) != 1 {
- t.Fatalf("selected %#v, want the full pack as fallback to cover missing episode 3", got)
- }
- if got[0].Download != "https://pt/download/pack" {
- t.Fatalf("selected %#v, want the Complete pack", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesPartialSeriesPackDoesNotSatisfySubscription(t *testing.T) {
- sub := &model.Subscription{Name: "问心2 自动订阅", Filter: "问心2", MediaType: "tv", TotalEpisodes: 33}
- results := []SearchResult{
- {Title: "问心2 S01E07 2160p WEB-DL", DownloadURL: "https://pt/download/7", Seeders: 100},
- }
- availability := LocalAvailability{
- TotalEpisodes: 33,
- LocalMediaCount: 7,
- HasSeriesPack: true,
- MissingEpisodes: []int{7},
- ExistingEpisodeKeys: map[string]struct{}{},
- }
- for episode := 1; episode <= 6; episode++ {
- availability.ExistingEpisodeKeys[episodeKey(1, episode)] = struct{}{}
- }
-
- got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability)
- if len(got) != 1 || got[0].Episode != 7 {
- t.Fatalf("selected %#v, want missing episode 7 despite local pack marker", got)
- }
- if stats.LocalSeriesPackPresent {
- t.Fatalf("LocalSeriesPackPresent = true, want false for partial series availability")
- }
-}
-
-func TestSelectSiteSearchCandidatesIgnoresUnderestimatedLocalTotal(t *testing.T) {
- sub := &model.Subscription{Name: "南部档案 自动订阅", Filter: "南部档案", MediaType: "tv"}
- results := []SearchResult{
- {Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/29-33", Seeders: 100},
- }
- existing := map[string]struct{}{}
- for episode := 1; episode <= 6; episode++ {
- existing[episodeKey(1, episode)] = struct{}{}
- }
- availability := LocalAvailability{
- TotalEpisodes: 1,
- LocalMediaCount: 7,
- HasSeriesPack: true,
- ExistingEpisodeKeys: existing,
- }
-
- got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability)
- if len(got) != 1 || got[0].Download != "https://pt/download/29-33" {
- t.Fatalf("selected %#v, want high-episode candidate despite underestimated local total", got)
- }
- if stats.SeriesComplete || stats.NotMissingEpisodeSkipped != 0 {
- t.Fatalf("stats = %#v, underestimated total must not mark series complete or skip high episodes", stats)
- }
-}
-
-func TestSelectSiteSearchCandidatesRangeCanCoverMissingEpisodesAfterExistingStart(t *testing.T) {
- sub := &model.Subscription{Name: "南部档案 自动订阅", Filter: "南部档案", MediaType: "tv", TotalEpisodes: 33}
- results := []SearchResult{
- {Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/29-33", Seeders: 100},
- }
- existing := map[string]struct{}{episodeKey(1, 29): {}}
- availability := LocalAvailability{
- TotalEpisodes: 33,
- LocalMediaCount: 1,
- MissingEpisodes: []int{30, 31, 32, 33},
- ExistingEpisodeKeys: existing,
- }
-
- got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability)
- if len(got) != 1 || got[0].Download != "https://pt/download/29-33" {
- t.Fatalf("selected %#v, want range candidate because it covers E30-E33", got)
- }
- if stats.ExistingEpisodeSkipped != 0 || stats.NotMissingEpisodeSkipped != 0 {
- t.Fatalf("stats = %#v, range covering missing episodes must not be skipped", stats)
- }
-}
-
-func TestSelectSiteSearchCandidatesMissingEpisodeCanMatchSubtitleAlias(t *testing.T) {
- sub := &model.Subscription{Name: "躲在超市后门抽烟的两人 自动订阅", Filter: "躲在超市后门抽烟的两人", MediaType: "tv", TotalEpisodes: 12}
- results := []SearchResult{
- {Title: "Smoking Behind the Supermarket with You S01E01 1080p", Subtitle: "躲在超市后门抽烟的两人", DownloadURL: "https://pt/download/1", Seeders: 100},
- {Title: "Smoking Behind the Supermarket with You S01E12 1080p", Subtitle: "躲在超市后门抽烟的两人", DownloadURL: "https://pt/download/12", Seeders: 80},
- }
- existing := map[string]struct{}{}
- for episode := 1; episode <= 11; episode++ {
- existing[episodeKey(1, episode)] = struct{}{}
- }
- availability := LocalAvailability{
- TotalEpisodes: 12,
- LocalMediaCount: 11,
- MissingEpisodes: []int{12},
- ExistingEpisodeKeys: existing,
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability)
- if len(got) != 1 || got[0].Episode != 12 || got[0].Download != "https://pt/download/12" {
- t.Fatalf("selected %#v, want subtitle-matched missing episode 12", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesRelaxesQueryForExistingSeriesMissingEpisodes(t *testing.T) {
- sub := &model.Subscription{Name: "翘楚 S01E06 自动订阅", Filter: "翘楚 S01E06", MediaType: "tv", TotalEpisodes: 24}
- results := []SearchResult{
- {Title: "Qiao Chu 2026 S01E06 2160p WEB-DL", DownloadURL: "https://pt/download/6", Seeders: 10},
- {Title: "Ashes to Crown 2026 S01E21 2160p WEB-DL", DownloadURL: "https://pt/download/21", Seeders: 8},
- {Title: "Ashes to Crown 2026 S01E99 2160p WEB-DL", DownloadURL: "https://pt/download/99", Seeders: 99},
- }
- availability := LocalAvailability{
- TotalEpisodes: 24,
- LocalMediaCount: 1,
- MissingEpisodes: []int{21},
- ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 6): {}},
- }
-
- got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability)
- if len(got) != 1 || got[0].Episode != 21 || got[0].Download != "https://pt/download/21" {
- t.Fatalf("selected %#v, want relaxed alias-like missing episode 21 only", got)
- }
- if stats.QueryMismatch != 3 || stats.RelaxedQueryMatch != 3 || stats.ExistingEpisodeSkipped != 1 || stats.NotMissingEpisodeSkipped != 1 {
- t.Fatalf("unexpected relaxed stats: %#v", stats)
- }
-}
-
-func TestAddSiteSearchCandidateAvailabilityTracksRelaxedAliasCandidate(t *testing.T) {
- sub := &model.Subscription{Name: "翘楚 S01E06 自动订阅", Filter: "翘楚 S01E06", MediaType: "tv", TotalEpisodes: 24}
- availability := LocalAvailability{
- TotalEpisodes: 24,
- LocalMediaCount: 1,
- MissingEpisodes: []int{21},
- ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 6): {}},
- MissingEpisodeKeys: map[string]struct{}{episodeKey(1, 21): {}},
- }
- candidate := siteSearchCandidate{
- Item: SearchResult{
- Title: "Ashes to Crown 2026 S01E21 2160p WEB-DL",
- DownloadURL: "https://pt/download/21",
- },
- Download: "https://pt/download/21",
- GUID: "site|m-team|ashes-to-crown-21",
- Season: 1,
- Episode: 21,
- }
-
- addSiteSearchCandidateAvailability(candidate, &availability)
- availability = NewSubscriptionService(nil, nil, nil, nil, nil, nil).finalizePendingAvailability(sub, availability)
-
- if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 21)]; !ok {
- t.Fatalf("missing relaxed alias candidate E21 key: %#v", availability.ExistingEpisodeKeys)
- }
- got := selectSiteSearchCandidates([]SearchResult{candidate.Item}, sub, map[string]struct{}{}, availability)
- if len(got) != 0 {
- t.Fatalf("selected %#v, want relaxed alias candidate skipped after dedup availability update", got)
- }
-}
-
-func TestAddSiteSearchCandidateAvailabilityTracksEpisodeRange(t *testing.T) {
- availability := LocalAvailability{
- TotalEpisodes: 33,
- ExistingEpisodeKeys: map[string]struct{}{},
- MissingEpisodeKeys: map[string]struct{}{},
- }
- candidate := siteSearchCandidate{
- Item: SearchResult{
- Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL",
- DownloadURL: "https://pt/download/29-33",
- },
- Download: "https://pt/download/29-33",
- GUID: "site|m-team|nanyang-29-33",
- Season: 1,
- Episode: 29,
- Episodes: []int{29, 30, 31, 32, 33},
- Pack: true,
- }
-
- addSiteSearchCandidateAvailability(candidate, &availability)
- for episode := 29; episode <= 33; episode++ {
- if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok {
- t.Fatalf("availability missing E%d after range mark: %#v", episode, availability.ExistingEpisodeKeys)
- }
- }
-}
-
-func TestCandidateAvailableInAvailabilityRequiresFullRangeCoverage(t *testing.T) {
- sub := &model.Subscription{Name: "南部档案 自动订阅", Filter: "南部档案", MediaType: "tv", TotalEpisodes: 33}
- candidate := siteSearchCandidate{
- Item: SearchResult{
- Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL",
- DownloadURL: "https://pt/download/29-33",
- },
- Season: 1,
- Episode: 29,
- Episodes: []int{29, 30, 31, 32, 33},
- Pack: true,
- }
- availability := LocalAvailability{
- TotalEpisodes: 33,
- ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 29): {}, episodeKey(1, 30): {}},
- }
-
- if candidateAvailableInAvailability(sub, candidate, availability) {
- t.Fatal("partial range availability must not confirm a deduped subscription candidate")
- }
- for episode := 31; episode <= 33; episode++ {
- availability.ExistingEpisodeKeys[episodeKey(1, episode)] = struct{}{}
- }
- if !candidateAvailableInAvailability(sub, candidate, availability) {
- t.Fatal("complete range availability should confirm a deduped subscription candidate")
- }
-}
-
-func TestShouldSkipExistingTorrentKeepsSeriesRangeCandidate(t *testing.T) {
- svc := &SubscriptionService{downloads: &DownloadService{}}
- candidate := siteSearchCandidate{
- Item: SearchResult{
- Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL",
- DownloadURL: "https://pt/download/29-33",
- },
- Season: 1,
- Episode: 29,
- Episodes: []int{29, 30, 31, 32, 33},
- Pack: true,
- }
-
- if svc.shouldSkipExistingTorrent(t.Context(), "tv", candidate) {
- t.Fatal("series range candidate should not be skipped by global torrent-name precheck")
- }
-}
-
-func TestSelectSiteSearchCandidatesDoesNotRelaxQueryForMovies(t *testing.T) {
- sub := &model.Subscription{Name: "玩具总动员 5 自动订阅", Filter: "玩具总动员 5 2026", MediaType: "movie"}
- results := []SearchResult{
- {Title: "Toy Story 4 2019 2160p WEB-DL", DownloadURL: "https://pt/download/wrong", Seeders: 100},
- }
- availability := LocalAvailability{}
-
- got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability)
- if len(got) != 0 {
- t.Fatalf("selected %#v, want no relaxed movie match", got)
- }
- if stats.QueryMismatch != 1 || stats.RelaxedQueryMatch != 0 {
- t.Fatalf("unexpected stats: %#v", stats)
- }
-}
-
-func TestSelectSiteSearchCandidatesSingleExistingMovieIsSkippedWhenNotWashing(t *testing.T) {
- sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie"}
- results := []SearchResult{
- {Title: "Inception 2010 1080p WEB-DL", DownloadURL: "https://pt/download/web", Seeders: 90},
- }
- availability := LocalAvailability{LocalMediaCount: 1, InLibrary: true, DownloadedEpisodes: 1, TotalEpisodes: 1}
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability)
- if len(got) != 0 {
- t.Fatalf("selected %#v, want none because movie already exists and wash is disabled", got)
- }
-}
diff --git a/internal/service/subscription_candidate_collect.go b/internal/service/subscription_candidate_collect.go
deleted file mode 100644
index b7b4184..0000000
--- a/internal/service/subscription_candidate_collect.go
+++ /dev/null
@@ -1,237 +0,0 @@
-package service
-
-import (
- "regexp"
- "strconv"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-var looseSubscriptionEpisodeRE = regexp.MustCompile(`(?i)(?:^|[\s._\-\[\(])0?(\d{1,3})(?:v\d+)?(?:$|[\s._\-\]\)])`)
-
-func collectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, allowQueryMismatch bool, stats *siteSearchSelectionStats) []siteSearchCandidate {
- candidates := make([]siteSearchCandidate, 0, len(results))
- for _, item := range results {
- matchText := subscriptionSearchResultText(item)
- if !subscriptionSearchResultMatchesQuery(sub, item) {
- if allowQueryMismatch {
- stats.RelaxedQueryMatch++
- } else {
- stats.QueryMismatch++
- stats.QueryMismatchExamples = appendLimitedStrings(stats.QueryMismatchExamples, matchText, 5)
- continue
- }
- }
- if !matchesSubscriptionRules(sub, matchText) || !matchesSubscriptionTorrentRules(sub, item) {
- stats.RuleMismatch++
- continue
- }
- download := strings.TrimSpace(item.DownloadURL)
- if download == "" {
- download = strings.TrimSpace(item.TorrentURL)
- }
- if download == "" {
- stats.MissingDownload++
- continue
- }
- guid := stableSiteSearchGUID(item, download)
- if _, ok := seenSet[guid]; ok {
- stats.Seen++
- continue
- }
- refs := subscriptionCandidateEpisodeRefs(sub, matchText)
- season, episode := ParseEpisode(matchText)
- if episode <= 0 && len(refs) > 0 {
- season = refs[0].Season
- episode = refs[0].Episode
- }
- episodes := episodeNumbersFromRefs(refs, season)
- score := subscriptionCandidateScore(sub, item)
- stats.Prepared++
- candidates = append(candidates, siteSearchCandidate{
- Item: item,
- Download: download,
- GUID: guid,
- Season: season,
- Episode: episode,
- Episodes: episodes,
- Pack: isSeriesPackTitle(item.Title) || len(episodes) > 1,
- Score: score,
- })
- }
- return candidates
-}
-
-func appendLimitedStrings(values []string, value string, limit int) []string {
- value = strings.TrimSpace(value)
- if value == "" || limit <= 0 || len(values) >= limit {
- return values
- }
- return append(values, value)
-}
-
-func shouldRelaxSiteSearchQueryMatch(sub *model.Subscription, local LocalAvailability) bool {
- if sub == nil {
- return false
- }
- mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "")
- if !isSubscriptionSeriesType(mediaType) {
- return false
- }
- if local.LocalMediaCount == 0 && len(local.ExistingEpisodeKeys) == 0 {
- return false
- }
- return local.TotalEpisodes > 0 || len(local.MissingEpisodes) > 0
-}
-
-func subscriptionSearchResultText(item SearchResult) string {
- return strings.TrimSpace(strings.Join([]string{item.Title, item.Subtitle, item.Labels}, " "))
-}
-
-func selectRSSSubscriptionCandidates(items []rssItem, sub *model.Subscription, filter *regexp.Regexp, seenSet map[string]struct{}, local LocalAvailability) []siteSearchCandidate {
- if seenSet == nil {
- seenSet = map[string]struct{}{}
- }
- candidates := make([]siteSearchCandidate, 0, len(items))
- for _, item := range items {
- title := strings.TrimSpace(item.Title)
- if title == "" {
- continue
- }
- if filter != nil && !filter.MatchString(title) {
- continue
- }
- searchItem := SearchResult{Title: title}
- if !matchesSubscriptionRules(sub, title) || !matchesSubscriptionTorrentRules(sub, searchItem) {
- continue
- }
- download := strings.TrimSpace(item.Enclosure.URL)
- if download == "" {
- download = strings.TrimSpace(item.Link)
- }
- if download == "" {
- continue
- }
- guid := stableRSSItemGUID(title, item.GUID, item.Link, item.Enclosure.URL)
- if _, ok := seenSet[guid]; ok {
- continue
- }
- searchItem.DownloadURL = download
- refs := subscriptionCandidateEpisodeRefs(sub, title)
- season, episode := ParseEpisode(title)
- if episode <= 0 && len(refs) > 0 {
- season = refs[0].Season
- episode = refs[0].Episode
- }
- episodes := episodeNumbersFromRefs(refs, season)
- candidates = append(candidates, siteSearchCandidate{
- Item: searchItem,
- Download: download,
- GUID: guid,
- Season: season,
- Episode: episode,
- Episodes: episodes,
- Pack: isSeriesPackTitle(title) || len(episodes) > 1,
- Score: subscriptionCandidateScore(sub, searchItem),
- })
- }
- return selectPreparedSubscriptionCandidates(candidates, sub, local)
-}
-
-func subscriptionCandidateEpisodeRefs(sub *model.Subscription, text string) []episodeRef {
- if refs := episodeRefsFromTitle(text); len(refs) > 0 {
- return refs
- }
- if sub == nil || !isSubscriptionSeriesType(sub.MediaType) || isSeriesPackTitle(text) || patSeasonOnly.MatchString(text) {
- return nil
- }
- episode := inferLooseSubscriptionEpisode(maskSubscriptionTitleQueries(sub, text))
- if episode <= 0 {
- return nil
- }
- return []episodeRef{{Season: 1, Episode: episode}}
-}
-
-func maskSubscriptionTitleQueries(sub *model.Subscription, text string) string {
- if sub == nil || strings.TrimSpace(text) == "" {
- return text
- }
- out := text
- outFold := strings.ToLower(out)
- for _, query := range subscriptionTitleMatchQueries(sub) {
- query = strings.TrimSpace(query)
- if query == "" {
- continue
- }
- queryFold := strings.ToLower(query)
- for {
- idx := strings.Index(outFold, queryFold)
- if idx < 0 {
- break
- }
- out = out[:idx] + strings.Repeat(" ", len(query)) + out[idx+len(query):]
- outFold = strings.ToLower(out)
- }
- }
- return out
-}
-
-func inferLooseSubscriptionEpisode(text string) int {
- for _, match := range looseSubscriptionEpisodeRE.FindAllStringSubmatchIndex(text, -1) {
- if len(match) < 4 || match[2] < 0 || match[3] < 0 {
- continue
- }
- if isDecimalFractionMatch(text, match[2]) {
- continue
- }
- value, err := strconv.Atoi(text[match[2]:match[3]])
- if err != nil || !looksLikeLooseEpisodeNumber(value) {
- continue
- }
- return value
- }
- return 0
-}
-
-func isDecimalFractionMatch(text string, digitStart int) bool {
- return digitStart >= 2 && text[digitStart-1] == '.' && text[digitStart-2] >= '0' && text[digitStart-2] <= '9'
-}
-
-func looksLikeLooseEpisodeNumber(value int) bool {
- switch {
- case value <= 0, value > 200:
- return false
- default:
- return true
- }
-}
-
-func episodeNumbersFromRefs(refs []episodeRef, fallbackSeason int) []int {
- if len(refs) == 0 {
- return nil
- }
- if fallbackSeason <= 0 {
- fallbackSeason = refs[0].Season
- }
- out := make([]int, 0, len(refs))
- seen := map[int]struct{}{}
- for _, ref := range refs {
- season := ref.Season
- if season <= 0 {
- season = 1
- }
- if fallbackSeason > 0 && season != fallbackSeason {
- continue
- }
- if ref.Episode <= 0 {
- continue
- }
- if _, ok := seen[ref.Episode]; ok {
- continue
- }
- seen[ref.Episode] = struct{}{}
- out = append(out, ref.Episode)
- }
- return out
-}
diff --git a/internal/service/subscription_candidate_defaults_test.go b/internal/service/subscription_candidate_defaults_test.go
deleted file mode 100644
index 122b619..0000000
--- a/internal/service/subscription_candidate_defaults_test.go
+++ /dev/null
@@ -1,299 +0,0 @@
-package service
-
-import (
- "testing"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func TestSelectSiteSearchCandidatesRejectsKeywordOriginWithConflictingYear(t *testing.T) {
- sub := &model.Subscription{
- Name: "玩具总动员 5 自动订阅",
- Filter: "玩具总动员 5 2026",
- MediaType: "movie",
- Year: 2026,
- }
- results := []SearchResult{{
- Title: "Toy Story 4 2019 2160p DSNP WEB-DL",
- DownloadURL: "https://pt/download/toy-story-4",
- SearchKeyword: "玩具总动员 5",
- Seeders: 90,
- }}
-
- got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, LocalAvailability{})
- if len(got) != 0 {
- t.Fatalf("selected %#v, want conflicting-year keyword-origin result rejected", got)
- }
- if stats.QueryMismatch != 1 || stats.Prepared != 0 {
- t.Fatalf("stats = %#v, want query mismatch for conflicting year", stats)
- }
-}
-
-func TestSelectSiteSearchCandidatesDoesNotWashByDefault(t *testing.T) {
- sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", WashPriority: "resolution"}
- results := []SearchResult{
- {Title: "Inception 2010 1080p", DownloadURL: "https://pt/download/1080", Seeders: 90},
- {Title: "Inception 2010 2160p", DownloadURL: "https://pt/download/2160", Seeders: 80},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 || got[0].Download != "https://pt/download/2160" {
- t.Fatalf("selected %#v, want default best single result when wash disabled", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesWashNeedsExplicitUpgradeCriteria(t *testing.T) {
- sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", WashEnabled: true, WashPriority: "resolution"}
- local := LocalAvailability{LocalMediaCount: 1, InLibrary: true}
- results := []SearchResult{
- {Title: "Inception 2010 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/1080", Seeders: 90},
- {Title: "Inception 2010 2160p WEB-DL H264 AAC", DownloadURL: "https://pt/download/2160", Seeders: 80},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, local)
- if len(got) != 0 {
- t.Fatalf("selected %#v, want no wash download without explicit upgrade criteria", got)
- }
-
- sub.Resolution = "2160p"
- got = selectSiteSearchCandidates(results, sub, map[string]struct{}{}, local)
- if len(got) != 1 || got[0].Download != "https://pt/download/2160" {
- t.Fatalf("selected %#v, want explicit 2160p wash candidate", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesWashWithoutCriteriaUsesDefaultQuality(t *testing.T) {
- sub := &model.Subscription{Name: "Dune 自动订阅", Filter: "Dune 2021", MediaType: "movie", WashEnabled: true, WashPriority: "quality"}
- results := []SearchResult{
- {Title: "Dune 2021 2160p REMUX H264 AAC", DownloadURL: "https://pt/download/remux", Seeders: 80},
- {Title: "Dune 2021 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/webdl", Seeders: 60},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 || got[0].Download != "https://pt/download/webdl" {
- t.Fatalf("selected %#v, want default compatible WEB-DL when wash has no explicit criteria", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesDefaultsToOnePreferredVersionPerEpisode(t *testing.T) {
- sub := &model.Subscription{Name: "House of the Dragon 自动订阅", Filter: "House of the Dragon", MediaType: "tv"}
- results := []SearchResult{
- {Title: "House of the Dragon S03E01 1080p HDTV", DownloadURL: "https://pt/download/e01-hdtv", Seeders: 50000},
- {Title: "House of the Dragon S03E01 1080p WEB-DL", DownloadURL: "https://pt/download/e01-webdl-1080", Seeders: 100},
- {Title: "House of the Dragon S03E01 2160p WEB-DL", DownloadURL: "https://pt/download/e01-webdl-2160", Seeders: 80},
- {Title: "House of the Dragon S03E02 720p HDTV", DownloadURL: "https://pt/download/e02-hdtv", Seeders: 500},
- {Title: "House of the Dragon S03E02 1080p WEBRip", DownloadURL: "https://pt/download/e02-webrip", Seeders: 60},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 2 {
- t.Fatalf("selected %d candidates, want one per episode", len(got))
- }
- if got[0].Download != "https://pt/download/e01-webdl-2160" {
- t.Fatalf("episode 1 selected %q, want best WEB-DL version", got[0].Download)
- }
- if got[1].Download != "https://pt/download/e02-webrip" {
- t.Fatalf("episode 2 selected %q, want WEBRip over high-seeder HDTV", got[1].Download)
- }
-}
-
-func TestSelectSiteSearchCandidatesDefaultQualityRecognizesWebDLVariants(t *testing.T) {
- sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"}
- results := []SearchResult{
- {Title: "Some Show S01E01 1080p BluRay H264 AAC", DownloadURL: "https://pt/download/e01-bluray", Seeders: 900},
- {Title: "Some Show S01E01 2160p WEB.DL H264 AAC", DownloadURL: "https://pt/download/e01-webdotdl", Seeders: 40},
- {Title: "Some Show S01E01 1080p WEB DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl", Seeders: 50},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdotdl" {
- t.Fatalf("selected %#v, want one best WEB-DL variant", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesDefaultPrefersWebDLBeforeResolution(t *testing.T) {
- sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"}
- results := []SearchResult{
- {Title: "Some Show S01E01 2160p BluRay H264 AAC", DownloadURL: "https://pt/download/e01-bluray-2160", Seeders: 900},
- {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl-1080", Seeders: 50},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdl-1080" {
- t.Fatalf("selected %#v, want one compatible WEB-DL version before higher-resolution BluRay", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesDefaultPrefersFreeWithinSameQualityBand(t *testing.T) {
- sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"}
- results := []SearchResult{
- {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-nonfree", Seeders: 5000},
- {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-free", Seeders: 80, Free: true},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 || got[0].Download != "https://pt/download/e01-free" {
- t.Fatalf("selected %#v, want free candidate within same quality/resolution band", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesDefaultDoesNotLetFreeOverrideBetterQuality(t *testing.T) {
- sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"}
- results := []SearchResult{
- {Title: "Some Show S01E01 1080p HDTV H264 AAC", DownloadURL: "https://pt/download/e01-free-hdtv", Seeders: 80, Free: true},
- {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl", Seeders: 50},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdl" {
- t.Fatalf("selected %#v, want WEB-DL quality to stay ahead of free HDTV", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesDefaultDoesNotLetFreeOverrideBetterResolution(t *testing.T) {
- sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"}
- results := []SearchResult{
- {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-free-1080", Seeders: 80, Free: true},
- {Title: "Some Show S01E01 2160p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-2160", Seeders: 50},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 || got[0].Download != "https://pt/download/e01-2160" {
- t.Fatalf("selected %#v, want better resolution to stay ahead of free lower-resolution release", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesDefaultsToOneLooseNumberedEpisode(t *testing.T) {
- sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"}
- results := []SearchResult{
- {Title: "Some Show 01 1080p HDTV H264 AAC", DownloadURL: "https://pt/download/e01-hdtv", Seeders: 5000},
- {Title: "Some Show 01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl-1080", Seeders: 100},
- {Title: "Some Show 01 2160p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl-2160", Seeders: 80},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 {
- t.Fatalf("selected %d candidates, want one preferred loose-numbered episode", len(got))
- }
- if got[0].Download != "https://pt/download/e01-webdl-2160" || got[0].Episode != 1 {
- t.Fatalf("selected %#v, want episode 1 best WEB-DL version", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesDoesNotTreatTitleNumberAsLooseEpisode(t *testing.T) {
- sub := &model.Subscription{Name: "问心2 自动订阅", Filter: "问心2 2023", MediaType: "tv"}
- results := []SearchResult{
- {Title: "问心2 2023 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/season", Seeders: 100},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 {
- t.Fatalf("selected %#v, want one fallback candidate", got)
- }
- if got[0].Episode != 0 {
- t.Fatalf("episode = %d, want title number not treated as episode", got[0].Episode)
- }
-}
-
-func TestSelectSiteSearchCandidatesRejectsRiskyLabelsFromSiteResult(t *testing.T) {
- sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"}
- results := []SearchResult{
- {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", Labels: "HR", DownloadURL: "https://pt/download/e01-hr", Seeders: 900},
- {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-safe", Seeders: 80},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 || got[0].Download != "https://pt/download/e01-safe" {
- t.Fatalf("selected %#v, want non-HR candidate only", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesWashPriorityDoesNotLetFreeOverrideResolution(t *testing.T) {
- sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv", WashEnabled: true, WashPriority: "resolution"}
- results := []SearchResult{
- {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-free-1080", Seeders: 80, Free: true},
- {Title: "Some Show S01E01 2160p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-2160", Seeders: 50},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 || got[0].Download != "https://pt/download/e01-2160" {
- t.Fatalf("selected %#v, want wash resolution priority to stay ahead of free lower-resolution release", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesRejectsDefaultCompatibilityVersions(t *testing.T) {
- sub := &model.Subscription{Name: "House of the Dragon 自动订阅", Filter: "House of the Dragon", MediaType: "tv"}
- results := []SearchResult{
- {Title: "House of the Dragon S03E01 2160p WEB-DL HEVC 10bit DoVi Atmos", DownloadURL: "https://pt/download/e01-dovi", Seeders: 900},
- {Title: "House of the Dragon S03E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl", Seeders: 80},
- {Title: "House of the Dragon S03E01 1080p HDTV H264 AAC", DownloadURL: "https://pt/download/e01-hdtv", Seeders: 5000},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdl" {
- t.Fatalf("selected %#v, want compatible WEB-DL only", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesKeepsCompatibilityExcludesWithCustomExcludeWords(t *testing.T) {
- sub := &model.Subscription{
- Name: "House of the Dragon 自动订阅",
- Filter: "House of the Dragon",
- MediaType: "tv",
- ExcludeWords: "官中,无字幕",
- }
- results := []SearchResult{
- {Title: "House of the Dragon S03E01 2160p WEB-DL HEVC 10bit DoVi Atmos", DownloadURL: "https://pt/download/e01-dovi", Seeders: 900},
- {Title: "House of the Dragon S03E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl", Seeders: 80},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdl" {
- t.Fatalf("selected %#v, want custom exclude words to keep default compatible WEB-DL guard", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesAvoidsOverlappingEpisodeRanges(t *testing.T) {
- sub := &model.Subscription{Name: "House of the Dragon 自动订阅", Filter: "House of the Dragon", MediaType: "tv", WashEnabled: true, WashPriority: "quality"}
- availability := LocalAvailability{
- LocalMediaCount: 1,
- TotalEpisodes: 3,
- MissingEpisodes: []int{1, 2, 3},
- ExistingEpisodeKeys: map[string]struct{}{},
- }
- results := []SearchResult{
- {Title: "House of the Dragon S03E01-E02 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-e02-pack", Seeders: 90},
- {Title: "House of the Dragon S03E02 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e02-duplicate", Seeders: 80},
- {Title: "House of the Dragon S03E03 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e03", Seeders: 70},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability)
- if len(got) != 2 {
- t.Fatalf("selected %d candidates, want pack plus non-overlapping episode", len(got))
- }
- if got[0].Download != "https://pt/download/e01-e02-pack" || got[1].Download != "https://pt/download/e03" {
- t.Fatalf("selected %#v, want overlapping E02 duplicate skipped", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesAppliesQualityRules(t *testing.T) {
- sub := &model.Subscription{
- Name: "Dune 自动订阅",
- Filter: "Dune 2021",
- MediaType: "movie",
- Resolution: "2160p",
- Quality: "remux",
- Effects: "hdr",
- ExcludeWords: "cam,ts",
- }
- results := []SearchResult{
- {Title: "Dune 2021 2160p WEB-DL HDR", DownloadURL: "https://pt/download/web", Seeders: 100},
- {Title: "Dune 2021 2160p UHD BluRay REMUX HDR", DownloadURL: "https://pt/download/remux", Seeders: 30},
- {Title: "Dune 2021 2160p REMUX HDR CAM", DownloadURL: "https://pt/download/cam", Seeders: 200},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 || got[0].Download != "https://pt/download/remux" {
- t.Fatalf("selected %#v, want filtered remux", got)
- }
-}
diff --git a/internal/service/subscription_candidate_select.go b/internal/service/subscription_candidate_select.go
deleted file mode 100644
index 187fa69..0000000
--- a/internal/service/subscription_candidate_select.go
+++ /dev/null
@@ -1,184 +0,0 @@
-package service
-
-import (
- "sort"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func selectPreparedSubscriptionCandidates(candidates []siteSearchCandidate, sub *model.Subscription, local LocalAvailability) []siteSearchCandidate {
- return selectPreparedSubscriptionCandidatesWithStats(candidates, sub, local, nil)
-}
-
-func selectPreparedSubscriptionCandidatesWithStats(candidates []siteSearchCandidate, sub *model.Subscription, local LocalAvailability, stats *siteSearchSelectionStats) []siteSearchCandidate {
- if len(candidates) > 1 {
- sort.SliceStable(candidates, func(i, j int) bool {
- if candidates[i].Score != candidates[j].Score {
- return candidates[i].Score > candidates[j].Score
- }
- if candidates[i].Item.Seeders != candidates[j].Item.Seeders {
- return candidates[i].Item.Seeders > candidates[j].Item.Seeders
- }
- return candidates[i].Item.Size > candidates[j].Item.Size
- })
- }
- if len(candidates) == 0 {
- return recordPreparedSelection(nil, stats)
- }
-
- mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "")
- if !isSubscriptionSeriesType(mediaType) {
- // 非洗版订阅成功下载一次即满足,媒体库/下载中已存在则不再重复下载。
- if !subscriptionAllowsWash(sub) && local.LocalMediaCount > 0 {
- if stats != nil {
- stats.LocalAlreadySatisfied = true
- }
- return recordPreparedSelection(nil, stats)
- }
- return recordPreparedSelection(candidates[:1], stats)
- }
-
- if localSeriesPackSatisfiesSubscription(local) {
- if stats != nil {
- stats.LocalSeriesPackPresent = true
- }
- return recordPreparedSelection(nil, stats)
- }
- if local.LocalMediaCount > 0 {
- trustedTotal := trustedAvailabilityTotal(local)
- if trustedTotal > 0 && len(local.MissingEpisodes) == 0 {
- if stats != nil {
- stats.SeriesComplete = true
- }
- return recordPreparedSelection(nil, stats)
- }
- missingSet := missingEpisodeSet(local)
- onlyMissing := make([]siteSearchCandidate, 0, len(candidates))
- var packFallback *siteSearchCandidate
- for i := range candidates {
- candidate := candidates[i]
- if candidate.Episode <= 0 {
- // 整季/全集包(无单集号)。剧集完结后站点常只挂全集包,
- // 这里记下来作兜底:当单集候选不足以补齐缺失集时启用,
- // 否则"补全缺失集"在站点只有全集包时永远匹配为空。
- if stats != nil {
- stats.NoEpisodeSkipped++
- }
- if candidate.Pack && packFallback == nil {
- packFallback = &candidates[i]
- if stats != nil {
- stats.PackFallbackAvailable = true
- }
- }
- continue
- }
- season := candidate.Season
- if season <= 0 {
- season = 1
- }
- if candidateEpisodesAllExist(local.ExistingEpisodeKeys, season, candidate) {
- if stats != nil {
- stats.ExistingEpisodeSkipped++
- }
- continue
- }
- if trustedTotal > 0 && !candidateCoversMissingEpisode(candidate, missingSet) {
- if stats != nil {
- stats.NotMissingEpisodeSkipped++
- }
- continue
- }
- onlyMissing = append(onlyMissing, candidate)
- }
- selected := sortedEpisodeCandidates(onlyMissing)
- if len(selected) == 0 && packFallback != nil {
- // 没有可用的单集候选,但站点有整季/全集包 → 用包兜底补缺集。
- // 代价是会重下已有集,但用户主动触发补全时这是可接受的。
- if stats != nil {
- stats.PackFallbackUsed = true
- }
- return recordPreparedSelection([]siteSearchCandidate{*packFallback}, stats)
- }
- return recordPreparedSelection(selected, stats)
- }
-
- for _, candidate := range candidates {
- if candidate.Pack {
- return recordPreparedSelection([]siteSearchCandidate{candidate}, stats)
- }
- }
-
- selected := sortedEpisodeCandidates(candidates)
- if len(selected) == 0 {
- return recordPreparedSelection(candidates[:1], stats)
- }
- return recordPreparedSelection(selected, stats)
-}
-
-func candidateEpisodesAllExist(existing map[string]struct{}, season int, candidate siteSearchCandidate) bool {
- episodes := candidateEpisodeNumbers(candidate)
- if len(episodes) == 0 {
- return false
- }
- for _, episode := range episodes {
- if _, exists := existing[episodeKey(season, episode)]; !exists {
- return false
- }
- }
- return true
-}
-
-func candidateCoversMissingEpisode(candidate siteSearchCandidate, missingSet map[int]struct{}) bool {
- episodes := candidateEpisodeNumbers(candidate)
- if len(episodes) == 0 {
- return false
- }
- for _, episode := range episodes {
- if _, missing := missingSet[episode]; missing {
- return true
- }
- }
- return false
-}
-
-func candidateEpisodeNumbers(candidate siteSearchCandidate) []int {
- if len(candidate.Episodes) > 0 {
- return candidate.Episodes
- }
- if candidate.Episode > 0 {
- return []int{candidate.Episode}
- }
- return nil
-}
-
-func recordPreparedSelection(candidates []siteSearchCandidate, stats *siteSearchSelectionStats) []siteSearchCandidate {
- if stats != nil {
- stats.Selected = len(candidates)
- }
- return candidates
-}
-
-func localSeriesPackSatisfiesSubscription(local LocalAvailability) bool {
- if !local.HasSeriesPack {
- return false
- }
- total := trustedAvailabilityTotal(local)
- if total <= 0 {
- return len(local.ExistingEpisodeKeys) == 0
- }
- if len(local.MissingEpisodes) > 0 {
- return false
- }
- return len(local.ExistingEpisodeKeys) >= total
-}
-
-func trustedAvailabilityTotal(local LocalAvailability) int {
- total := local.TotalEpisodes
- if total <= 0 {
- return 0
- }
- if maxEpisode := maxAvailabilityEpisode(local.ExistingEpisodeKeys); maxEpisode > total {
- return 0
- }
- return total
-}
diff --git a/internal/service/subscription_candidate_selection_test.go b/internal/service/subscription_candidate_selection_test.go
deleted file mode 100644
index 6fee3bc..0000000
--- a/internal/service/subscription_candidate_selection_test.go
+++ /dev/null
@@ -1,191 +0,0 @@
-package service
-
-import (
- "strings"
- "testing"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func TestSelectSiteSearchCandidatesPrefersSeriesPack(t *testing.T) {
- sub := &model.Subscription{Name: "间谍过家家 自动订阅", Filter: "间谍过家家 2022", MediaType: "tv"}
- results := []SearchResult{
- {Title: "间谍过家家 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 80},
- {Title: "间谍过家家 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 50},
- {Title: "间谍过家家 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 70},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 {
- t.Fatalf("selected %d candidates, want 1", len(got))
- }
- if got[0].Download != "https://pt/download/pack" || !got[0].Pack {
- t.Fatalf("selected %#v, want complete pack", got[0])
- }
-}
-
-func TestSelectSiteSearchCandidatesQueuesDistinctEpisodesWhenNoPack(t *testing.T) {
- sub := &model.Subscription{Name: "葬送的芙莉莲 自动订阅", Filter: "葬送的芙莉莲", MediaType: "anime", WashEnabled: true, WashPriority: "resolution"}
- results := []SearchResult{
- {Title: "葬送的芙莉莲 S01E01 1080p", DownloadURL: "https://pt/download/1a", Seeders: 90},
- {Title: "葬送的芙莉莲 S01E01 2160p", DownloadURL: "https://pt/download/1b", Seeders: 80},
- {Title: "葬送的芙莉莲 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 70},
- {Title: "葬送的芙莉莲 S01E03 1080p", DownloadURL: "https://pt/download/3", Seeders: 60},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 3 {
- t.Fatalf("selected %d candidates, want 3", len(got))
- }
- if got[0].Episode != 1 || got[1].Episode != 2 || got[2].Episode != 3 {
- t.Fatalf("episodes = %d,%d,%d; want 1,2,3", got[0].Episode, got[1].Episode, got[2].Episode)
- }
- if got[0].Download != "https://pt/download/1b" {
- t.Fatalf("duplicate episode should keep wash-priority best result, got %q", got[0].Download)
- }
-}
-
-func TestSelectSiteSearchCandidatesKeepsMovieSingleBest(t *testing.T) {
- sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", WashPriority: "seeders"}
- results := []SearchResult{
- {Title: "Inception 2010 1080p HDTV", DownloadURL: "https://pt/download/1080-hdtv", Seeders: 900},
- {Title: "Inception 2010 2160p WEB-DL", DownloadURL: "https://pt/download/2160-webdl", Seeders: 80},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 || got[0].Download != "https://pt/download/2160-webdl" {
- t.Fatalf("selected %#v, want movie best only", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesRejectsUnrelatedHighSeederResult(t *testing.T) {
- sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", WashPriority: "seeders"}
- results := []SearchResult{
- {Title: "Unrelated Movie 2026 2160p", DownloadURL: "https://pt/download/wrong", Seeders: 999},
- {Title: "Inception 2010 1080p", DownloadURL: "https://pt/download/right", Seeders: 90},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 || got[0].Download != "https://pt/download/right" {
- t.Fatalf("selected %#v, want title-matched result only", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesMatchesTranslatedSubtitle(t *testing.T) {
- sub := &model.Subscription{Name: "真人快打2 自动订阅", Filter: "真人快打2 2026", MediaType: "movie", WashPriority: "seeders"}
- results := []SearchResult{
- {Title: "Unrelated Movie 2026 2160p", DownloadURL: "https://pt/download/wrong", Seeders: 999},
- {Title: "Mortal Kombat II 2026 1080p WEB-DL", Subtitle: "真人快打2", DownloadURL: "https://pt/download/right", Seeders: 90},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 || got[0].Download != "https://pt/download/right" {
- t.Fatalf("selected %#v, want translated subtitle match", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesMatchesFeedAlias(t *testing.T) {
- sub := &model.Subscription{
- Name: "真人快打2 自动订阅",
- FeedURL: "site-search://search?keyword=%E7%9C%9F%E4%BA%BA%E5%BF%AB%E6%89%932%202026&alias=Mortal%20Kombat%20II%202026",
- Filter: "真人快打2 2026",
- MediaType: "movie",
- }
- results := []SearchResult{
- {Title: "Mortal Kombat II 2026 1080p WEB-DL", DownloadURL: "https://pt/download/right", Seeders: 90},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 || got[0].Download != "https://pt/download/right" {
- t.Fatalf("selected %#v, want alias-matched result", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesMatchesSubscriptionOriginalNameAlias(t *testing.T) {
- sub := &model.Subscription{
- Name: "玩具总动员 5 自动订阅",
- Filter: "玩具总动员 5 2026",
- OriginalName: "Toy Story 5",
- Year: 2026,
- MediaType: "movie",
- }
- results := []SearchResult{
- {Title: "Toy Story 5 2026 1080p WEB-DL", DownloadURL: "https://pt/download/right", Seeders: 90},
- }
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
- if len(got) != 1 || got[0].Download != "https://pt/download/right" {
- t.Fatalf("selected %#v, want original-name alias match", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesTrustsMatchedSearchKeyword(t *testing.T) {
- sub := &model.Subscription{
- Name: "南部档案 自动订阅",
- Filter: "南部档案",
- MediaType: "tv",
- TotalEpisodes: 33,
- }
- results := []SearchResult{{
- Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL",
- DownloadURL: "https://pt/download/nanyang-29-33",
- SearchKeyword: "南部档案 2026",
- Seeders: 90,
- }}
- availability := LocalAvailability{
- TotalEpisodes: 33,
- ExistingEpisodeKeys: map[string]struct{}{
- episodeKey(1, 1): {},
- },
- MissingEpisodes: []int{2, 3, 4, 5},
- }
-
- got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability)
- if len(got) != 1 || got[0].Download != "https://pt/download/nanyang-29-33" || !got[0].Pack {
- t.Fatalf("selected %#v, want English pack matched by Chinese search keyword", got)
- }
- if stats.QueryMismatch != 0 || stats.Prepared != 1 || stats.Selected != 1 {
- t.Fatalf("stats = %#v, want keyword-origin match without query mismatch", stats)
- }
-}
-
-func TestDedupeSiteSearchResultsKeepsMatchedSearchKeyword(t *testing.T) {
- sub := &model.Subscription{
- Name: "南部档案 自动订阅",
- Filter: "南部档案",
- MediaType: "tv",
- TotalEpisodes: 33,
- }
- results := dedupeSiteSearchResults([]SearchResult{
- {
- SiteID: "mteam",
- Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL",
- DownloadURL: "https://pt/download/nanyang-29-33",
- SearchKeyword: "Archives The Nanyang Mystery",
- Seeders: 80,
- Size: 1024,
- },
- {
- SiteID: "mteam",
- Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL",
- DownloadURL: "https://pt/download/nanyang-29-33",
- SearchKeyword: "南部档案 2026",
- Seeders: 80,
- Size: 1024,
- },
- })
- if len(results) != 1 {
- t.Fatalf("deduped results = %#v, want one merged result", results)
- }
- if !strings.Contains(results[0].SearchKeyword, "南部档案 2026") {
- t.Fatalf("merged search keyword = %q, missing Chinese keyword", results[0].SearchKeyword)
- }
- availability := LocalAvailability{TotalEpisodes: 33, MissingEpisodes: []int{29, 30, 31, 32, 33}, ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}}}
- got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability)
- if len(got) != 1 || got[0].Download != "https://pt/download/nanyang-29-33" {
- t.Fatalf("selected %#v, want merged keyword candidate", got)
- }
- if stats.QueryMismatch != 0 || stats.Prepared != 1 {
- t.Fatalf("stats = %#v, want merged keyword to avoid query mismatch", stats)
- }
-}
diff --git a/internal/service/subscription_classifier.go b/internal/service/subscription_classifier.go
deleted file mode 100644
index fd20a24..0000000
--- a/internal/service/subscription_classifier.go
+++ /dev/null
@@ -1,172 +0,0 @@
-package service
-
-import (
- "context"
- "strings"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *SubscriptionService) classifySubscriptionItem(ctx context.Context, sub *model.Subscription, title, sourceCategory string) (string, string) {
- mediaType := normalizeMediaType(sub.MediaType, title+" "+sub.Name+" "+sub.Filter, sourceCategory)
- category := strings.TrimSpace(sub.MediaCategory)
- if category == "" {
- if match := s.lookupSubscriptionMetadata(ctx, mediaType, title, sub); match != nil {
- category = classifyMediaCategory(mediaClassifyInput{
- MediaType: mediaType,
- Title: match.Title + " " + match.OriginalName,
- Languages: match.Languages,
- Countries: match.Countries,
- Genres: match.Genres,
- Category: sourceCategory,
- }, s.categoryMap())
- if s != nil && s.log != nil && category != "" {
- s.log.Info("subscription metadata classified",
- zap.String("title", title),
- zap.String("matched_title", match.Title),
- zap.String("media_type", mediaType),
- zap.String("media_category", category),
- zap.Int("tmdb_id", match.TMDbID),
- zap.Int("bangumi_id", match.BangumiID),
- zap.String("douban_id", match.DoubanID),
- zap.String("thetvdb_id", match.TheTVDBID))
- }
- }
- }
- if category == "" {
- category = classifyMediaCategory(mediaClassifyInput{
- MediaType: mediaType,
- Title: title + " " + sub.Name + " " + sub.Filter,
- Category: sourceCategory,
- }, s.categoryMap())
- }
- return mediaType, category
-}
-
-func (s *SubscriptionService) lookupSubscriptionMetadata(ctx context.Context, mediaType, title string, sub *model.Subscription) *Match {
- if s == nil || s.scraper == nil || !s.scraper.AnyEnabled() {
- return nil
- }
- queries := subscriptionMetadataQueries(title, sub)
- if len(queries) == 0 {
- return nil
- }
- for _, libType := range subscriptionMetadataLibraryTypes(mediaType, title) {
- lib := &model.Library{Type: libType, Enabled: true}
- for _, query := range queries {
- cleaned, year := CleanQueryWithRecognition(ctx, s.repo, query)
- if cleaned == "" {
- cleaned = strings.TrimSpace(query)
- }
- for _, candidate := range titleCandidates(cleaned) {
- if candidate == "" {
- continue
- }
- match := s.scraper.lookup(ctx, lib, nil, candidate, year)
- if match == nil || strings.TrimSpace(match.Title) == "" {
- continue
- }
- if !organizeMetadataMatchTrusted(candidate, year, match) {
- continue
- }
- return match
- }
- }
- }
- return nil
-}
-
-func subscriptionMetadataQueries(title string, sub *model.Subscription) []string {
- seen := map[string]struct{}{}
- out := make([]string, 0, 3)
- add := func(value string) {
- value = strings.TrimSpace(value)
- if value == "" {
- return
- }
- key := strings.ToLower(value)
- if _, ok := seen[key]; ok {
- return
- }
- seen[key] = struct{}{}
- out = append(out, value)
- }
- add(title)
- if sub != nil {
- add(sub.Filter)
- add(sub.Name)
- }
- return out
-}
-
-func subscriptionMetadataLibraryTypes(mediaType, title string) []string {
- if strings.TrimSpace(mediaType) == "" {
- text := strings.ToLower(title)
- switch {
- case classifierEpisodeRE.MatchString(text) || classifierSeasonRE.MatchString(text):
- return []string{"tv", "anime", "movie"}
- case containsAnyText(text, "动漫", "动画", "anime", "bangumi"):
- return []string{"anime", "tv", "movie"}
- case containsAnyText(text, "电影", "movie", "film"):
- return []string{"movie", "tv", "anime"}
- default:
- return []string{"tv", "movie", "anime"}
- }
- }
- switch normalizeMediaType(mediaType, title, "") {
- case "movie":
- return []string{"movie"}
- case "anime":
- return []string{"anime", "tv"}
- case "tv", "variety":
- return []string{"tv", "anime"}
- default:
- if classifierEpisodeRE.MatchString(title) || classifierSeasonRE.MatchString(title) {
- return []string{"tv", "anime"}
- }
- return []string{"movie", "tv", "anime"}
- }
-}
-
-func (s *SubscriptionService) categoryMap() map[string]string {
- if s == nil || s.cfg == nil || s.cfg.Organizer.Categories == nil {
- return nil
- }
- return s.cfg.Organizer.Categories
-}
-
-func (s *SubscriptionService) resolveSubscriptionSavePath(ctx context.Context, sub *model.Subscription, mediaType, category string) string {
- if sub == nil {
- return ""
- }
- base := strings.TrimSpace(sub.SavePath)
- if base == "" {
- base = downloadDefaultSaveRoot(ctx, s.repo)
- }
- if base == "" {
- return ""
- }
- if !s.isSmartClassifyEnabled(ctx) || category == "" {
- return base
- }
- return downloadSavePathCategoryRoot(base, sanitizeFilename(category))
-}
-
-func (s *SubscriptionService) isSmartClassifyEnabled(ctx context.Context) bool {
- if s != nil && s.repo != nil && s.repo.Setting != nil {
- val, err := s.repo.Setting.Get(ctx, DownloadSmartClassifySettingKey)
- if err == nil && val != "" {
- return parseBoolSetting(val, true)
- }
- val, err = s.repo.Setting.Get(ctx, "organizer.smart_classify")
- if err == nil && parseBoolSetting(val, false) {
- return true
- }
- }
- if s != nil && s.cfg != nil && s.cfg.Organizer.SmartClassify {
- return true
- }
- return true
-}
diff --git a/internal/service/subscription_delete.go b/internal/service/subscription_delete.go
deleted file mode 100644
index 115f2fd..0000000
--- a/internal/service/subscription_delete.go
+++ /dev/null
@@ -1,131 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "gorm.io/gorm"
-)
-
-func (s *SubscriptionService) deleteSubscriptionDownloads(ctx context.Context, sub *model.Subscription) error {
- if s == nil || s.repo == nil || s.repo.Download == nil || sub == nil {
- return nil
- }
- rows, err := s.repo.Download.List(ctx)
- if err != nil {
- return err
- }
- candidates := make([]model.DownloadTask, 0)
- for _, row := range rows {
- if subscriptionDeleteMatchesTask(ctx, s, sub, row) {
- candidates = append(candidates, row)
- }
- }
- if len(candidates) == 0 {
- return nil
- }
-
- var live []QBitTorrent
- if s.downloads != nil {
- live, _ = s.downloads.listLiveTorrents(ctx, "")
- }
- deletedHashes := map[string]struct{}{}
- for _, task := range candidates {
- hash := firstNonEmpty(task.ExternalID, downloadTaskInfoHash(task))
- clientID := strings.TrimSpace(task.DownloadClientID)
- if matched, ok := matchingLiveTorrent(task, live); ok {
- hash = firstNonEmpty(hash, matched.Hash)
- clientID = firstNonEmpty(clientID, matched.ClientID)
- }
- if hash != "" && s.downloads != nil {
- key := strings.ToLower(clientID + ":" + hash)
- if _, ok := deletedHashes[key]; !ok {
- if err := s.downloads.Delete(ctx, hash, false, clientID); err != nil {
- return fmt.Errorf("删除订阅关联下载任务 %q 失败: %w", task.Title, err)
- }
- deletedHashes[key] = struct{}{}
- }
- continue
- }
- markDownloadTaskDeletedByID(ctx, s.repo.DB, task)
- }
- return nil
-}
-
-func subscriptionDeleteMatchesTask(ctx context.Context, s *SubscriptionService, sub *model.Subscription, task model.DownloadTask) bool {
- if strings.TrimSpace(task.Status) != "" && !downloadTaskBlocksReadd(task.Status) {
- return false
- }
- if strings.TrimSpace(task.SubscriptionID) != "" {
- return task.SubscriptionID == sub.ID
- }
- if strings.TrimSpace(sub.UserID) != "" && strings.TrimSpace(task.UserID) != "" && sub.UserID != task.UserID {
- return false
- }
- baseSavePath := s.subscriptionBaseSavePath(ctx, sub)
- if baseSavePath != "" && task.SavePath != "" && !sameOrChildPath(task.SavePath, baseSavePath) && !sameOrChildPath(baseSavePath, task.SavePath) {
- return false
- }
- query := normalizeAvailabilityComparable(availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)))
- if query == "" {
- return false
- }
- title := normalizeAvailabilityComparable(task.Title)
- if title == "" {
- title = normalizeAvailabilityComparable(publicDownloadTitle(task.URL))
- }
- return title != "" && (strings.Contains(title, query) || strings.Contains(query, title))
-}
-
-func downloadTaskInfoHash(task model.DownloadTask) string {
- return torrentURLInfoHash(task.URL)
-}
-
-func matchingLiveTorrentHash(task model.DownloadTask, live []QBitTorrent) string {
- if torrent, ok := matchingLiveTorrent(task, live); ok {
- return strings.TrimSpace(torrent.Hash)
- }
- return ""
-}
-
-func matchingLiveTorrent(task model.DownloadTask, live []QBitTorrent) (QBitTorrent, bool) {
- for _, torrent := range live {
- if strings.TrimSpace(task.DownloadClientID) != "" && task.DownloadClientID != torrent.ClientID {
- continue
- }
- if strings.TrimSpace(task.ExternalID) != "" && strings.EqualFold(task.ExternalID, torrent.Hash) {
- return torrent, true
- }
- }
- key := downloadTaskIdentityKey(task.Title)
- if key == "" {
- key = downloadTaskIdentityKey(publicDownloadTitle(task.URL))
- }
- if key == "" {
- return QBitTorrent{}, false
- }
- for _, torrent := range live {
- current := downloadTaskIdentityKey(torrent.Name)
- if current == "" {
- continue
- }
- if current == key || strings.Contains(current, key) || strings.Contains(key, current) {
- return torrent, true
- }
- }
- return QBitTorrent{}, false
-}
-
-func markDownloadTaskDeletedByID(ctx context.Context, db *gorm.DB, task model.DownloadTask) {
- if db == nil || strings.TrimSpace(task.ID) == "" {
- return
- }
- _ = db.WithContext(ctx).Model(&model.DownloadTask{}).
- Where("id = ?", task.ID).
- Updates(map[string]any{
- "status": "deleted",
- "progress": task.Progress,
- }).Error
-}
diff --git a/internal/service/subscription_downloader_test.go b/internal/service/subscription_downloader_test.go
deleted file mode 100644
index f273cfa..0000000
--- a/internal/service/subscription_downloader_test.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package service
-
-import (
- "net/http"
- "net/http/httptest"
- "sync/atomic"
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestSubscriptionRunOneDoesNotUseDeletedDownloader(t *testing.T) {
- rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/rss+xml")
- _, _ = w.Write([]byte(`
-
- -
- Deleted Downloader Show S01E01 1080p
- deleted-downloader-episode-1
- magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&dn=Deleted+Downloader+Show+S01E01
-
-`))
- }))
- defer rss.Close()
-
- var qbCalls int32
- qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- atomic.AddInt32(&qbCalls, 1)
- switch r.URL.Path {
- case "/api/v2/auth/login":
- _, _ = w.Write([]byte("Ok."))
- case "/api/v2/torrents/info":
- _, _ = w.Write([]byte(`[]`))
- case "/api/v2/torrents/add":
- _, _ = w.Write([]byte("Ok."))
- default:
- http.NotFound(w, r)
- }
- }))
- defer qb.Close()
-
- db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{})
- repos := repository.New(db)
- client := &model.DownloadClient{Name: "qB deleted", Type: "qbittorrent", Host: qb.URL, Username: "admin", Password: "admin", IsDefault: true, Enabled: true}
- if err := repos.DownloadClient.Create(t.Context(), client); err != nil {
- t.Fatal(err)
- }
- if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil {
- t.Fatal(err)
- }
- if err := repos.DownloadClient.Delete(t.Context(), client.ID); err != nil {
- t.Fatal(err)
- }
-
- downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
- svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop()))
- sub := &model.Subscription{
- Name: "Deleted Downloader Show 自动订阅",
- FeedURL: rss.URL,
- Filter: "Deleted Downloader Show",
- MediaType: "tv",
- SavePath: "/downloads/tv",
- }
- if err := repos.Subscription.Create(t.Context(), sub); err != nil {
- t.Fatal(err)
- }
-
- queued, err := svc.runOne(t.Context(), sub)
- if err != nil {
- t.Fatal(err)
- }
- if queued != 0 {
- t.Fatalf("queued = %d, want 0 when default downloader was deleted", queued)
- }
- if got := atomic.LoadInt32(&qbCalls); got != 0 {
- t.Fatalf("qB calls = %d, want 0 after downloader deletion", got)
- }
- rows, err := repos.Download.List(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(rows) != 0 {
- t.Fatalf("download rows = %d, want 0", len(rows))
- }
-}
diff --git a/internal/service/subscription_episode_queries.go b/internal/service/subscription_episode_queries.go
deleted file mode 100644
index f5f2586..0000000
--- a/internal/service/subscription_episode_queries.go
+++ /dev/null
@@ -1,176 +0,0 @@
-package service
-
-import (
- "fmt"
- "net/url"
- "regexp"
- "strconv"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func subscriptionTitleMatchesQuery(sub *model.Subscription, title string) bool {
- if strings.TrimSpace(title) == "" {
- return false
- }
- for _, query := range subscriptionTitleMatchQueries(sub) {
- if strings.Contains(normalizeAvailabilityComparable(title), normalizeAvailabilityComparable(query)) {
- return true
- }
- }
- return len(subscriptionTitleMatchQueries(sub)) == 0
-}
-
-func subscriptionSearchResultMatchesQuery(sub *model.Subscription, item SearchResult) bool {
- matchText := subscriptionSearchResultText(item)
- if subscriptionTitleMatchesQuery(sub, matchText) {
- return true
- }
- if !subscriptionSearchResultYearCompatible(sub, matchText) {
- return false
- }
- return subscriptionKeywordMatchesQuery(sub, item.SearchKeyword)
-}
-
-func subscriptionSearchResultYearCompatible(sub *model.Subscription, title string) bool {
- expected := subscriptionExpectedYear(sub)
- if expected <= 0 {
- return true
- }
- years := titleYears(title)
- if len(years) == 0 {
- return true
- }
- for _, year := range years {
- if year == expected {
- return true
- }
- }
- return false
-}
-
-func subscriptionExpectedYear(sub *model.Subscription) int {
- if sub == nil {
- return 0
- }
- if sub.Year > 0 {
- return sub.Year
- }
- for _, value := range []string{sub.Filter, sub.Name, sub.FeedURL} {
- for _, year := range titleYears(value) {
- return year
- }
- }
- return 0
-}
-
-func titleYears(value string) []int {
- matches := regexp.MustCompile(`\b(19\d{2}|20\d{2})\b`).FindAllString(value, -1)
- if len(matches) == 0 {
- return nil
- }
- out := make([]int, 0, len(matches))
- seen := map[int]struct{}{}
- for _, match := range matches {
- year, err := strconv.Atoi(match)
- if err != nil {
- continue
- }
- if _, ok := seen[year]; ok {
- continue
- }
- seen[year] = struct{}{}
- out = append(out, year)
- }
- return out
-}
-
-func subscriptionKeywordMatchesQuery(sub *model.Subscription, keyword string) bool {
- keyword = strings.TrimSpace(keyword)
- if keyword == "" {
- return false
- }
- normalizedKeyword := normalizeAvailabilityComparable(keyword)
- if normalizedKeyword == "" {
- return false
- }
- for _, query := range subscriptionTitleMatchQueries(sub) {
- normalizedQuery := normalizeAvailabilityComparable(query)
- if normalizedQuery == "" {
- continue
- }
- if strings.Contains(normalizedKeyword, normalizedQuery) || strings.Contains(normalizedQuery, normalizedKeyword) {
- return true
- }
- }
- return len(subscriptionTitleMatchQueries(sub)) == 0
-}
-
-func subscriptionTitleMatchQueries(sub *model.Subscription) []string {
- if sub == nil {
- return nil
- }
- values := []string{
- availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)),
- cleanAvailabilityTitle(subscriptionFilter(sub)),
- cleanAvailabilityTitle(subscriptionName(sub)),
- }
- for _, alias := range subscriptionFeedAliases(sub) {
- values = append(values, alias, cleanAvailabilityTitle(alias))
- }
- for _, alias := range subscriptionMetadataAliases(sub) {
- values = append(values, alias, cleanAvailabilityTitle(alias))
- }
- return compactUniqueStrings(values...)
-}
-
-func subscriptionEpisodeMetadataQueries(sub *model.Subscription) []string {
- if sub == nil {
- return nil
- }
- raw := []string{
- siteSearchKeyword(sub),
- sub.Filter,
- sub.Name,
- availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)),
- }
- out := make([]string, 0, len(raw)*2)
- for _, value := range raw {
- value = cleanAvailabilityTitle(value)
- if value == "" {
- continue
- }
- if cleaned, _ := CleanQuery(value); cleaned != "" {
- out = append(out, cleaned)
- }
- out = append(out, value)
- }
- return compactUniqueStrings(out...)
-}
-
-func subscriptionExplicitTMDbID(sub *model.Subscription) int {
- if sub == nil {
- return 0
- }
- values := []string{sub.Name, sub.Filter, sub.FeedURL}
- for _, raw := range values {
- for _, pattern := range []string{`(?i)\btmdb[_:\-\s=]+(\d{2,})`, `(?i)\btmdbid[_:\-\s=]+(\d{2,})`} {
- if m := regexp.MustCompile(pattern).FindStringSubmatch(raw); len(m) >= 2 {
- var id int
- if _, err := fmt.Sscanf(m[1], "%d", &id); err == nil && id > 0 {
- return id
- }
- }
- }
- if u, err := url.Parse(raw); err == nil {
- for _, key := range []string{"tmdb_id", "tmdb", "tmdbid"} {
- var id int
- if _, err := fmt.Sscanf(u.Query().Get(key), "%d", &id); err == nil && id > 0 {
- return id
- }
- }
- }
- }
- return 0
-}
diff --git a/internal/service/subscription_episode_totals.go b/internal/service/subscription_episode_totals.go
deleted file mode 100644
index 500ecbd..0000000
--- a/internal/service/subscription_episode_totals.go
+++ /dev/null
@@ -1,197 +0,0 @@
-package service
-
-import (
- "context"
- "regexp"
- "strings"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *SubscriptionService) updateSubscriptionTotalEpisodes(ctx context.Context, sub *model.Subscription, total int) {
- if s == nil || s.repo == nil || s.repo.DB == nil || sub == nil || total <= sub.TotalEpisodes {
- return
- }
- sub.TotalEpisodes = total
- _ = s.repo.DB.WithContext(ctx).Model(sub).Update("total_episodes", total).Error
-}
-
-func inferRSSTotalEpisodes(items []rssItem, sub *model.Subscription, filter *regexp.Regexp) int {
- if !subscriptionShouldInferTotal(sub) {
- return 0
- }
- maxEpisode := 0
- for _, item := range items {
- title := strings.TrimSpace(item.Title)
- if title == "" {
- continue
- }
- if filter != nil && !filter.MatchString(title) {
- continue
- }
- if !subscriptionTitleMatchesQuery(sub, title) {
- continue
- }
- if !matchesSubscriptionRules(sub, title) {
- continue
- }
- _, episode := ParseEpisode(title)
- if episode > maxEpisode {
- maxEpisode = episode
- }
- }
- return maxEpisode
-}
-
-func inferSearchTotalEpisodes(results []SearchResult, sub *model.Subscription) int {
- if !subscriptionShouldInferTotal(sub) {
- return 0
- }
- maxEpisode := 0
- for _, item := range results {
- matchText := subscriptionSearchResultText(item)
- if !subscriptionSearchResultMatchesQuery(sub, item) {
- continue
- }
- if !matchesSubscriptionRules(sub, matchText) {
- continue
- }
- _, episode := ParseEpisode(matchText)
- if episode > maxEpisode {
- maxEpisode = episode
- }
- }
- return maxEpisode
-}
-
-func subscriptionShouldInferTotal(sub *model.Subscription) bool {
- if sub == nil {
- return false
- }
- mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "")
- return isSubscriptionSeriesType(mediaType)
-}
-
-func (s *SubscriptionService) resolveSubscriptionTotalEpisodes(ctx context.Context, sub *model.Subscription, fallback int) int {
- if !subscriptionShouldInferTotal(sub) {
- return 0
- }
- if sub.TotalEpisodes > 0 {
- return sub.TotalEpisodes
- }
- if total := s.resolveSubscriptionMetadataTotalEpisodes(ctx, sub); total > 0 {
- return total
- }
- return fallback
-}
-
-func (s *SubscriptionService) resolveSubscriptionMetadataTotalEpisodes(ctx context.Context, sub *model.Subscription) int {
- if s == nil || s.scraper == nil || sub == nil {
- return 0
- }
- queries := subscriptionEpisodeMetadataQueries(sub)
-
- // Priority: TMDb -> Douban -> Bangumi -> TheTVDB -> Fanart -> title fallback.
- // Fanart.tv is artwork-only in MediaStationGo, so it intentionally does not
- // claim episode counts and lets the title fallback handle the final layer.
- if s.scraper.tmdb != nil {
- if id := subscriptionExplicitTMDbID(sub); id > 0 {
- if total, err := s.scraper.tmdb.GetTVEpisodeCount(ctx, id); err == nil && total > 0 {
- return total
- } else if err != nil && s.log != nil {
- s.log.Debug("subscription tmdb episode count failed", zap.Int("tmdb_id", id), zap.Error(err))
- }
- }
- for _, query := range queries {
- match, err := s.scraper.tmdb.SearchTV(ctx, query, 0)
- if err != nil {
- if s.log != nil {
- s.log.Debug("subscription tmdb search failed", zap.String("query", query), zap.Error(err))
- }
- continue
- }
- if match == nil || match.TMDbID <= 0 {
- continue
- }
- total, err := s.scraper.tmdb.GetTVEpisodeCount(ctx, match.TMDbID)
- if err != nil {
- if s.log != nil {
- s.log.Debug("subscription tmdb episode count failed", zap.Int("tmdb_id", match.TMDbID), zap.Error(err))
- }
- continue
- }
- if total > 0 {
- return total
- }
- }
- }
-
- if s.scraper.douban != nil {
- for _, query := range queries {
- total, err := s.scraper.douban.GetEpisodeCount(ctx, query)
- if err != nil {
- if s.log != nil {
- s.log.Debug("subscription douban episode count failed", zap.String("query", query), zap.Error(err))
- }
- continue
- }
- if total > 0 {
- return total
- }
- }
- }
-
- if s.scraper.bangumi != nil {
- for _, query := range queries {
- match, err := s.scraper.bangumi.Search(ctx, query)
- if err != nil {
- if s.log != nil {
- s.log.Debug("subscription bangumi search failed", zap.String("query", query), zap.Error(err))
- }
- continue
- }
- if match == nil || match.BangumiID <= 0 {
- continue
- }
- total, err := s.scraper.bangumi.GetEpisodeCount(ctx, match.BangumiID)
- if err != nil {
- if s.log != nil {
- s.log.Debug("subscription bangumi episode count failed", zap.Int("bangumi_id", match.BangumiID), zap.Error(err))
- }
- continue
- }
- if total > 0 {
- return total
- }
- }
- }
-
- if s.scraper.thetvdb != nil {
- for _, query := range queries {
- match, err := s.scraper.thetvdb.SearchSeries(ctx, query)
- if err != nil {
- if s.log != nil {
- s.log.Debug("subscription thetvdb search failed", zap.String("query", query), zap.Error(err))
- }
- continue
- }
- if match == nil || strings.TrimSpace(match.TheTVDBID) == "" {
- continue
- }
- total, err := s.scraper.thetvdb.GetSeriesEpisodeCount(ctx, match.TheTVDBID)
- if err != nil {
- if s.log != nil {
- s.log.Debug("subscription thetvdb episode count failed", zap.String("thetvdb_id", match.TheTVDBID), zap.Error(err))
- }
- continue
- }
- if total > 0 {
- return total
- }
- }
- }
-
- return 0
-}
diff --git a/internal/service/subscription_episode_totals_test.go b/internal/service/subscription_episode_totals_test.go
deleted file mode 100644
index c10b17e..0000000
--- a/internal/service/subscription_episode_totals_test.go
+++ /dev/null
@@ -1,64 +0,0 @@
-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"
-)
-
-func TestInferSubscriptionTotalEpisodesFromSearchAndRSS(t *testing.T) {
- sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"}
- results := []SearchResult{
- {Title: "Some Show S01E01 1080p"},
- {Title: "Some Show S01E12 1080p"},
- {Title: "Other Show S01E99 1080p"},
- }
- if got := inferSearchTotalEpisodes(results, sub); got != 12 {
- t.Fatalf("search inferred total = %d, want 12", got)
- }
- subtitleResults := []SearchResult{
- {Title: "Smoking Behind the Supermarket with You", Subtitle: "躲在超市后门抽烟的两人 S01E12"},
- }
- subtitleSub := &model.Subscription{Name: "躲在超市后门抽烟的两人 自动订阅", Filter: "躲在超市后门抽烟的两人", MediaType: "tv"}
- if got := inferSearchTotalEpisodes(subtitleResults, subtitleSub); got != 12 {
- t.Fatalf("subtitle search inferred total = %d, want 12", got)
- }
- items := []rssItem{
- {Title: "Some Show S01E02 WEB-DL"},
- {Title: "Some Show S01E10 WEB-DL"},
- }
- if got := inferRSSTotalEpisodes(items, sub, compileFilter("Some Show")); got != 10 {
- t.Fatalf("rss inferred total = %d, want 10", got)
- }
-}
-
-func TestResolveSubscriptionTotalEpisodesPrefersTMDbOverTitleFallback(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/search/tv":
- _, _ = w.Write([]byte(`{"results":[{"id":42,"name":"Some Show","first_air_date":"2026-01-01"}]}`))
- case "/tv/42":
- _, _ = w.Write([]byte(`{"number_of_episodes":13}`))
- default:
- http.NotFound(w, r)
- }
- }))
- defer upstream.Close()
-
- cfg := &config.Config{}
- cfg.Secrets.TMDbAPIKey = "test"
- cfg.Secrets.TMDbAPIProxy = upstream.URL
- tmdb := NewTMDbProvider(cfg, zap.NewNop(), nil)
- svc := NewSubscriptionService(cfg, zap.NewNop(), nil, nil, nil, NewHub(zap.NewNop()))
- svc.SetScraper(NewScraperService(cfg, zap.NewNop(), nil, tmdb, nil, nil, nil, NewHub(zap.NewNop())))
-
- sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"}
- if got := svc.resolveSubscriptionTotalEpisodes(t.Context(), sub, 10); got != 13 {
- t.Fatalf("resolved total = %d, want TMDb total 13", got)
- }
-}
diff --git a/internal/service/subscription_exclude_rules.go b/internal/service/subscription_exclude_rules.go
deleted file mode 100644
index 90cd4fe..0000000
--- a/internal/service/subscription_exclude_rules.go
+++ /dev/null
@@ -1,187 +0,0 @@
-package service
-
-import (
- "strings"
- "unicode"
-)
-
-// defaultExcludeWords 是默认过滤的「垃圾版本」排除清单,对所有订阅生效。
-// 拉丁词在 containsAnyExcludeToken 里按词边界匹配以避免子串误伤。
-const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample,hr,h&r,hit and run,hit&run,hit-and-run,禁转,禁止转载,禁下,禁止下载"
-
-// defaultCompatibilityExcludeWords 是面向自动订阅的兼容性默认排除清单。
-// 仅在用户未真正自定义排除词时启用,避免默认命中 DoVi/H.265/10bit/杜比音轨等版本。
-const defaultCompatibilityExcludeWords = "dovi,dv,dolby vision,dolby,杜比视界,杜比,h265,h.265,h-265,h_265,h 265,hevc,x265,10bit,10-bit,10 bit,hi10p,atmos,truehd,ddp,dd+,eac3"
-
-func containsAnyToken(titleFold, csv string) bool {
- for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
- return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
- }) {
- token = strings.TrimSpace(token)
- if token != "" && strings.Contains(titleFold, token) {
- return true
- }
- }
- return false
-}
-
-// containsAnyExcludeToken 用于排除词匹配:纯 ASCII 字母数字的词按词边界匹配(避免 "ts"
-// 误伤 "tsukihime"、"cam" 误伤 "camp" 之类的子串误判),含 CJK/符号的词仍按子串匹配。
-func containsAnyExcludeToken(titleFold, csv string) bool {
- for _, token := range excludeWordTokens(csv) {
- if matchesExcludeToken(titleFold, token) {
- return true
- }
- }
- return false
-}
-
-func excludeWordTokens(csv string) []string {
- parts := make([]string, 0)
- for _, token := range strings.FieldsFunc(strings.ToLower(csv), isExcludeSeparator) {
- token = strings.TrimSpace(token)
- if token == "" {
- continue
- }
- parts = append(parts, token)
- if shouldExpandDottedExcludeToken(token) {
- parts = append(parts, dottedExcludeTokenParts(token)...)
- }
- }
- return parts
-}
-
-func isExcludeSeparator(r rune) bool {
- switch r {
- case ',', '/', '|', ';', ',', '、', '\n', '\r', '\t':
- return true
- default:
- return false
- }
-}
-
-func shouldExpandDottedExcludeToken(token string) bool {
- return strings.Count(token, ".") >= 2
-}
-
-func dottedExcludeTokenParts(token string) []string {
- rawParts := strings.Split(token, ".")
- parts := make([]string, 0, len(rawParts))
- for _, part := range rawParts {
- part = strings.TrimSpace(part)
- if len(part) < 2 || isDigitsOnly(part) {
- continue
- }
- parts = append(parts, part)
- }
- return parts
-}
-
-func isDigitsOnly(value string) bool {
- if value == "" {
- return false
- }
- for _, r := range value {
- if !unicode.IsDigit(r) {
- return false
- }
- }
- return true
-}
-
-func matchesExcludeToken(titleFold, token string) bool {
- if token == "" {
- return false
- }
- if isASCIIWordToken(token) {
- return matchesWordBoundary(titleFold, token) || matchesReleasePrefixToken(titleFold, token)
- }
- return strings.Contains(titleFold, token)
-}
-
-func isASCIIWordToken(token string) bool {
- for _, r := range token {
- if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r)) {
- return false
- }
- }
- return token != ""
-}
-
-// matchesWordBoundary 判断 token 是否作为独立词出现在 title 中,词边界为「非字母数字」。
-func matchesWordBoundary(titleFold, token string) bool {
- from := 0
- for {
- idx := strings.Index(titleFold[from:], token)
- if idx < 0 {
- return false
- }
- start := from + idx
- end := start + len(token)
- leftOK := start == 0 || !isASCIIAlnumByte(titleFold[start-1])
- rightOK := end >= len(titleFold) || !isASCIIAlnumByte(titleFold[end])
- if leftOK && rightOK {
- return true
- }
- from = start + 1
- if from >= len(titleFold) {
- return false
- }
- }
-}
-
-func matchesReleasePrefixToken(titleFold, token string) bool {
- if !isReleasePrefixExcludeToken(token) {
- return false
- }
- from := 0
- for {
- idx := strings.Index(titleFold[from:], token)
- if idx < 0 {
- return false
- }
- start := from + idx
- end := start + len(token)
- leftOK := start == 0 || !isASCIIAlnumByte(titleFold[start-1])
- if leftOK && releasePrefixSuffixOK(token, titleFold[end:]) {
- return true
- }
- from = start + 1
- if from >= len(titleFold) {
- return false
- }
- }
-}
-
-func isReleasePrefixExcludeToken(token string) bool {
- switch token {
- case "ddp", "dolby":
- return true
- default:
- return false
- }
-}
-
-func releasePrefixSuffixOK(token, suffix string) bool {
- if suffix == "" {
- return false
- }
- switch token {
- case "ddp":
- return isASCIIDigitByte(suffix[0])
- case "dolby":
- return strings.HasPrefix(suffix, "vision") ||
- strings.HasPrefix(suffix, "atmos") ||
- strings.HasPrefix(suffix, "digital")
- default:
- return false
- }
-}
-
-func isASCIIAlnumByte(b byte) bool {
- return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || isASCIIDigitByte(b)
-}
-
-func isASCIIDigitByte(b byte) bool {
- return b >= '0' && b <= '9'
-}
diff --git a/internal/service/subscription_guid.go b/internal/service/subscription_guid.go
deleted file mode 100644
index 08a474b..0000000
--- a/internal/service/subscription_guid.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package service
-
-import (
- "fmt"
- "net/url"
- "strings"
-)
-
-func stableRSSItemGUID(title, guid, link, enclosureURL string) string {
- parts := []string{"rss", strings.ToLower(strings.TrimSpace(title))}
- for _, raw := range []string{guid, enclosureURL, link} {
- if key := stableDownloadURLKey(raw); key != "" {
- parts = append(parts, key)
- return strings.Join(parts, "|")
- }
- if raw = strings.TrimSpace(raw); raw != "" {
- parts = append(parts, strings.ToLower(raw))
- return strings.Join(parts, "|")
- }
- }
- return strings.Join(parts, "|")
-}
-
-func stableSiteSearchGUID(item SearchResult, download string) string {
- parts := []string{
- "site",
- strings.ToLower(strings.TrimSpace(firstNonEmpty(item.SiteID, item.SiteName))),
- strings.ToLower(strings.TrimSpace(item.Category)),
- strings.ToLower(strings.TrimSpace(item.Title)),
- fmt.Sprintf("%d", item.Size),
- }
- if key := stableDownloadURLKey(download); key != "" {
- parts = append(parts, key)
- }
- return strings.Join(parts, "|")
-}
-
-func stableDownloadURLKey(raw string) string {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return ""
- }
- u, err := url.Parse(raw)
- if err != nil {
- return strings.ToLower(raw)
- }
- if strings.EqualFold(u.Scheme, "magnet") {
- xt := strings.ToLower(strings.TrimSpace(u.Query().Get("xt")))
- if xt != "" {
- return "magnet:" + xt
- }
- return strings.ToLower(raw)
- }
- if u.Host == "" {
- return strings.ToLower(raw)
- }
- q := u.Query()
- kept := make([]string, 0, 4)
- for _, key := range []string{"id", "tid", "torrent", "torrent_id", "torrentid", "hash", "info_hash"} {
- if value := strings.TrimSpace(q.Get(key)); value != "" {
- kept = append(kept, key+"="+strings.ToLower(value))
- }
- }
- base := strings.ToLower(strings.TrimRight(u.Host, "/") + "/" + strings.TrimLeft(u.Path, "/"))
- if len(kept) > 0 {
- return base + "?" + strings.Join(kept, "&")
- }
- return base
-}
diff --git a/internal/service/subscription_identity.go b/internal/service/subscription_identity.go
deleted file mode 100644
index c11dc6b..0000000
--- a/internal/service/subscription_identity.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package service
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-var ErrSubscriptionAlreadyExists = errors.New("subscription already exists")
-
-type SubscriptionAlreadyExistsError struct {
- ExistingID string
-}
-
-func (e *SubscriptionAlreadyExistsError) Error() string {
- if e == nil || e.ExistingID == "" {
- return ErrSubscriptionAlreadyExists.Error()
- }
- return fmt.Sprintf("%s: %s", ErrSubscriptionAlreadyExists, e.ExistingID)
-}
-
-func (e *SubscriptionAlreadyExistsError) Unwrap() error {
- return ErrSubscriptionAlreadyExists
-}
-
-func newSubscriptionAlreadyExistsError(existingID string) error {
- return &SubscriptionAlreadyExistsError{ExistingID: existingID}
-}
-
-func SubscriptionAlreadyExistsID(err error) string {
- var conflict *SubscriptionAlreadyExistsError
- if errors.As(err, &conflict) && conflict != nil {
- return conflict.ExistingID
- }
- return ""
-}
-
-func (s *SubscriptionService) subscriptionDuplicate(ctx context.Context, sub *model.Subscription, excludeID string) (*model.Subscription, error) {
- if s == nil || s.repo == nil || s.repo.Subscription == nil || sub == nil {
- return nil, nil
- }
- return s.repo.Subscription.FindActiveByIdentity(ctx, sub.UserID, sub.IdentityKey, excludeID)
-}
-
-// Update applies API patch fields while recomputing the functional identity.
-// The database partial unique index remains the final concurrency guard.
-func (s *SubscriptionService) Update(ctx context.Context, id string, updates map[string]any) error {
- if s == nil || s.repo == nil || s.repo.DB == nil {
- return errors.New("subscription service unavailable")
- }
- var sub model.Subscription
- if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&sub).Error; err != nil {
- return err
- }
- raw, err := json.Marshal(updates)
- if err != nil {
- return err
- }
- if err := json.Unmarshal(raw, &sub); err != nil {
- return err
- }
- if sub.Name == "" || sub.FeedURL == "" {
- return errors.New("name and feed_url required")
- }
- normalizeSubscriptionDefaults(&sub)
- model.RefreshSubscriptionIdentity(&sub)
- if duplicate, err := s.subscriptionDuplicate(ctx, &sub, sub.ID); err != nil {
- return err
- } else if duplicate != nil {
- return newSubscriptionAlreadyExistsError(duplicate.ID)
- }
-
- updates["search_mode"] = sub.SearchMode
- updates["resolution"] = sub.Resolution
- updates["wash_priority"] = sub.WashPriority
- updates["priority"] = sub.Priority
- updates["identity_key"] = sub.IdentityKey
- if err := s.repo.DB.WithContext(ctx).Model(&model.Subscription{}).
- Where("id = ?", sub.ID).Updates(updates).Error; err != nil {
- if duplicate, lookupErr := s.subscriptionDuplicate(ctx, &sub, sub.ID); lookupErr == nil && duplicate != nil {
- return newSubscriptionAlreadyExistsError(duplicate.ID)
- }
- return err
- }
- return nil
-}
diff --git a/internal/service/subscription_identity_test.go b/internal/service/subscription_identity_test.go
deleted file mode 100644
index ca9bcb6..0000000
--- a/internal/service/subscription_identity_test.go
+++ /dev/null
@@ -1,114 +0,0 @@
-package service
-
-import (
- "errors"
- "path/filepath"
- "sync"
- "testing"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/config"
- "github.com/ShukeBta/MediaStationGo/internal/database"
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestSubscriptionCreateRejectsConcurrentDuplicateRules(t *testing.T) {
- cfg := &config.Config{}
- cfg.Database.Type = "sqlite"
- cfg.Database.DBPath = filepath.Join(t.TempDir(), "subscriptions.db")
- cfg.Database.WALMode = true
- cfg.Database.BusyTimeout = 5000
- cfg.Database.MaxOpenConns = 4
- db, err := database.Open(cfg, zap.NewNop())
- if err != nil {
- t.Fatal(err)
- }
- if sqlDB, dbErr := db.DB(); dbErr == nil {
- t.Cleanup(func() { _ = sqlDB.Close() })
- }
- if err := database.AutoMigrate(db); err != nil {
- t.Fatal(err)
- }
- repos := repository.New(db)
- svc := NewSubscriptionService(cfg, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop()))
-
- start := make(chan struct{})
- results := make(chan error, 2)
- var wg sync.WaitGroup
- for i := 0; i < 2; i++ {
- wg.Add(1)
- go func() {
- defer wg.Done()
- <-start
- results <- svc.Create(t.Context(), &model.Subscription{
- UserID: "user-1",
- Name: "Example Show",
- FeedURL: "site-search://search?keyword=Example+Show",
- Filter: "Example Show",
- MediaType: "tv",
- Resolution: "1080p",
- Enabled: true,
- })
- }()
- }
- close(start)
- wg.Wait()
- close(results)
-
- var created, conflicts int
- for createErr := range results {
- switch {
- case createErr == nil:
- created++
- case errors.Is(createErr, ErrSubscriptionAlreadyExists):
- conflicts++
- default:
- t.Fatalf("unexpected create error: %v", createErr)
- }
- }
- if created != 1 || conflicts != 1 {
- t.Fatalf("created=%d conflicts=%d, want 1/1", created, conflicts)
- }
- var count int64
- if err := db.Model(&model.Subscription{}).Where("archived_at IS NULL").Count(&count).Error; err != nil {
- t.Fatal(err)
- }
- if count != 1 {
- t.Fatalf("active subscription count = %d, want 1", count)
- }
-}
-
-func TestSubscriptionUpdateAndRestoreReturnExistingConflict(t *testing.T) {
- db := newServiceTestDB(t, &model.Subscription{})
- if err := database.AutoMigrate(db); err != nil {
- t.Fatal(err)
- }
- repos := repository.New(db)
- svc := NewSubscriptionService(&config.Config{}, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop()))
- first := &model.Subscription{UserID: "user-1", Name: "Example", FeedURL: "rss://example", Filter: "Example", Resolution: "1080p", Enabled: true}
- second := &model.Subscription{UserID: "user-1", Name: "Example", FeedURL: "rss://example", Filter: "Example", Resolution: "2160p", Enabled: true}
- if err := svc.Create(t.Context(), first); err != nil {
- t.Fatal(err)
- }
- if err := svc.Create(t.Context(), second); err != nil {
- t.Fatal(err)
- }
- if err := svc.Update(t.Context(), second.ID, map[string]any{"resolution": "1080p"}); !errors.Is(err, ErrSubscriptionAlreadyExists) || SubscriptionAlreadyExistsID(err) != first.ID {
- t.Fatalf("update error = %v, want conflict with %s", err, first.ID)
- }
-
- archivedAt := time.Now()
- archived := *first
- archived.ID = ""
- archived.ArchivedAt = &archivedAt
- archived.Enabled = false
- if err := repos.Subscription.Create(t.Context(), &archived); err != nil {
- t.Fatal(err)
- }
- if _, err := svc.Restore(t.Context(), archived.ID); !errors.Is(err, ErrSubscriptionAlreadyExists) || SubscriptionAlreadyExistsID(err) != first.ID {
- t.Fatalf("restore error = %v, want conflict with %s", err, first.ID)
- }
-}
diff --git a/internal/service/subscription_logging.go b/internal/service/subscription_logging.go
deleted file mode 100644
index 85973a4..0000000
--- a/internal/service/subscription_logging.go
+++ /dev/null
@@ -1,54 +0,0 @@
-package service
-
-import (
- "net/url"
- "strings"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func subscriptionRunLogFields(sub *model.Subscription) []zap.Field {
- fields := []zap.Field{}
- if sub == nil {
- return fields
- }
- return append(fields,
- zap.String("subscription_id", sub.ID),
- zap.String("subscription", sub.Name),
- zap.String("feed_kind", subscriptionFeedKind(sub.FeedURL)),
- zap.String("filter", sub.Filter),
- zap.String("media_type", sub.MediaType),
- zap.String("media_category", sub.MediaCategory),
- zap.String("search_mode", sub.SearchMode),
- zap.Bool("enabled", sub.Enabled),
- zap.Bool("wash_enabled", sub.WashEnabled),
- zap.String("wash_priority", sub.WashPriority),
- zap.Int("total_episodes", sub.TotalEpisodes),
- )
-}
-
-func appendSubscriptionRunResultFields(fields []zap.Field, queued int, started time.Time) []zap.Field {
- return append(fields,
- zap.Int("queued", queued),
- zap.Int64("duration_ms", time.Since(started).Milliseconds()),
- )
-}
-
-func subscriptionFeedKind(feedURL string) string {
- raw := strings.TrimSpace(feedURL)
- if raw == "" {
- return "empty"
- }
- lower := strings.ToLower(raw)
- if strings.HasPrefix(lower, "site-search://") {
- return "site-search"
- }
- parsed, err := url.Parse(raw)
- if err == nil && parsed.Scheme != "" {
- return parsed.Scheme
- }
- return "unknown"
-}
diff --git a/internal/service/subscription_metadata_prepare.go b/internal/service/subscription_metadata_prepare.go
deleted file mode 100644
index 91cd7ef..0000000
--- a/internal/service/subscription_metadata_prepare.go
+++ /dev/null
@@ -1,110 +0,0 @@
-package service
-
-import (
- "context"
- "strings"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *SubscriptionService) prepareSubscriptionForRun(ctx context.Context, sub *model.Subscription) {
- if s == nil || sub == nil {
- return
- }
- normalizeSubscriptionDefaults(sub)
- updates := map[string]any{}
- s.fillSubscriptionRunMetadata(ctx, sub, updates)
- if identityKey := model.SubscriptionIdentityKey(sub); identityKey != sub.IdentityKey {
- sub.IdentityKey = identityKey
- updates["identity_key"] = identityKey
- }
- if len(updates) > 0 && s.repo != nil && s.repo.DB != nil {
- if err := s.repo.DB.WithContext(ctx).Model(&model.Subscription{}).Where("id = ?", sub.ID).Updates(updates).Error; err != nil && s.log != nil {
- s.log.Debug("subscription metadata prepare persist failed", zap.String("id", sub.ID), zap.Error(err))
- }
- }
-}
-
-func (s *SubscriptionService) fillSubscriptionRunMetadata(ctx context.Context, sub *model.Subscription, updates map[string]any) {
- if sub == nil {
- return
- }
- if needsSubscriptionMetadataLookup(sub) {
- query := subscriptionMetadataPrepareQuery(sub)
- lookupCtx, cancel := context.WithTimeout(ctx, 12*time.Second)
- defer cancel()
- if match := s.lookupSubscriptionMetadata(lookupCtx, strings.TrimSpace(sub.MediaType), query, sub); match != nil {
- applySubscriptionMetadataMatch(sub, match, updates)
- }
- }
- if isSubscriptionSeriesType(strings.TrimSpace(sub.MediaType)) && sub.TotalEpisodes <= 0 {
- if total := s.resolveSubscriptionTotalEpisodes(ctx, sub, 0); total > 0 {
- sub.TotalEpisodes = total
- updates["total_episodes"] = total
- }
- }
-}
-
-func needsSubscriptionMetadataLookup(sub *model.Subscription) bool {
- if sub == nil {
- return false
- }
- return strings.TrimSpace(sub.MediaType) == "" ||
- strings.TrimSpace(sub.OriginalName) == "" ||
- sub.Year <= 0
-}
-
-func subscriptionMetadataPrepareQuery(sub *model.Subscription) string {
- if sub == nil {
- return ""
- }
- if value := strings.TrimSpace(sub.Filter); value != "" {
- return value
- }
- return strings.TrimSpace(sub.Name)
-}
-
-func applySubscriptionMetadataMatch(sub *model.Subscription, match *Match, updates map[string]any) {
- if sub == nil || match == nil {
- return
- }
- if strings.TrimSpace(sub.MediaType) == "" {
- if mediaType := normalizeMetadataMatchSubscriptionType(match); mediaType != "" {
- sub.MediaType = mediaType
- updates["media_type"] = mediaType
- }
- }
- if strings.TrimSpace(sub.OriginalName) == "" {
- if value := strings.TrimSpace(match.OriginalName); value != "" {
- sub.OriginalName = value
- updates["original_name"] = value
- }
- }
- if sub.Year <= 0 && match.Year > 0 {
- sub.Year = match.Year
- updates["year"] = match.Year
- }
-}
-
-func normalizeMetadataMatchSubscriptionType(match *Match) string {
- if match == nil {
- return ""
- }
- switch normalizeOrganizeMediaType(match.MediaType) {
- case "movie":
- return "movie"
- case "tv":
- return "tv"
- case "anime":
- return "anime"
- case "variety":
- return "variety"
- case "adult":
- return "adult"
- default:
- return ""
- }
-}
diff --git a/internal/service/subscription_metadata_prepare_test.go b/internal/service/subscription_metadata_prepare_test.go
deleted file mode 100644
index 6aeeecb..0000000
--- a/internal/service/subscription_metadata_prepare_test.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package service
-
-import (
- "encoding/json"
- "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 TestPrepareSubscriptionForRunFillsSeriesMetadata(t *testing.T) {
- var searchedTV bool
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- switch r.URL.Path {
- case "/search/tv":
- searchedTV = true
- _ = json.NewEncoder(w).Encode(map[string]any{
- "results": []map[string]any{{
- "id": 12345,
- "name": "南部档案",
- "original_name": "Archives The Nanyang Mystery",
- "original_language": "zh",
- "origin_country": []string{"CN"},
- "first_air_date": "2026-01-01",
- }},
- })
- case "/tv/12345":
- _ = json.NewEncoder(w).Encode(map[string]any{"number_of_episodes": 33})
- default:
- http.NotFound(w, r)
- }
- }))
- defer upstream.Close()
-
- cfg := &config.Config{}
- cfg.Secrets.TMDbAPIKey = "test-key"
- cfg.Secrets.TMDbAPIProxy = upstream.URL
- repos := repository.New(newServiceTestDB(t, &model.Subscription{}))
- scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop()))
- svc := NewSubscriptionService(cfg, zap.NewNop(), repos, nil, nil, nil)
- svc.SetScraper(scraper)
-
- sub := model.Subscription{Name: "南部档案 自动订阅", FeedURL: "site-search://search?keyword=南部档案", Filter: "南部档案 2026", Enabled: true}
- if err := repos.Subscription.Create(t.Context(), &sub); err != nil {
- t.Fatal(err)
- }
-
- svc.prepareSubscriptionForRun(t.Context(), &sub)
- if !searchedTV {
- t.Fatal("blank media type subscription should try TV metadata before defaulting to movie")
- }
- if sub.MediaType != "tv" || sub.OriginalName != "Archives The Nanyang Mystery" || sub.Year != 2026 || sub.TotalEpisodes != 33 {
- t.Fatalf("prepared subscription = %#v, want tv metadata with total episodes", sub)
- }
-
- var stored model.Subscription
- if err := repos.DB.First(&stored, "id = ?", sub.ID).Error; err != nil {
- t.Fatal(err)
- }
- if stored.MediaType != "tv" || stored.OriginalName != "Archives The Nanyang Mystery" || stored.Year != 2026 || stored.TotalEpisodes != 33 {
- t.Fatalf("stored subscription = %#v, want persisted metadata", stored)
- }
-}
-
-func TestSubscriptionMetadataLibraryTypesSearchesTVForBlankType(t *testing.T) {
- got := subscriptionMetadataLibraryTypes("", "南部档案 2026")
- if len(got) < 2 || got[0] != "tv" || got[1] != "movie" {
- t.Fatalf("library types = %#v, want tv before movie for blank subscription type", got)
- }
-}
diff --git a/internal/service/subscription_notification.go b/internal/service/subscription_notification.go
deleted file mode 100644
index 5a55db7..0000000
--- a/internal/service/subscription_notification.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "strings"
- "time"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *SubscriptionService) notifySubscriptionHit(sub *model.Subscription, queued int, resources []string) {
- if s == nil || s.notify == nil || sub == nil || queued <= 0 {
- return
- }
- body := fmt.Sprintf("订阅:%s\n新增资源:%d", sub.Name, queued)
- if len(resources) > 0 {
- body += "\n资源:\n- " + strings.Join(resources, "\n- ")
- }
- go func() {
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- data := map[string]interface{}{}
- if strings.TrimSpace(sub.PosterURL) != "" {
- data["poster_url"] = sub.PosterURL
- }
- if strings.TrimSpace(sub.BackdropURL) != "" {
- data["backdrop_url"] = sub.BackdropURL
- }
- if strings.TrimSpace(sub.MediaType) != "" {
- data["media_type"] = sub.MediaType
- }
- if strings.TrimSpace(sub.MediaCategory) != "" {
- data["media_category"] = sub.MediaCategory
- }
- // 补充媒体通知模板(formatTelegramMediaNotification)所需字段:片名 / 原名 /
- // 语言 / 年份 / 评分 / 类型 / 简介 / 外链 / 资源标题(供模板提取季集 + 版本)。
- // 仅填现成可用的,缺失项模板会自动略过。
- if strings.TrimSpace(sub.Name) != "" {
- data["title"] = sub.Name
- }
- if strings.TrimSpace(sub.OriginalName) != "" {
- data["original_title"] = sub.OriginalName
- }
- if strings.TrimSpace(sub.OriginalLanguage) != "" {
- data["original_language"] = sub.OriginalLanguage
- }
- if sub.Year > 0 {
- data["year"] = sub.Year
- }
- if sub.Rating > 0 {
- data["rating"] = sub.Rating
- }
- if strings.TrimSpace(sub.Genres) != "" {
- data["genres"] = sub.Genres
- }
- if strings.TrimSpace(sub.Overview) != "" {
- data["overview"] = sub.Overview
- }
- if id := strings.TrimSpace(sub.IMDBID); id != "" {
- data["imdb_url"] = "https://www.imdb.com/title/" + id + "/"
- }
- if len(resources) > 0 {
- data["resource_title"] = resources[0]
- }
- s.notify.BroadcastEvent(ctx, NotifyEvent{
- Type: EventSubscriptionHit,
- Title: "MediaStationGo 订阅命中新资源",
- Message: body,
- Data: data,
- })
- }()
-}
diff --git a/internal/service/subscription_pack.go b/internal/service/subscription_pack.go
deleted file mode 100644
index 02bf2f4..0000000
--- a/internal/service/subscription_pack.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package service
-
-import (
- "regexp"
- "strings"
-)
-
-var (
- seriesPackRE = regexp.MustCompile(`(?i)(complete|batch|合集|全集|全\s*\d+\s*[集话話期]|整季|全季|s\d{1,2}\s*(?:complete|batch|pack)|season\s*\d{1,2}\s*(?:complete|batch|pack)|s\d{1,2}e\d{1,3}\s*[-~–—]\s*(?:s\d{1,2})?e?\d{1,3}|第\s*\d+\s*[-~–—]\s*\d+\s*[集话話期])`)
- seasonOnlyRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\s*\d{1,2}(?:[\s._-]|$)|第\s*\d+\s*季`)
-)
-
-func isSeriesPackTitle(title string) bool {
- title = strings.TrimSpace(title)
- if title == "" {
- return false
- }
- if seriesPackRE.MatchString(title) {
- return true
- }
- _, episode := ParseEpisode(title)
- return episode == 0 && seasonOnlyRE.MatchString(title)
-}
diff --git a/internal/service/subscription_pending_availability_test.go b/internal/service/subscription_pending_availability_test.go
deleted file mode 100644
index 5bb621f..0000000
--- a/internal/service/subscription_pending_availability_test.go
+++ /dev/null
@@ -1,267 +0,0 @@
-package service
-
-import (
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestSubscriptionPendingDownloadAvailabilitySkipsUnorganizedEpisodes(t *testing.T) {
- root := t.TempDir()
- seasonDir := filepath.Join(root, "间谍过家家", "Season 01")
- if err := os.MkdirAll(seasonDir, 0o755); err != nil {
- t.Fatal(err)
- }
- for _, name := range []string{
- "间谍过家家 - S01E01.mkv",
- "间谍过家家 - S01E02.mkv.!qB",
- } {
- if err := os.WriteFile(filepath.Join(seasonDir, name), []byte("video"), 0o644); err != nil {
- t.Fatal(err)
- }
- }
-
- sub := &model.Subscription{
- Name: "间谍过家家 自动订阅",
- Filter: "间谍过家家",
- MediaType: "tv",
- SavePath: root,
- TotalEpisodes: 3,
- }
- svc := NewSubscriptionService(nil, nil, nil, nil, nil, nil)
- availability := svc.pendingDownloadAvailability(t.Context(), sub)
- if availability.DownloadedEpisodes != 2 {
- t.Fatalf("downloaded episodes = %d, want 2", availability.DownloadedEpisodes)
- }
- if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 1)]; !ok {
- t.Fatalf("missing pending E01 key: %#v", availability.ExistingEpisodeKeys)
- }
- if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 2)]; !ok {
- t.Fatalf("missing pending E02 key: %#v", availability.ExistingEpisodeKeys)
- }
-
- results := []SearchResult{
- {Title: "间谍过家家 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 100},
- {Title: "间谍过家家 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 90},
- {Title: "间谍过家家 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 80},
- {Title: "间谍过家家 S01E03 1080p", DownloadURL: "https://pt/download/3", Seeders: 70},
- }
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability)
- if len(got) != 1 || got[0].Episode != 3 {
- t.Fatalf("selected %#v, want only not-yet-downloaded episode 3", got)
- }
- if !svc.downloadPathHasCandidate(t.Context(), sub, "间谍过家家 S01E02 1080p", root) {
- t.Fatal("expected existing pending E02 file to be detected")
- }
- if svc.downloadPathHasCandidate(t.Context(), sub, "间谍过家家 S01E03 1080p", root) {
- t.Fatal("did not expect missing E03 to be detected")
- }
-}
-
-func TestSubscriptionPendingDownloadAvailabilityIncludesQueuedTasks(t *testing.T) {
- db := newServiceTestDB(t, &model.DownloadTask{})
- repos := repository.New(db)
- if err := repos.Download.Create(t.Context(), &model.DownloadTask{
- Source: "qbittorrent",
- URL: "magnet:?xt=urn:btih:2222222222222222222222222222222222222222",
- Title: "间谍过家家 S01E02 1080p",
- SavePath: "/downloads/tv",
- Status: "queued",
- }); err != nil {
- t.Fatal(err)
- }
- svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil)
- sub := &model.Subscription{
- Name: "间谍过家家 自动订阅",
- Filter: "间谍过家家",
- MediaType: "tv",
- SavePath: "/downloads/tv",
- TotalEpisodes: 3,
- }
-
- availability := svc.pendingDownloadAvailability(t.Context(), sub)
- if availability.DownloadedEpisodes != 1 {
- t.Fatalf("downloaded episodes = %d, want 1", availability.DownloadedEpisodes)
- }
- if availability.InLibrary {
- t.Fatal("queued download should not be reported as already in library")
- }
- if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 2)]; !ok {
- t.Fatalf("missing queued E02 key: %#v", availability.ExistingEpisodeKeys)
- }
-
- results := []SearchResult{
- {Title: "间谍过家家 S01E02 1080p WEB-DL", DownloadURL: "https://pt/download/2", Seeders: 80},
- {Title: "间谍过家家 S01E03 1080p WEB-DL", DownloadURL: "https://pt/download/3", Seeders: 70},
- }
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability)
- if len(got) != 1 || got[0].Episode != 3 {
- t.Fatalf("selected %#v, want only not-yet-downloaded episode 3", got)
- }
-}
-
-func TestSubscriptionPendingDownloadAvailabilityUsesOriginalNameAliasForTasks(t *testing.T) {
- db := newServiceTestDB(t, &model.DownloadTask{})
- repos := repository.New(db)
- if err := repos.Download.Create(t.Context(), &model.DownloadTask{
- Source: "qbittorrent",
- URL: "https://pt/download/7-8",
- Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL",
- SavePath: "/downloads/tv",
- Status: "queued",
- }); err != nil {
- t.Fatal(err)
- }
- svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil)
- sub := &model.Subscription{
- Name: "南部档案 自动订阅",
- Filter: "南部档案 2026",
- OriginalName: "Archives The Nanyang Mystery",
- MediaType: "tv",
- SavePath: "/downloads/tv",
- TotalEpisodes: 33,
- }
-
- availability := svc.pendingDownloadAvailability(t.Context(), sub)
- if availability.DownloadedEpisodes != 2 {
- t.Fatalf("downloaded episodes = %d, want 2", availability.DownloadedEpisodes)
- }
- for _, episode := range []int{7, 8} {
- if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok {
- t.Fatalf("missing pending E%02d key: %#v", episode, availability.ExistingEpisodeKeys)
- }
- }
- got := selectSiteSearchCandidates([]SearchResult{
- {Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/7-8", Seeders: 80},
- }, sub, map[string]struct{}{}, availability)
- if len(got) != 0 {
- t.Fatalf("selected %#v, want existing alias range to satisfy E07-E08", got)
- }
-}
-
-func TestSubscriptionPendingDownloadAvailabilityUsesOriginalNameAliasForLiveTorrents(t *testing.T) {
- qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/api/v2/auth/login":
- _, _ = w.Write([]byte("Ok."))
- case "/api/v2/torrents/info":
- _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL","save_path":"/downloads/tv","state":"downloading","progress":0.3}]`))
- default:
- http.NotFound(w, r)
- }
- }))
- defer qb.Close()
-
- db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{})
- repos := repository.New(db)
- downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
- downloads.qb.Configure(QBitConfig{BaseURL: qb.URL, Username: "admin", Password: "admin"})
- svc := NewSubscriptionService(nil, nil, repos, downloads, nil, nil)
- sub := &model.Subscription{
- Name: "南部档案 自动订阅",
- Filter: "南部档案 2026",
- OriginalName: "Archives The Nanyang Mystery",
- MediaType: "tv",
- SavePath: "/downloads/tv",
- TotalEpisodes: 33,
- }
-
- availability := svc.pendingDownloadAvailability(t.Context(), sub)
- if availability.DownloadedEpisodes != 5 {
- t.Fatalf("downloaded episodes = %d, want 5", availability.DownloadedEpisodes)
- }
- for _, episode := range []int{29, 30, 31, 32, 33} {
- if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok {
- t.Fatalf("missing live E%02d key: %#v", episode, availability.ExistingEpisodeKeys)
- }
- }
- got := selectSiteSearchCandidates([]SearchResult{
- {Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/29-33", Seeders: 80},
- }, sub, map[string]struct{}{}, availability)
- if len(got) != 0 {
- t.Fatalf("selected %#v, want existing live alias range to satisfy E29-E33", got)
- }
-}
-
-func TestSubscriptionPendingDownloadAvailabilityIncludesLinkedAliasTask(t *testing.T) {
- db := newServiceTestDB(t, &model.DownloadTask{})
- repos := repository.New(db)
- sub := &model.Subscription{
- Base: model.Base{ID: "sub-qiao-chu"},
- Name: "翘楚 S01E06 自动订阅",
- Filter: "翘楚 S01E06",
- MediaType: "tv",
- SavePath: "/downloads/tv",
- TotalEpisodes: 24,
- }
- if err := repos.Download.Create(t.Context(), &model.DownloadTask{
- SubscriptionID: sub.ID,
- Source: "qbittorrent",
- URL: "https://pt/download/21",
- Title: "Ashes to Crown 2026 S01E21 2160p WEB-DL",
- SavePath: "/downloads/tv",
- Status: "queued",
- }); err != nil {
- t.Fatal(err)
- }
- svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil)
-
- availability := svc.pendingDownloadAvailability(t.Context(), sub)
- if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 21)]; !ok {
- t.Fatalf("missing linked alias E21 key: %#v", availability.ExistingEpisodeKeys)
- }
- got := selectSiteSearchCandidates([]SearchResult{
- {Title: "Ashes to Crown 2026 S01E21 2160p WEB-DL", DownloadURL: "https://pt/download/21", Seeders: 80},
- }, sub, map[string]struct{}{}, availability)
- if len(got) != 0 {
- t.Fatalf("selected %#v, want linked alias task to satisfy E21", got)
- }
-}
-
-func TestSubscriptionPendingDownloadAvailabilitySkipsStaleTaskMissingFromQB(t *testing.T) {
- db := newServiceTestDB(t, &model.DownloadTask{})
- repos := repository.New(db)
- sub := &model.Subscription{
- Base: model.Base{ID: "sub-nanyang"},
- Name: "南部档案 自动订阅",
- Filter: "南部档案",
- MediaType: "tv",
- SavePath: "/downloads/tv",
- TotalEpisodes: 33,
- }
- if err := repos.Download.Create(t.Context(), &model.DownloadTask{
- SubscriptionID: sub.ID,
- Source: "qbittorrent",
- URL: "https://pt/download/stale",
- Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL",
- SavePath: "/downloads/tv",
- Status: "queued",
- Progress: 0,
- }); err != nil {
- t.Fatal(err)
- }
- downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
- downloads.recordLiveTorrentSnapshot(nil)
- svc := NewSubscriptionService(nil, nil, repos, downloads, nil, nil)
-
- availability := svc.pendingDownloadAvailability(t.Context(), sub)
- if availability.DownloadedEpisodes != 0 {
- t.Fatalf("downloaded episodes = %d, want stale task not counted", availability.DownloadedEpisodes)
- }
- if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 7)]; ok {
- t.Fatalf("stale E07 task should not count as available: %#v", availability.ExistingEpisodeKeys)
- }
- got := selectSiteSearchCandidates([]SearchResult{
- {Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/7-8", Seeders: 80},
- }, sub, map[string]struct{}{}, availability)
- if len(got) != 1 || got[0].Episode != 7 {
- t.Fatalf("selected %#v, want stale missing range to be eligible", got)
- }
-}
diff --git a/internal/service/subscription_planner.go b/internal/service/subscription_planner.go
deleted file mode 100644
index b1ebf7f..0000000
--- a/internal/service/subscription_planner.go
+++ /dev/null
@@ -1,85 +0,0 @@
-// Package service — subscription planning and release candidate selection.
-package service
-
-import "github.com/ShukeBta/MediaStationGo/internal/model"
-
-type siteSearchCandidate struct {
- Item SearchResult
- Download string
- GUID string
- Season int
- Episode int
- Episodes []int
- Pack bool
- Score int
-}
-
-type siteSearchSelectionStats struct {
- Total int
- QueryMismatch int
- QueryMismatchExamples []string
- RelaxedQueryMatch int
- RuleMismatch int
- MissingDownload int
- Seen int
- Prepared int
- Selected int
- LocalAlreadySatisfied bool
- LocalSeriesPackPresent bool
- SeriesComplete bool
- ExistingEpisodeSkipped int
- NotMissingEpisodeSkipped int
- NoEpisodeSkipped int
- PackFallbackAvailable bool
- PackFallbackUsed bool
-}
-
-// SubscriptionPlanner owns release selection decisions for subscriptions:
-// rule matching, candidate scoring, and filtering against known availability.
-type SubscriptionPlanner struct{}
-
-func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, availability ...LocalAvailability) []siteSearchCandidate {
- return SubscriptionPlanner{}.SelectSiteSearchCandidates(results, sub, seenSet, availability...)
-}
-
-func (SubscriptionPlanner) SelectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, availability ...LocalAvailability) []siteSearchCandidate {
- if sub == nil {
- return nil
- }
- if seenSet == nil {
- seenSet = map[string]struct{}{}
- }
- local := LocalAvailability{}
- if len(availability) > 0 {
- local = availability[0]
- }
- candidates, _ := selectSiteSearchCandidatesWithStats(results, sub, seenSet, local)
- return candidates
-}
-
-func selectSiteSearchCandidatesWithAvailability(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, local LocalAvailability) []siteSearchCandidate {
- candidates, _ := selectSiteSearchCandidatesWithStats(results, sub, seenSet, local)
- return candidates
-}
-
-func selectSiteSearchCandidatesWithStats(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, local LocalAvailability) ([]siteSearchCandidate, siteSearchSelectionStats) {
- stats := siteSearchSelectionStats{Total: len(results)}
- if sub == nil {
- return nil, stats
- }
- if seenSet == nil {
- seenSet = map[string]struct{}{}
- }
- candidates := collectSiteSearchCandidates(results, sub, seenSet, false, &stats)
- if len(candidates) == 0 && shouldRelaxSiteSearchQueryMatch(sub, local) && stats.QueryMismatch > 0 {
- relaxedStats := siteSearchSelectionStats{Total: len(results)}
- candidates = collectSiteSearchCandidates(results, sub, seenSet, true, &relaxedStats)
- stats.RuleMismatch = relaxedStats.RuleMismatch
- stats.MissingDownload = relaxedStats.MissingDownload
- stats.Seen = relaxedStats.Seen
- stats.Prepared = relaxedStats.Prepared
- stats.RelaxedQueryMatch = relaxedStats.RelaxedQueryMatch
- }
- selected := selectPreparedSubscriptionCandidatesWithStats(candidates, sub, local, &stats)
- return selected, stats
-}
diff --git a/internal/service/subscription_rss.go b/internal/service/subscription_rss.go
deleted file mode 100644
index f765551..0000000
--- a/internal/service/subscription_rss.go
+++ /dev/null
@@ -1,79 +0,0 @@
-package service
-
-import (
- "context"
- "encoding/xml"
- "fmt"
- "io"
- "net/http"
- "regexp"
- "strings"
-)
-
-// rssFeed is the minimal RSS subset we need to decode.
-type rssFeed struct {
- XMLName xml.Name `xml:"rss"`
- Channel struct {
- Items []rssItem `xml:"item"`
- } `xml:"channel"`
-}
-
-type rssItem struct {
- Title string `xml:"title"`
- Link string `xml:"link"`
- GUID string `xml:"guid"`
- Description string `xml:"description"`
- Enclosure struct {
- URL string `xml:"url,attr"`
- } `xml:"enclosure"`
-}
-
-func (s *SubscriptionService) fetch(ctx context.Context, feedURL string) (*rssFeed, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
- if err != nil {
- return nil, err
- }
- req.Header.Set("User-Agent", "MediaStationGo/0.1")
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
- if resp.StatusCode >= 400 {
- return nil, fmt.Errorf("rss %s: %d", feedURL, resp.StatusCode)
- }
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, err
- }
- var f rssFeed
- if err := xml.Unmarshal(body, &f); err != nil {
- return nil, err
- }
- return &f, nil
-}
-
-func compileFilter(pat string) *regexp.Regexp {
- pat = strings.TrimSpace(pat)
- if pat == "" {
- return nil
- }
- if r, err := regexp.Compile("(?i)" + pat); err == nil {
- return r
- }
- return nil
-}
-
-func splitNonEmpty(s string) []string {
- if s == "" {
- return nil
- }
- out := make([]string, 0)
- for _, p := range strings.Split(s, "\n") {
- p = strings.TrimSpace(p)
- if p != "" {
- out = append(out, p)
- }
- }
- return out
-}
diff --git a/internal/service/subscription_rss_defaults_test.go b/internal/service/subscription_rss_defaults_test.go
deleted file mode 100644
index c4e15d8..0000000
--- a/internal/service/subscription_rss_defaults_test.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package service
-
-import (
- "testing"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func TestSelectRSSSubscriptionCandidatesDefaultKeepsOnlyBestWebDLPerEpisode(t *testing.T) {
- sub := &model.Subscription{Name: "House of the Dragon 自动订阅", Filter: "House of the Dragon", MediaType: "tv"}
- items := []rssItem{
- rssSubscriptionItem("House of the Dragon S03E01 1080p WEB-DL H264 AAC", "https://pt/download/e01-1080"),
- rssSubscriptionItem("House of the Dragon S03E01 2160p WEB-DL H264 AAC", "https://pt/download/e01-2160"),
- rssSubscriptionItem("House of the Dragon S03E01 720p WEBRip H264 AAC", "https://pt/download/e01-720"),
- rssSubscriptionItem("House of the Dragon S03E02 1080p HDTV H264 AAC", "https://pt/download/e02-hdtv"),
- rssSubscriptionItem("House of the Dragon S03E02 1080p WEB-DL H264 AAC", "https://pt/download/e02-webdl"),
- }
-
- got := selectRSSSubscriptionCandidates(items, sub, compileFilter(sub.Filter), nil, LocalAvailability{})
- if len(got) != 2 {
- t.Fatalf("selected %d candidates, want one best release per episode", len(got))
- }
- if got[0].Download != "https://pt/download/e01-2160" {
- t.Fatalf("episode 1 selected %q, want 2160p WEB-DL", got[0].Download)
- }
- if got[1].Download != "https://pt/download/e02-webdl" {
- t.Fatalf("episode 2 selected %q, want WEB-DL over HDTV", got[1].Download)
- }
-}
-
-func rssSubscriptionItem(title, download string) rssItem {
- item := rssItem{Title: title, Link: download, GUID: download}
- item.Enclosure.URL = download
- return item
-}
diff --git a/internal/service/subscription_rss_run.go b/internal/service/subscription_rss_run.go
deleted file mode 100644
index e9158cb..0000000
--- a/internal/service/subscription_rss_run.go
+++ /dev/null
@@ -1,188 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "strings"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-type rssSubscriptionRunState struct {
- seen []string
- seenSet map[string]struct{}
- availability LocalAvailability
- availabilityQuery string
- washOff bool
-}
-
-func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscription) (queued int, err error) {
- s.prepareSubscriptionForRun(ctx, sub)
- started := time.Now()
- if s.log != nil {
- s.log.Info("subscription run started", subscriptionRunLogFields(sub)...)
- defer func() {
- fields := appendSubscriptionRunResultFields(subscriptionRunLogFields(sub), queued, started)
- if err != nil {
- fields = append(fields, zap.Error(err))
- s.log.Warn("subscription run finished with error", fields...)
- return
- }
- s.log.Info("subscription run finished", fields...)
- }()
- }
- if strings.HasPrefix(strings.ToLower(strings.TrimSpace(sub.FeedURL)), "site-search://") {
- return s.runSiteSearch(ctx, sub)
- }
-
- feed, err := s.fetch(ctx, sub.FeedURL)
- if err != nil {
- return 0, err
- }
-
- filter := compileFilter(sub.Filter)
- guidKey := fmt.Sprintf("subscription.%s.seen", sub.ID)
- seenRaw, _ := s.repo.Setting.Get(ctx, guidKey)
- seen := splitNonEmpty(seenRaw)
- seenSet := make(map[string]struct{}, len(seen))
- for _, g := range seen {
- seenSet[g] = struct{}{}
- }
-
- s.updateSubscriptionTotalEpisodes(ctx, sub, s.resolveSubscriptionTotalEpisodes(ctx, sub, inferRSSTotalEpisodes(feed.Channel.Items, sub, filter)))
- // RSS 和站点搜索统一使用候选规划:先按订阅规则过滤,再按洗版优先级/集数去重择优。
- // 非洗版订阅成功下载一次即满足,媒体库与下载中任务会作为可用性输入避免重复下载。
- runState := &rssSubscriptionRunState{
- seen: seen,
- seenSet: seenSet,
- availability: mergeLocalAvailability(SubscriptionLocalAvailability(ctx, s.repo, sub), s.pendingDownloadAvailability(ctx, sub)),
- availabilityQuery: availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)),
- washOff: !subscriptionAllowsWash(sub),
- }
- candidates := selectRSSSubscriptionCandidates(feed.Channel.Items, sub, filter, runState.seenSet, runState.availability)
- queued = s.enqueueRSSSubscriptionCandidates(ctx, sub, candidates, runState)
- s.finishRSSSubscriptionRun(ctx, sub, guidKey, runState, queued)
- return queued, nil
-}
-
-func (s *SubscriptionService) enqueueRSSSubscriptionCandidates(ctx context.Context, sub *model.Subscription, candidates []siteSearchCandidate, state *rssSubscriptionRunState) int {
- queued := 0
- for _, candidate := range candidates {
- if s.enqueueRSSSubscriptionCandidate(ctx, sub, candidate, state) {
- queued++
- }
- }
- return queued
-}
-
-func (s *SubscriptionService) enqueueRSSSubscriptionCandidate(ctx context.Context, sub *model.Subscription, candidate siteSearchCandidate, state *rssSubscriptionRunState) bool {
- item := candidate.Item
- mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, item.Title, "")
- savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory)
- if s.downloadPathHasCandidate(ctx, sub, item.Title, savePath) {
- state.markTitleAvailable(item.Title)
- return false
- }
- if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, candidate.Download, savePath, DownloadTaskMeta{
- SubscriptionID: sub.ID,
- Title: firstNonEmpty(item.Title, sub.Name),
- PosterURL: sub.PosterURL,
- BackdropURL: sub.BackdropURL,
- Overview: sub.Overview,
- MediaType: mediaType,
- MediaCategory: mediaCategory,
- AllowExistingLibrary: subscriptionAllowsWash(sub),
- }); err != nil {
- if IsDownloadDedupError(err) {
- if s.subscriptionCandidateConfirmedAvailable(ctx, sub, candidate) {
- state.markCandidateAvailable(candidate)
- return false
- }
- if s.log != nil {
- s.log.Info("subscription dedup candidate not confirmed available",
- zap.String("title", item.Title),
- zap.String("media_type", mediaType),
- zap.String("media_category", mediaCategory),
- zap.String("save_path", savePath))
- }
- return false
- }
- s.log.Warn("subscription enqueue failed",
- zap.String("title", item.Title),
- zap.String("media_type", mediaType),
- zap.String("media_category", mediaCategory),
- zap.String("save_path", savePath),
- zap.Error(err))
- return false
- }
- if s.log != nil {
- s.log.Info("rss subscription candidate queued",
- zap.String("subscription_id", sub.ID),
- zap.String("subscription", sub.Name),
- zap.String("title", item.Title),
- zap.String("media_type", mediaType),
- zap.String("media_category", mediaCategory),
- zap.String("save_path", savePath))
- }
- state.markTitleAvailable(item.Title)
- state.markSeen(candidate.GUID)
- return true
-}
-
-func (s *SubscriptionService) finishRSSSubscriptionRun(ctx context.Context, sub *model.Subscription, guidKey string, state *rssSubscriptionRunState, queued int) {
- state.availability = s.finalizePendingAvailability(sub, state.availability)
- // Remember the last 200 GUIDs so the seen set doesn't grow forever.
- if len(state.seen) > 200 {
- state.seen = state.seen[len(state.seen)-200:]
- }
- if err := s.repo.Setting.Set(ctx, guidKey, strings.Join(state.seen, "\n")); err != nil && s.log != nil {
- s.log.Warn("subscription seen state update failed",
- zap.String("subscription_id", sub.ID),
- zap.String("subscription", sub.Name),
- zap.Error(err))
- }
-
- now := time.Now()
- if err := s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error; err != nil && s.log != nil {
- s.log.Warn("subscription last_run_at update failed",
- zap.String("subscription_id", sub.ID),
- zap.String("subscription", sub.Name),
- zap.Error(err))
- }
- if err := s.archiveCompletedSubscription(ctx, sub, state.availability); err != nil && s.log != nil {
- s.log.Warn("subscription archive check failed",
- zap.String("subscription_id", sub.ID),
- zap.String("subscription", sub.Name),
- zap.Error(err))
- }
- if queued > 0 {
- s.hub.Publish("subscription", map[string]any{
- "id": sub.ID,
- "name": sub.Name,
- "queued": queued,
- })
- s.notifySubscriptionHit(sub, queued, nil)
- }
-}
-
-func (state *rssSubscriptionRunState) markTitleAvailable(title string) {
- if state.washOff {
- addAvailabilityTitle(title, state.availabilityQuery, &state.availability)
- }
-}
-
-func (state *rssSubscriptionRunState) markCandidateAvailable(candidate siteSearchCandidate) {
- if state.washOff {
- addSiteSearchCandidateAvailability(candidate, &state.availability)
- }
-}
-
-func (state *rssSubscriptionRunState) markSeen(guid string) {
- state.seen = append(state.seen, guid)
- if state.seenSet != nil {
- state.seenSet[guid] = struct{}{}
- }
-}
diff --git a/internal/service/subscription_rules.go b/internal/service/subscription_rules.go
deleted file mode 100644
index 2fe52af..0000000
--- a/internal/service/subscription_rules.go
+++ /dev/null
@@ -1,104 +0,0 @@
-package service
-
-import (
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func matchesSubscriptionRules(sub *model.Subscription, title string) bool {
- titleFold := strings.ToLower(title)
- if containsAnyExcludeToken(titleFold, defaultExcludeWords) {
- return false
- }
- if sub == nil {
- return true
- }
- if compatibilityExcludes := defaultCompatibilityExcludesForSubscription(sub); compatibilityExcludes != "" && containsAnyExcludeToken(titleFold, compatibilityExcludes) {
- return false
- }
- if sub.ExcludeWords != "" && containsAnyExcludeToken(titleFold, sub.ExcludeWords) {
- return false
- }
- if sub.ReleaseGroups != "" && !containsAnyToken(titleFold, sub.ReleaseGroups) {
- return false
- }
- if sub.Resolution != "" && sub.Resolution != "best" && !titleMatchesResolution(titleFold, sub.Resolution) {
- return false
- }
- if sub.Quality != "" && sub.Quality != "best" && !titleMatchesQuality(titleFold, sub.Quality) {
- return false
- }
- if sub.Effects != "" && !containsAnyEffect(titleFold, sub.Effects) {
- return false
- }
- return true
-}
-
-func defaultCompatibilityExcludesForSubscription(sub *model.Subscription) string {
- if sub == nil {
- return defaultCompatibilityExcludeWords
- }
- requested := strings.ToLower(strings.Join([]string{sub.Effects, sub.Quality}, ","))
- if strings.TrimSpace(requested) == "" {
- return defaultCompatibilityExcludeWords
- }
- tokens := excludeWordTokens(defaultCompatibilityExcludeWords)
- out := make([]string, 0, len(tokens))
- for _, token := range tokens {
- if token == "" || compatibilityTokenRequested(requested, token) {
- continue
- }
- out = append(out, token)
- }
- return strings.Join(out, ",")
-}
-
-func compatibilityTokenRequested(requested, token string) bool {
- switch token {
- case "dovi", "dv", "dolby vision", "杜比视界":
- return containsAnyEffect(requested, "dolby-vision") || containsAnyToken(requested, "dovi,dv,dolby vision,杜比视界")
- case "dolby", "杜比":
- return containsAnyEffect(requested, "dolby-vision") || containsAnyToken(requested, "dolby,dolby vision,杜比,杜比视界,atmos,dolby atmos,杜比全景声")
- case "atmos":
- return containsAnyToken(requested, "atmos,dolby atmos,杜比全景声")
- case "h265", "h.265", "h-265", "h_265", "h 265", "hevc", "x265":
- return containsAnyToken(requested, "h265,h.265,h-265,h_265,h 265,hevc,x265")
- case "10bit", "10-bit", "10 bit", "hi10p":
- return containsAnyToken(requested, "10bit,10-bit,10 bit,hi10p")
- case "truehd", "ddp", "dd+", "eac3":
- return containsAnyToken(requested, "truehd,ddp,dd+,eac3")
- default:
- return containsAnyToken(requested, token)
- }
-}
-
-func isSubscriptionSeriesType(mediaType string) bool {
- switch normalizeMediaType(mediaType, "", "") {
- case "tv", "anime", "variety":
- return true
- default:
- return false
- }
-}
-
-func subscriptionAllowsWash(sub *model.Subscription) bool {
- if sub == nil || !sub.WashEnabled {
- return false
- }
- return subscriptionHasExplicitUpgradeCriteria(sub)
-}
-
-func subscriptionHasExplicitUpgradeCriteria(sub *model.Subscription) bool {
- if sub == nil {
- return false
- }
- if value := strings.TrimSpace(strings.ToLower(sub.Resolution)); value != "" && value != "best" {
- return true
- }
- if value := strings.TrimSpace(strings.ToLower(sub.Quality)); value != "" && value != "best" {
- return true
- }
- return strings.TrimSpace(sub.Effects) != "" ||
- strings.TrimSpace(sub.ReleaseGroups) != ""
-}
diff --git a/internal/service/subscription_rules_test.go b/internal/service/subscription_rules_test.go
deleted file mode 100644
index ebf60aa..0000000
--- a/internal/service/subscription_rules_test.go
+++ /dev/null
@@ -1,213 +0,0 @@
-package service
-
-import (
- "testing"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func TestMatchesSubscriptionRulesUserExcludeWords(t *testing.T) {
- sub := &model.Subscription{ExcludeWords: "10bit,dolby vision,杜比"}
- cases := []struct {
- title string
- want bool
- }{
- {"Movie 2024 1080p WEB-DL", true},
- {"Movie 2024 2160p 10bit HEVC", false},
- {"Movie 2024 2160p Dolby Vision", false},
- {"电影 2024 杜比全景声", false},
- }
- for _, c := range cases {
- if got := matchesSubscriptionRules(sub, c.title); got != c.want {
- t.Errorf("matchesSubscriptionRules(%q) = %v, want %v", c.title, got, c.want)
- }
- }
-}
-
-func TestMatchesSubscriptionRulesReleaseStyleExcludeWords(t *testing.T) {
- cases := []struct {
- name string
- sub *model.Subscription
- title string
- }{
- {
- name: "default excludes ddp channel suffix",
- sub: &model.Subscription{},
- title: "Some Show 2026 S01E01 1080p WEB-DL DDP5.1 H264",
- },
- {
- name: "default excludes dolby glued word",
- sub: &model.Subscription{},
- title: "Some Movie 2026 1080p WEB-DL DolbyVision H264",
- },
- {
- name: "custom dotted list excludes split tokens",
- sub: &model.Subscription{ExcludeWords: "DoVi.H265.10bit.杜比"},
- title: "Some Movie 2026 1080p WEB-DL H265",
- },
- {
- name: "custom dotted list excludes cjk split token",
- sub: &model.Subscription{ExcludeWords: "DoVi.H265.10bit.杜比"},
- title: "某电影 2026 1080p 杜比全景声",
- },
- }
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- if matchesSubscriptionRules(c.sub, c.title) {
- t.Fatalf("expected exclude words to reject %q", c.title)
- }
- })
- }
-}
-
-func TestMatchesSubscriptionRulesDefaultExcludesJunkReleases(t *testing.T) {
- sub := &model.Subscription{}
- for _, title := range []string{
- "Some Movie 2024 CAM",
- "Some Movie 2024 HDTS",
- "某电影 2024 枪版",
- "Some Movie 2024 TELESYNC",
- "Some Show 预告",
- } {
- if matchesSubscriptionRules(sub, title) {
- t.Errorf("expected default rules to exclude junk release %q", title)
- }
- }
-}
-
-func TestMatchesSubscriptionRulesDefaultExcludesRiskyTorrentLabels(t *testing.T) {
- sub := &model.Subscription{}
- for _, title := range []string{
- "Some Show S01E01 1080p WEB-DL HR",
- "Some Show S01E01 1080p WEB-DL H&R",
- "Some Show S01E01 1080p WEB-DL Hit and Run",
- "Some Show S01E01 1080p WEB-DL 禁转",
- "Some Show S01E01 1080p WEB-DL 禁止下载",
- } {
- if matchesSubscriptionRules(sub, title) {
- t.Errorf("expected default rules to exclude risky torrent label %q", title)
- }
- }
-}
-
-func TestMatchesSubscriptionRulesDefaultExcludesCompatibilityReleases(t *testing.T) {
- cases := []struct {
- name string
- sub *model.Subscription
- }{
- {name: "empty exclude words", sub: &model.Subscription{}},
- {name: "legacy frontend defaults", sub: &model.Subscription{ExcludeWords: "cam,ts,tc,枪版"}},
- {name: "custom exclude words", sub: &model.Subscription{ExcludeWords: "官中,无字幕"}},
- }
-
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- for _, title := range []string{
- "Some Movie 2024 2160p DoVi H.265 10bit",
- "Some Movie 2024 2160p H-265",
- "Some Movie 2024 2160p H 265 10 bit",
- "Some Movie 2024 1080p HEVC",
- "Some Movie 2024 1080p x265",
- "Some Movie 2024 2160p Dolby Vision Atmos",
- "某电影 2024 1080p 杜比全景声",
- "Some Anime 2024 1080p Hi10P",
- } {
- if matchesSubscriptionRules(c.sub, title) {
- t.Errorf("expected default compatibility rules to exclude %q", title)
- }
- }
- })
- }
-}
-
-func TestMatchesSubscriptionRulesCustomExcludeWordsKeepCompatibilityDefaults(t *testing.T) {
- sub := &model.Subscription{ExcludeWords: "sample"}
- title := "Some Movie 2024 2160p DoVi HEVC 10bit"
- if matchesSubscriptionRules(sub, title) {
- t.Fatalf("custom exclude words should keep default compatibility excludes for %q", title)
- }
- if matchesSubscriptionRules(sub, "Some Movie 2024 1080p SAMPLE") {
- t.Fatal("custom exclude words should still apply")
- }
-}
-
-func TestMatchesSubscriptionRulesExplicitEffectsCanRequestCompatibilityFormats(t *testing.T) {
- sub := &model.Subscription{Effects: "dolby vision"}
- title := "Some Movie 2024 2160p DoVi WEB-DL"
- if !matchesSubscriptionRules(sub, title) {
- t.Fatalf("explicit requested effects should allow compatibility format for %q", title)
- }
-}
-
-func TestMatchesSubscriptionRulesExplicitAtmosDoesNotAllowOtherCompatibilityFormats(t *testing.T) {
- sub := &model.Subscription{Effects: "atmos"}
- if !matchesSubscriptionRules(sub, "Some Movie 2024 1080p WEB-DL Atmos") {
- t.Fatal("explicit atmos should allow an Atmos-only release")
- }
- if !matchesSubscriptionRules(sub, "Some Movie 2024 1080p WEB-DL Dolby Atmos") {
- t.Fatal("explicit atmos should allow Dolby Atmos wording")
- }
- if matchesSubscriptionRules(sub, "Some Movie 2024 2160p WEB-DL HEVC 10bit DoVi Atmos") {
- t.Fatal("explicit atmos should not also allow DoVi/HEVC/10bit")
- }
-}
-
-func TestMatchesSubscriptionRulesWordBoundaryAvoidsFalsePositives(t *testing.T) {
- sub := &model.Subscription{}
- // "ts" / "cam" / "tc" 作为子串出现在合法标题里时不应被默认排除误伤。
- for _, title := range []string{
- "Tsukihime 2024 1080p WEB-DL",
- "Camp Rock 2024 1080p BluRay",
- "Catch Me 2024 1080p WEB-DL",
- } {
- if !matchesSubscriptionRules(sub, title) {
- t.Errorf("word-boundary match wrongly excluded %q", title)
- }
- }
-}
-
-func TestSelectSiteSearchCandidatesSkipsExistingMovieWhenNotWashing(t *testing.T) {
- sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie"}
- results := []SearchResult{
- {Title: "Inception 2010 2160p 10bit Dolby Vision Atmos", DownloadURL: "https://pt/download/dovi", Seeders: 500},
- {Title: "Inception 2010 1080p WEB-DL", DownloadURL: "https://pt/download/web", Seeders: 90},
- }
- availability := LocalAvailability{LocalMediaCount: 1, InLibrary: true, DownloadedEpisodes: 1, TotalEpisodes: 1}
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability)
- if len(got) != 0 {
- t.Fatalf("selected %#v, want none (movie already in library, wash disabled)", got)
- }
-}
-
-func TestSelectSiteSearchCandidatesAllowsMovieWashUpgradeWithExplicitCriteria(t *testing.T) {
- sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", Resolution: "2160p", WashEnabled: true, WashPriority: "resolution"}
- results := []SearchResult{
- {Title: "Inception 2010 2160p REMUX", DownloadURL: "https://pt/download/2160", Seeders: 80},
- {Title: "Inception 2010 1080p WEB-DL", DownloadURL: "https://pt/download/1080", Seeders: 200},
- }
- availability := LocalAvailability{LocalMediaCount: 1, InLibrary: true, DownloadedEpisodes: 1, TotalEpisodes: 1}
-
- got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability)
- if len(got) != 1 || got[0].Download != "https://pt/download/2160" {
- t.Fatalf("selected %#v, want 2160p upgrade allowed when washing", got)
- }
-}
-
-func TestSubscriptionItemAlreadyAvailable(t *testing.T) {
- movieSub := &model.Subscription{MediaType: "movie"}
- if !subscriptionItemAlreadyAvailable(movieSub, LocalAvailability{LocalMediaCount: 1}, "Inception 2010 2160p") {
- t.Fatal("movie already in library should be reported available")
- }
- if subscriptionItemAlreadyAvailable(movieSub, LocalAvailability{}, "Inception 2010 2160p") {
- t.Fatal("empty library should not be reported available")
- }
- tvSub := &model.Subscription{MediaType: "tv"}
- avail := LocalAvailability{LocalMediaCount: 1, ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 2): {}}}
- if !subscriptionItemAlreadyAvailable(tvSub, avail, "Show S01E02 1080p") {
- t.Fatal("existing episode should be reported available")
- }
- if subscriptionItemAlreadyAvailable(tvSub, avail, "Show S01E03 1080p") {
- t.Fatal("missing episode should not be reported available")
- }
-}
diff --git a/internal/service/subscription_run_rss_test.go b/internal/service/subscription_run_rss_test.go
deleted file mode 100644
index 5170bc3..0000000
--- a/internal/service/subscription_run_rss_test.go
+++ /dev/null
@@ -1,162 +0,0 @@
-package service
-
-import (
- "net/http"
- "net/http/httptest"
- "sync/atomic"
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestSubscriptionRunOneDeduplicatesDuplicateRSSGUIDInSameFeed(t *testing.T) {
- rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/rss+xml")
- _, _ = w.Write([]byte(`
-
- -
- Some Show S01E01 1080p
- episode-1
- magnet:?xt=urn:btih:1111111111111111111111111111111111111111&dn=Some+Show+S01E01
-
- -
- Some Show S01E01 1080p
- episode-1
- magnet:?xt=urn:btih:1111111111111111111111111111111111111111&dn=Some+Show+S01E01
-
-`))
- }))
- defer rss.Close()
-
- var addCalls int32
- qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/api/v2/auth/login":
- _, _ = w.Write([]byte("Ok."))
- case "/api/v2/torrents/info":
- if atomic.LoadInt32(&addCalls) > 0 {
- _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Some Show S01E01 1080p","state":"downloading","progress":0.1}]`))
- return
- }
- _, _ = w.Write([]byte(`[]`))
- case "/api/v2/torrents/add":
- atomic.AddInt32(&addCalls, 1)
- _, _ = w.Write([]byte("Ok."))
- default:
- http.NotFound(w, r)
- }
- }))
- defer qb.Close()
-
- db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{})
- repos := repository.New(db)
- configureTestDefaultQB(t, repos, qb.URL)
- downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
- svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop()))
-
- sub := &model.Subscription{
- Name: "Some Show 自动订阅",
- FeedURL: rss.URL,
- Filter: "Some Show",
- MediaType: "tv",
- SavePath: "/downloads/tv",
- }
- if err := repos.Subscription.Create(t.Context(), sub); err != nil {
- t.Fatal(err)
- }
- queued, err := svc.runOne(t.Context(), sub)
- if err != nil {
- t.Fatal(err)
- }
- if queued != 1 {
- t.Fatalf("queued = %d, want 1", queued)
- }
- if got := atomic.LoadInt32(&addCalls); got != 1 {
- t.Fatalf("qb add calls = %d, want 1", got)
- }
- rows, err := repos.Download.List(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(rows) != 1 {
- t.Fatalf("download rows = %d, want 1", len(rows))
- }
-}
-
-func TestSubscriptionRunOneSkipsSameEpisodeAddedEarlierInFeed(t *testing.T) {
- rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/rss+xml")
- _, _ = w.Write([]byte(`
-
- -
- Some Show S01E01 1080p
- episode-1-a
- magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=Some+Show+S01E01+1080p
-
- -
- Some Show S01E01 WEB-DL
- episode-1-b
- magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=Some+Show+S01E01+WEB-DL
-
-`))
- }))
- defer rss.Close()
-
- var addCalls int32
- qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/api/v2/auth/login":
- _, _ = w.Write([]byte("Ok."))
- case "/api/v2/torrents/info":
- if atomic.LoadInt32(&addCalls) > 0 {
- _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Some Show S01E01 1080p","state":"downloading","progress":0.1}]`))
- return
- }
- _, _ = w.Write([]byte(`[]`))
- case "/api/v2/torrents/add":
- atomic.AddInt32(&addCalls, 1)
- _, _ = w.Write([]byte("Ok."))
- default:
- http.NotFound(w, r)
- }
- }))
- defer qb.Close()
-
- db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{})
- repos := repository.New(db)
- configureTestDefaultQB(t, repos, qb.URL)
- downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
- svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop()))
-
- sub := &model.Subscription{
- Name: "Some Show 自动订阅",
- FeedURL: rss.URL,
- Filter: "Some Show",
- MediaType: "tv",
- SavePath: "/downloads/tv",
- TotalEpisodes: 12,
- }
- if err := repos.Subscription.Create(t.Context(), sub); err != nil {
- t.Fatal(err)
- }
- queued, err := svc.runOne(t.Context(), sub)
- if err != nil {
- t.Fatal(err)
- }
- if queued != 1 {
- t.Fatalf("queued = %d, want 1", queued)
- }
- if got := atomic.LoadInt32(&addCalls); got != 1 {
- t.Fatalf("qb add calls = %d, want 1", got)
- }
- rows, err := repos.Download.List(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(rows) != 1 {
- t.Fatalf("download rows = %d, want 1", len(rows))
- }
-}
diff --git a/internal/service/subscription_run_wash_test.go b/internal/service/subscription_run_wash_test.go
deleted file mode 100644
index 321c1f4..0000000
--- a/internal/service/subscription_run_wash_test.go
+++ /dev/null
@@ -1,267 +0,0 @@
-package service
-
-import (
- "fmt"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync/atomic"
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestSubscriptionRunOneRSSWashQueuesOnlyBestMovieVariant(t *testing.T) {
- rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/rss+xml")
- _, _ = w.Write([]byte(`
-
- -
- Dune 2021 1080p WEB-DL
- dune-1080-web
- magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&dn=Dune+2021+1080p+WEB-DL
-
- -
- Dune 2021 2160p UHD BluRay REMUX HDR
- dune-2160-remux
- magnet:?xt=urn:btih:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&dn=Dune+2021+2160p+REMUX
-
- -
- Dune 2021 720p HDTV
- dune-720-hdtv
- magnet:?xt=urn:btih:ffffffffffffffffffffffffffffffffffffffff&dn=Dune+2021+720p+HDTV
-
-`))
- }))
- defer rss.Close()
-
- var addCalls int32
- var addedTitles []string
- addedHashes := make([]string, 0, 3)
- qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/api/v2/auth/login":
- _, _ = w.Write([]byte("Ok."))
- case "/api/v2/torrents/info":
- if len(addedHashes) == 0 {
- _, _ = w.Write([]byte(`[]`))
- return
- }
- var items []string
- for _, hash := range addedHashes {
- items = append(items, `{"hash":"`+hash+`","name":"Dune 2021","state":"downloading","progress":0.1}`)
- }
- _, _ = w.Write([]byte(`[` + strings.Join(items, ",") + `]`))
- case "/api/v2/torrents/add":
- call := atomic.AddInt32(&addCalls, 1)
- _ = r.ParseMultipartForm(10 << 20)
- addedTitles = append(addedTitles, r.FormValue("urls"))
- addedHashes = append(addedHashes, strings.Repeat(fmt.Sprintf("%x", call), 40))
- _, _ = w.Write([]byte("Ok."))
- default:
- http.NotFound(w, r)
- }
- }))
- defer qb.Close()
-
- db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{})
- repos := repository.New(db)
- configureTestDefaultQB(t, repos, qb.URL)
- downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
- svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop()))
-
- sub := &model.Subscription{
- Name: "Dune 自动订阅",
- FeedURL: rss.URL,
- Filter: "Dune 2021",
- MediaType: "movie",
- Resolution: "2160p",
- Quality: "remux",
- WashEnabled: true,
- WashPriority: "resolution",
- SavePath: "/downloads/movies",
- }
- if err := repos.Subscription.Create(t.Context(), sub); err != nil {
- t.Fatal(err)
- }
- queued, err := svc.runOne(t.Context(), sub)
- if err != nil {
- t.Fatal(err)
- }
- if queued != 1 {
- t.Fatalf("queued = %d, want 1 best movie variant", queued)
- }
- if got := atomic.LoadInt32(&addCalls); got != 1 {
- t.Fatalf("qb add calls = %d, want 1", got)
- }
- if len(addedTitles) != 1 || !strings.Contains(addedTitles[0], "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") {
- t.Fatalf("added %#v, want 2160p REMUX variant only", addedTitles)
- }
-}
-
-func TestSubscriptionRunOneRSSDefaultQueuesOnlyBestEpisodeVariant(t *testing.T) {
- rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/rss+xml")
- _, _ = w.Write([]byte(`
-
- -
- House of the Dragon S03E01 2160p BluRay H264 AAC
- hotd-e01-bluray
- magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=House+of+the+Dragon+S03E01+2160p+BluRay
-
- -
- House of the Dragon S03E01 1080p WEB-DL H264 AAC
- hotd-e01-webdl
- magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=House+of+the+Dragon+S03E01+1080p+WEB-DL
-
- -
- House of the Dragon S03E01 720p HDTV H264 AAC
- hotd-e01-hdtv
- magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&dn=House+of+the+Dragon+S03E01+720p+HDTV
-
-`))
- }))
- defer rss.Close()
-
- var addCalls int32
- var addedURLs []string
- addedHashes := make([]string, 0, 3)
- qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/api/v2/auth/login":
- _, _ = w.Write([]byte("Ok."))
- case "/api/v2/torrents/info":
- if len(addedHashes) == 0 {
- _, _ = w.Write([]byte(`[]`))
- return
- }
- var items []string
- for _, hash := range addedHashes {
- items = append(items, `{"hash":"`+hash+`","name":"House of the Dragon S03E01","state":"downloading","progress":0.1}`)
- }
- _, _ = w.Write([]byte(`[` + strings.Join(items, ",") + `]`))
- case "/api/v2/torrents/add":
- call := atomic.AddInt32(&addCalls, 1)
- _ = r.ParseMultipartForm(10 << 20)
- addedURLs = append(addedURLs, r.FormValue("urls"))
- addedHashes = append(addedHashes, strings.Repeat(fmt.Sprintf("%x", call), 40))
- _, _ = w.Write([]byte("Ok."))
- default:
- http.NotFound(w, r)
- }
- }))
- defer qb.Close()
-
- db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{})
- repos := repository.New(db)
- configureTestDefaultQB(t, repos, qb.URL)
- downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
- svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop()))
-
- sub := &model.Subscription{
- Name: "House of the Dragon 自动订阅",
- FeedURL: rss.URL,
- Filter: "House of the Dragon",
- MediaType: "tv",
- SavePath: "/downloads/tv",
- }
- if err := repos.Subscription.Create(t.Context(), sub); err != nil {
- t.Fatal(err)
- }
- queued, err := svc.runOne(t.Context(), sub)
- if err != nil {
- t.Fatal(err)
- }
- if queued != 1 {
- t.Fatalf("queued = %d, want one best episode variant", queued)
- }
- if got := atomic.LoadInt32(&addCalls); got != 1 {
- t.Fatalf("qb add calls = %d, want 1", got)
- }
- if len(addedURLs) != 1 || !strings.Contains(addedURLs[0], "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") {
- t.Fatalf("added %#v, want 1080p WEB-DL variant only", addedURLs)
- }
-}
-
-func TestSubscriptionRunOneRSSCustomExcludeStillSkipsIncompatibleVariants(t *testing.T) {
- rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/rss+xml")
- _, _ = w.Write([]byte(`
-
- -
- House of the Dragon S03E01 2160p WEB-DL HEVC 10bit DoVi Atmos
- hotd-e01-dovi
- magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=House+of+the+Dragon+S03E01+2160p+DoVi
-
- -
- House of the Dragon S03E01 1080p WEB-DL H264 AAC
- hotd-e01-webdl
- magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=House+of+the+Dragon+S03E01+1080p+WEB-DL
-
-`))
- }))
- defer rss.Close()
-
- var addCalls int32
- var addedURLs []string
- addedHashes := make([]string, 0, 2)
- qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/api/v2/auth/login":
- _, _ = w.Write([]byte("Ok."))
- case "/api/v2/torrents/info":
- if len(addedHashes) == 0 {
- _, _ = w.Write([]byte(`[]`))
- return
- }
- var items []string
- for _, hash := range addedHashes {
- items = append(items, `{"hash":"`+hash+`","name":"House of the Dragon S03E01","state":"downloading","progress":0.1}`)
- }
- _, _ = w.Write([]byte(`[` + strings.Join(items, ",") + `]`))
- case "/api/v2/torrents/add":
- call := atomic.AddInt32(&addCalls, 1)
- _ = r.ParseMultipartForm(10 << 20)
- addedURLs = append(addedURLs, r.FormValue("urls"))
- addedHashes = append(addedHashes, strings.Repeat(fmt.Sprintf("%x", call), 40))
- _, _ = w.Write([]byte("Ok."))
- default:
- http.NotFound(w, r)
- }
- }))
- defer qb.Close()
-
- db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{})
- repos := repository.New(db)
- configureTestDefaultQB(t, repos, qb.URL)
- downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
- svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop()))
-
- sub := &model.Subscription{
- Name: "House of the Dragon 自动订阅",
- FeedURL: rss.URL,
- Filter: "House of the Dragon",
- MediaType: "tv",
- SavePath: "/downloads/tv",
- ExcludeWords: "官中,无字幕",
- }
- if err := repos.Subscription.Create(t.Context(), sub); err != nil {
- t.Fatal(err)
- }
- queued, err := svc.runOne(t.Context(), sub)
- if err != nil {
- t.Fatal(err)
- }
- if queued != 1 {
- t.Fatalf("queued = %d, want one compatible WEB-DL variant", queued)
- }
- if got := atomic.LoadInt32(&addCalls); got != 1 {
- t.Fatalf("qb add calls = %d, want 1", got)
- }
- if len(addedURLs) != 1 || !strings.Contains(addedURLs[0], "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") {
- t.Fatalf("added %#v, want compatible 1080p WEB-DL only", addedURLs)
- }
-}
diff --git a/internal/service/subscription_score.go b/internal/service/subscription_score.go
deleted file mode 100644
index 87090a6..0000000
--- a/internal/service/subscription_score.go
+++ /dev/null
@@ -1,167 +0,0 @@
-package service
-
-import (
- "regexp"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-var (
- dolbyVisionTokenRE = regexp.MustCompile(`\bdv\b`)
- webDLTokenRE = regexp.MustCompile(`\bweb[\s._-]?dl\b`)
- webRipTokenRE = regexp.MustCompile(`\bweb[\s._-]?rip\b`)
- bluRayTokenRE = regexp.MustCompile(`\b(?:blu[\s._-]?ray|bdrip|bdremux|uhd[\s._-]?blu[\s._-]?ray)\b`)
-)
-
-const (
- defaultSubscriptionFreePromotionScore = 50_000
- washSubscriptionFreePromotionScore = 25
-)
-
-func subscriptionCandidateScore(sub *model.Subscription, item SearchResult) int {
- title := strings.ToLower(subscriptionSearchResultText(item))
- score := item.Seeders
- if !subscriptionAllowsWash(sub) {
- score += detectDefaultSubscriptionQualityScore(title)*1_000_000 + detectResolutionScore(title)*100_000
- if item.Free {
- score += defaultSubscriptionFreePromotionScore
- }
- return score
- }
- resolutionScore := detectResolutionScore(title)
- qualityScore := detectQualityScore(title)
- effectScore := detectEffectScore(title)
-
- priority := "balanced"
- if sub != nil && strings.TrimSpace(sub.WashPriority) != "" {
- priority = strings.ToLower(strings.TrimSpace(sub.WashPriority))
- }
- switch priority {
- case "resolution":
- score += resolutionScore*1000 + qualityScore*100 + effectScore*50
- case "quality":
- score += qualityScore*1000 + resolutionScore*200 + effectScore*50
- case "effects":
- score += effectScore*1000 + resolutionScore*200 + qualityScore*100
- case "seeders":
- score += qualityScore*3 + resolutionScore*2 + effectScore
- default:
- score += resolutionScore*500 + qualityScore*300 + effectScore*150
- }
- if item.Free {
- score += washSubscriptionFreePromotionScore
- }
- return score
-}
-
-func containsAnyEffect(titleFold, csv string) bool {
- for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
- return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
- }) {
- token = strings.TrimSpace(token)
- if token == "" {
- continue
- }
- switch token {
- case "dolby-vision", "dolby vision", "dv":
- if strings.Contains(titleFold, "dolby vision") || strings.Contains(titleFold, "dovi") || dolbyVisionTokenRE.MatchString(titleFold) {
- return true
- }
- default:
- if strings.Contains(titleFold, token) {
- return true
- }
- }
- }
- return false
-}
-
-func titleMatchesResolution(titleFold, resolution string) bool {
- switch strings.ToLower(strings.TrimSpace(resolution)) {
- case "2160p", "4k", "uhd":
- return strings.Contains(titleFold, "2160p") || strings.Contains(titleFold, "4k") || strings.Contains(titleFold, "uhd")
- case "1080p":
- return strings.Contains(titleFold, "1080p") || strings.Contains(titleFold, "fhd")
- case "720p":
- return strings.Contains(titleFold, "720p")
- default:
- return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(resolution)))
- }
-}
-
-func titleMatchesQuality(titleFold, quality string) bool {
- switch strings.ToLower(strings.TrimSpace(quality)) {
- case "webdl", "web-dl":
- return webDLTokenRE.MatchString(titleFold)
- case "webrip", "web-rip":
- return webRipTokenRE.MatchString(titleFold)
- case "bluray", "blu-ray":
- return bluRayTokenRE.MatchString(titleFold)
- case "remux":
- return strings.Contains(titleFold, "remux")
- case "hdtv":
- return strings.Contains(titleFold, "hdtv")
- default:
- return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(quality)))
- }
-}
-
-func detectDefaultSubscriptionQualityScore(titleFold string) int {
- switch {
- case titleMatchesQuality(titleFold, "web-dl"):
- return 5
- case titleMatchesQuality(titleFold, "web-rip"):
- return 4
- case titleMatchesQuality(titleFold, "bluray"), titleMatchesQuality(titleFold, "remux"):
- return 3
- case titleMatchesQuality(titleFold, "hdtv"):
- return 2
- default:
- return 1
- }
-}
-
-func detectResolutionScore(titleFold string) int {
- switch {
- case titleMatchesResolution(titleFold, "2160p"):
- return 4
- case titleMatchesResolution(titleFold, "1080p"):
- return 3
- case titleMatchesResolution(titleFold, "720p"):
- return 2
- default:
- return 1
- }
-}
-
-func detectQualityScore(titleFold string) int {
- switch {
- case titleMatchesQuality(titleFold, "remux"):
- return 5
- case titleMatchesQuality(titleFold, "bluray"):
- return 4
- case titleMatchesQuality(titleFold, "web-dl"):
- return 3
- case titleMatchesQuality(titleFold, "hdtv"):
- return 2
- default:
- return 1
- }
-}
-
-func detectEffectScore(titleFold string) int {
- score := 0
- if containsAnyEffect(titleFold, "dolby-vision") {
- score += 4
- }
- if strings.Contains(titleFold, "hdr10+") {
- score += 3
- } else if strings.Contains(titleFold, "hdr") {
- score += 2
- }
- if strings.Contains(titleFold, "atmos") {
- score += 2
- }
- return score
-}
diff --git a/internal/service/subscription_site_search.go b/internal/service/subscription_site_search.go
deleted file mode 100644
index 12b0244..0000000
--- a/internal/service/subscription_site_search.go
+++ /dev/null
@@ -1,241 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "strings"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subscription) (int, error) {
- if s.site == nil {
- if s.log != nil {
- s.log.Warn("site-search subscription service unavailable", subscriptionSiteSearchLogFields(sub, "")...)
- }
- return 0, errors.New("site search service unavailable")
- }
- keywords := siteSearchKeywords(sub)
- keyword := ""
- if len(keywords) > 0 {
- keyword = keywords[0]
- }
- if keyword == "" {
- if s.log != nil {
- s.log.Warn("site-search subscription keyword missing", subscriptionSiteSearchLogFields(sub, "")...)
- }
- return 0, errors.New("site-search subscription keyword required")
- }
- if s.log != nil {
- s.log.Info("site-search subscription run started", subscriptionSiteSearchLogFields(sub, keyword)...)
- }
-
- results, err := s.searchSubscriptionSites(ctx, sub, keywords)
- if err != nil {
- return 0, err
- }
- if len(results) == 0 {
- return s.finishSiteSearchNoResults(sub, keyword)
- }
- s.updateSubscriptionTotalEpisodes(ctx, sub, s.resolveSubscriptionTotalEpisodes(ctx, sub, inferSearchTotalEpisodes(results, sub)))
-
- guidKey, seen, seenSet := s.loadSiteSearchSeen(ctx, sub)
- availability := mergeLocalAvailability(
- SubscriptionLocalAvailability(ctx, s.repo, sub),
- s.pendingDownloadAvailability(ctx, sub),
- )
- candidates, selectionStats := selectSiteSearchCandidatesWithStats(results, sub, seenSet, availability)
- if s.log != nil {
- fields := subscriptionSiteSearchLogFields(sub, keyword)
- fields = appendSiteSearchSelectionLogFields(fields, selectionStats)
- fields = appendAvailabilityLogFields(fields, availability)
- s.log.Info("site-search subscription selection summary", fields...)
- }
- runState := &siteSearchRunState{
- Keyword: keyword,
- Seen: seen,
- SeenSet: seenSet,
- Availability: availability,
- }
- queueResult := s.enqueueSiteSearchCandidates(ctx, sub, candidates, runState)
- availability = s.finishSiteSearchRun(ctx, sub, guidKey, runState)
- return s.handleSiteSearchQueueResult(sub, keyword, queueResult, selectionStats, availability)
-}
-
-func (s *SubscriptionService) finishSiteSearchRun(ctx context.Context, sub *model.Subscription, guidKey string, state *siteSearchRunState) LocalAvailability {
- availability := s.finalizePendingAvailability(sub, state.Availability)
- seen := trimSiteSearchSeen(state.Seen)
- if err := s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n")); err != nil && s.log != nil {
- s.log.Warn("site-search subscription seen state update failed",
- zap.String("subscription_id", sub.ID),
- zap.String("subscription", sub.Name),
- zap.Error(err))
- }
- now := time.Now()
- if err := s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error; err != nil && s.log != nil {
- s.log.Warn("site-search subscription last_run_at update failed",
- zap.String("subscription_id", sub.ID),
- zap.String("subscription", sub.Name),
- zap.Error(err))
- }
- if err := s.archiveCompletedSubscription(ctx, sub, availability); err != nil && s.log != nil {
- s.log.Warn("site-search subscription archive check failed",
- zap.String("subscription_id", sub.ID),
- zap.String("subscription", sub.Name),
- zap.Error(err))
- }
- return availability
-}
-
-func (s *SubscriptionService) handleSiteSearchQueueResult(sub *model.Subscription, keyword string, queueResult siteSearchQueueResult, selectionStats siteSearchSelectionStats, availability LocalAvailability) (int, error) {
- if queueResult.Queued > 0 {
- if s.log != nil {
- fields := subscriptionSiteSearchLogFields(sub, keyword)
- fields = appendSiteSearchSelectionLogFields(fields, selectionStats)
- fields = appendAvailabilityLogFields(fields, availability)
- fields = append(fields,
- zap.Int("queued", queueResult.Queued),
- zap.Strings("resources", queueResult.Resources),
- zap.Bool("archived", sub.ArchivedAt != nil),
- zap.String("archive_reason", sub.ArchiveReason),
- )
- s.log.Info("site-search subscription queued resources", fields...)
- }
- s.hub.Publish("subscription", map[string]any{
- "id": sub.ID,
- "name": sub.Name,
- "queued": queueResult.Queued,
- "keyword": keyword,
- "resources": queueResult.Resources,
- })
- s.notifySubscriptionHit(sub, queueResult.Queued, queueResult.Resources)
- return queueResult.Queued, nil
- }
- if queueResult.LastEnqueueErr != nil {
- return 0, fmt.Errorf("找到 PT 资源但加入下载器失败: %w", queueResult.LastEnqueueErr)
- }
- if s.log != nil {
- fields := subscriptionSiteSearchLogFields(sub, keyword)
- fields = appendSiteSearchSelectionLogFields(fields, selectionStats)
- fields = appendAvailabilityLogFields(fields, availability)
- fields = append(fields, zap.Int("queued", queueResult.Queued))
- s.log.Info("site-search subscription no candidate queued", fields...)
- }
- return 0, nil
-}
-
-func (s *SubscriptionService) searchSubscriptionSites(ctx context.Context, sub *model.Subscription, keywords []string) ([]SearchResult, error) {
- var (
- results []SearchResult
- lastSearchErr error
- searchErrors int
- )
- for _, searchKeyword := range keywords {
- found, err := s.site.Search(ctx, searchKeyword)
- if err != nil {
- lastSearchErr = err
- searchErrors++
- if s.log != nil {
- fields := subscriptionSiteSearchLogFields(sub, searchKeyword)
- fields = append(fields, zap.Error(err))
- s.log.Warn("site-search subscription search failed", fields...)
- }
- if subscriptionSiteSearchShouldStopOnError(err) {
- return nil, err
- }
- continue
- }
- results = append(results, found...)
- if hasUsableSubscriptionSearchResult(found, sub) {
- break
- }
- }
- results = dedupeSiteSearchResults(results)
- if len(results) == 0 && lastSearchErr != nil && searchErrors == len(keywords) {
- return nil, lastSearchErr
- }
- return results, nil
-}
-
-func subscriptionSiteSearchShouldStopOnError(err error) bool {
- if err == nil {
- return false
- }
- var limited *siteAPIRateLimitError
- if errors.As(err, &limited) {
- return true
- }
- if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
- return true
- }
- var timeout interface{ Timeout() bool }
- if errors.As(err, &timeout) && timeout.Timeout() {
- return true
- }
- msg := strings.ToLower(err.Error())
- return strings.Contains(msg, "rate limit") ||
- strings.Contains(msg, "quota") ||
- strings.Contains(msg, "too many requests") ||
- strings.Contains(msg, "status 429") ||
- strings.Contains(msg, "context deadline exceeded") ||
- strings.Contains(msg, "tls handshake timeout") ||
- strings.Contains(msg, "i/o timeout") ||
- strings.Contains(msg, "unexpected eof") ||
- strings.Contains(msg, "connection reset") ||
- strings.Contains(msg, "connection refused") ||
- strings.Contains(msg, "no such host") ||
- strings.Contains(msg, "temporary failure")
-}
-
-func hasUsableSubscriptionSearchResult(results []SearchResult, sub *model.Subscription) bool {
- for _, item := range results {
- if !subscriptionSearchResultMatchesQuery(sub, item) {
- continue
- }
- if !matchesSubscriptionRules(sub, subscriptionSearchResultText(item)) {
- continue
- }
- if strings.TrimSpace(firstNonEmpty(item.DownloadURL, item.TorrentURL)) != "" {
- return true
- }
- }
- return false
-}
-
-func (s *SubscriptionService) finishSiteSearchNoResults(sub *model.Subscription, keyword string) (int, error) {
- if s.log != nil {
- fields := subscriptionSiteSearchLogFields(sub, keyword)
- fields = append(fields, zap.Int("results_count", 0))
- s.log.Info("site-search subscription no results", fields...)
- }
- now := time.Now()
- if err := s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error; err != nil && s.log != nil {
- s.log.Warn("site-search subscription last_run_at update failed",
- zap.String("subscription_id", sub.ID),
- zap.String("subscription", sub.Name),
- zap.Error(err))
- }
- return 0, nil
-}
-
-func (s *SubscriptionService) loadSiteSearchSeen(ctx context.Context, sub *model.Subscription) (string, []string, map[string]struct{}) {
- guidKey := fmt.Sprintf("subscription.%s.seen", sub.ID)
- seenRaw, _ := s.repo.Setting.Get(ctx, guidKey)
- seen := splitNonEmpty(seenRaw)
- seenSet := make(map[string]struct{}, len(seen))
- for _, g := range seen {
- seenSet[g] = struct{}{}
- }
- return guidKey, seen, seenSet
-}
-
-func trimSiteSearchSeen(seen []string) []string {
- if len(seen) <= 200 {
- return seen
- }
- return seen[len(seen)-200:]
-}
diff --git a/internal/service/subscription_site_search_enqueue.go b/internal/service/subscription_site_search_enqueue.go
deleted file mode 100644
index 8ff6a78..0000000
--- a/internal/service/subscription_site_search_enqueue.go
+++ /dev/null
@@ -1,164 +0,0 @@
-package service
-
-import (
- "context"
- "strings"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-type siteSearchRunState struct {
- Keyword string
- Seen []string
- SeenSet map[string]struct{}
- Availability LocalAvailability
-}
-
-type siteSearchQueueResult struct {
- Queued int
- Resources []string
- LastEnqueueErr error
-}
-
-func (s *SubscriptionService) enqueueSiteSearchCandidates(ctx context.Context, sub *model.Subscription, candidates []siteSearchCandidate, state *siteSearchRunState) siteSearchQueueResult {
- var result siteSearchQueueResult
- for _, candidate := range candidates {
- title, err := s.enqueueSiteSearchCandidate(ctx, sub, candidate, state)
- if err != nil {
- result.LastEnqueueErr = err
- continue
- }
- if title == "" {
- continue
- }
- result.Queued++
- result.Resources = append(result.Resources, title)
- }
- return result
-}
-
-func (s *SubscriptionService) enqueueSiteSearchCandidate(ctx context.Context, sub *model.Subscription, candidate siteSearchCandidate, state *siteSearchRunState) (string, error) {
- item := candidate.Item
- matchText := subscriptionSearchResultText(item)
- mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, matchText, item.Category)
- if s.shouldSkipExistingTorrent(ctx, mediaType, candidate) {
- state.markCandidateAvailable(candidate)
- state.markCandidateSeen(candidate.GUID)
- s.logSiteSearchCandidateSkipped(sub, state, candidate, "existing_torrent", mediaType, "", "")
- return "", nil
- }
-
- realURL := s.site.ResolveDownloadURL(ctx, candidate.Download)
- savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory)
- if s.downloadPathHasCandidate(ctx, sub, matchText, savePath) {
- state.markCandidateAvailable(candidate)
- state.markCandidateSeen(candidate.GUID)
- s.logSiteSearchCandidateSkipped(sub, state, candidate, "download_path_has_candidate", mediaType, mediaCategory, savePath)
- return "", nil
- }
-
- if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, realURL, savePath, DownloadTaskMeta{
- SubscriptionID: sub.ID,
- Title: firstNonEmpty(item.Title, sub.Name),
- PosterURL: sub.PosterURL,
- BackdropURL: sub.BackdropURL,
- Overview: sub.Overview,
- MediaType: mediaType,
- MediaCategory: mediaCategory,
- SourceCategory: item.Category,
- AllowExistingLibrary: subscriptionAllowsWash(sub),
- }); err != nil {
- if IsDownloadDedupError(err) {
- if s.subscriptionCandidateConfirmedAvailable(ctx, sub, candidate) {
- state.markCandidateAvailable(candidate)
- s.logSiteSearchCandidateSkipped(sub, state, candidate, "download_dedup", mediaType, mediaCategory, savePath)
- return "", nil
- }
- s.logSiteSearchCandidateSkipped(sub, state, candidate, "download_dedup_unconfirmed", mediaType, mediaCategory, savePath)
- return "", nil
- }
- s.logSiteSearchEnqueueFailed(sub, state, candidate, mediaType, mediaCategory, savePath, err)
- return "", err
- }
-
- state.markCandidateAvailable(candidate)
- state.markCandidateSeen(candidate.GUID)
- s.logSiteSearchCandidateQueued(sub, state, candidate, mediaType, mediaCategory, savePath)
- return item.Title, nil
-}
-
-func (state *siteSearchRunState) markCandidateAvailable(candidate siteSearchCandidate) {
- addSiteSearchCandidateAvailability(candidate, &state.Availability)
-}
-
-func (state *siteSearchRunState) markCandidateSeen(guid string) {
- if strings.TrimSpace(guid) == "" {
- return
- }
- state.Seen = append(state.Seen, guid)
- if state.SeenSet != nil {
- state.SeenSet[guid] = struct{}{}
- }
-}
-
-func (s *SubscriptionService) logSiteSearchCandidateSkipped(sub *model.Subscription, state *siteSearchRunState, candidate siteSearchCandidate, reason, mediaType, mediaCategory, savePath string) {
- if s.log == nil {
- return
- }
- fields := subscriptionSiteSearchLogFields(sub, state.Keyword)
- fields = append(fields, zap.String("reason", reason))
- fields = appendSiteSearchCandidateLogFields(fields, candidate)
- fields = append(fields, zap.String("media_type", mediaType))
- if mediaCategory != "" {
- fields = append(fields, zap.String("media_category", mediaCategory))
- }
- if savePath != "" {
- fields = append(fields, zap.String("save_path", savePath))
- }
- s.log.Info("site-search subscription candidate skipped", fields...)
-}
-
-func (s *SubscriptionService) logSiteSearchCandidateQueued(sub *model.Subscription, state *siteSearchRunState, candidate siteSearchCandidate, mediaType, mediaCategory, savePath string) {
- if s.log == nil {
- return
- }
- fields := subscriptionSiteSearchLogFields(sub, state.Keyword)
- fields = appendSiteSearchCandidateLogFields(fields, candidate)
- fields = append(fields,
- zap.Int("score", candidate.Score),
- zap.String("media_type", mediaType),
- zap.String("media_category", mediaCategory),
- zap.String("save_path", savePath),
- )
- s.log.Info("site-search subscription candidate queued", fields...)
-}
-
-func (s *SubscriptionService) logSiteSearchEnqueueFailed(sub *model.Subscription, state *siteSearchRunState, candidate siteSearchCandidate, mediaType, mediaCategory, savePath string, err error) {
- if s.log == nil {
- return
- }
- fields := subscriptionSiteSearchLogFields(sub, state.Keyword)
- fields = appendSiteSearchCandidateLogFields(fields, candidate)
- fields = append(fields,
- zap.String("media_type", mediaType),
- zap.String("media_category", mediaCategory),
- zap.String("save_path", savePath),
- zap.Error(err),
- )
- s.log.Warn("site-search subscription enqueue failed", fields...)
-}
-
-func appendSiteSearchCandidateLogFields(fields []zap.Field, candidate siteSearchCandidate) []zap.Field {
- item := candidate.Item
- return append(fields,
- zap.String("title", item.Title),
- zap.String("subtitle", item.Subtitle),
- zap.String("site", firstNonEmpty(item.SiteName, item.SiteID)),
- zap.String("site_category", item.Category),
- zap.Int("season", candidate.Season),
- zap.Int("episode", candidate.Episode),
- zap.Bool("pack", candidate.Pack),
- )
-}
diff --git a/internal/service/subscription_site_search_enqueue_test.go b/internal/service/subscription_site_search_enqueue_test.go
deleted file mode 100644
index c43e41b..0000000
--- a/internal/service/subscription_site_search_enqueue_test.go
+++ /dev/null
@@ -1,156 +0,0 @@
-package service
-
-import (
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestEnqueueSiteSearchDedupMarksEnglishRangeAvailableForChineseSubscription(t *testing.T) {
- db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{})
- repos := repository.New(db)
- if err := repos.Download.Create(t.Context(), &model.DownloadTask{
- UserID: "u1",
- Source: "qbittorrent",
- URL: "https://pt.example/download?id=old",
- Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL",
- SavePath: "/downloads/国产剧",
- Status: "queued",
- Progress: 0.1,
- }); err != nil {
- t.Fatal(err)
- }
-
- site := NewSiteService(zap.NewNop(), repos, "")
- downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
- svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, site, NewHub(zap.NewNop()))
- sub := &model.Subscription{
- Base: model.Base{ID: "sub-nanyang"},
- UserID: "u1",
- Name: "南部档案 自动订阅",
- Filter: "南部档案 2026",
- MediaType: "tv",
- MediaCategory: "国产剧",
- SavePath: "/downloads",
- TotalEpisodes: 33,
- }
- state := &siteSearchRunState{
- Keyword: "南部档案 2026",
- SeenSet: map[string]struct{}{},
- Availability: LocalAvailability{
- TotalEpisodes: 33,
- ExistingEpisodeKeys: map[string]struct{}{},
- MissingEpisodeKeys: map[string]struct{}{},
- },
- }
- candidate := siteSearchCandidate{
- Item: SearchResult{
- Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL",
- DownloadURL: "https://pt.example/download?id=new",
- },
- Download: "https://pt.example/download?id=new",
- GUID: "site|m-team|nanyang-7-8",
- Season: 1,
- Episode: 7,
- Episodes: []int{7, 8},
- Pack: true,
- }
-
- title, err := svc.enqueueSiteSearchCandidate(t.Context(), sub, candidate, state)
- if err != nil {
- t.Fatalf("enqueueSiteSearchCandidate returned %v, want dedup skip without error", err)
- }
- if title != "" {
- t.Fatalf("title = %q, want empty because candidate was deduped", title)
- }
- for _, episode := range []int{7, 8} {
- if _, ok := state.Availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok {
- t.Fatalf("availability missing E%d after dedup range: %#v", episode, state.Availability.ExistingEpisodeKeys)
- }
- }
- if _, ok := state.SeenSet[candidate.GUID]; ok {
- t.Fatalf("deduped candidate should not be marked seen before the next availability scan")
- }
-}
-
-func TestEnqueueSiteSearchDedupLinksEnglishHistoryTaskToChineseSubscription(t *testing.T) {
- db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{})
- repos := repository.New(db)
- if err := repos.Download.Create(t.Context(), &model.DownloadTask{
- UserID: "u1",
- Source: "qbittorrent",
- URL: "https://pt.example/download?id=history",
- Title: "The Heir 2026 S01E33-E34 2160p WEB-DL",
- SavePath: "/downloads/国产剧",
- Status: "queued",
- Progress: 0.2,
- }); err != nil {
- t.Fatal(err)
- }
-
- site := NewSiteService(zap.NewNop(), repos, "")
- downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
- svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, site, NewHub(zap.NewNop()))
- sub := &model.Subscription{
- Base: model.Base{ID: "sub-heir"},
- UserID: "u1",
- Name: "家业 自动订阅",
- Filter: "家业 2026",
- MediaType: "tv",
- MediaCategory: "国产剧",
- SavePath: "/downloads",
- TotalEpisodes: 42,
- }
- state := &siteSearchRunState{
- Keyword: "家业 2026",
- SeenSet: map[string]struct{}{},
- Availability: LocalAvailability{
- TotalEpisodes: 42,
- ExistingEpisodeKeys: map[string]struct{}{},
- MissingEpisodeKeys: map[string]struct{}{},
- },
- }
- candidate := siteSearchCandidate{
- Item: SearchResult{
- Title: "The Heir 2026 S01E33-E34 2160p WEB-DL",
- DownloadURL: "https://pt.example/download?id=new",
- },
- Download: "https://pt.example/download?id=new",
- GUID: "site|m-team|heir-33-34",
- Season: 1,
- Episode: 33,
- Episodes: []int{33, 34},
- Pack: true,
- }
-
- title, err := svc.enqueueSiteSearchCandidate(t.Context(), sub, candidate, state)
- if err != nil {
- t.Fatalf("enqueueSiteSearchCandidate returned %v, want dedup skip without error", err)
- }
- if title != "" {
- t.Fatalf("title = %q, want empty because candidate was deduped", title)
- }
- var rows []model.DownloadTask
- if err := db.Find(&rows).Error; err != nil {
- t.Fatal(err)
- }
- if len(rows) != 1 {
- t.Fatalf("download rows = %d, want 1", len(rows))
- }
- if rows[0].SubscriptionID != sub.ID {
- t.Fatalf("history task subscription_id = %q, want %q", rows[0].SubscriptionID, sub.ID)
- }
- if rows[0].MediaType != "tv" || rows[0].MediaCategory != "国产剧" {
- t.Fatalf("history task metadata = (%q, %q), want (tv, 国产剧)", rows[0].MediaType, rows[0].MediaCategory)
- }
-
- availability := svc.pendingDownloadAvailability(t.Context(), sub)
- for _, episode := range []int{33, 34} {
- if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok {
- t.Fatalf("linked history task missing E%d availability: %#v", episode, availability.ExistingEpisodeKeys)
- }
- }
-}
diff --git a/internal/service/subscription_site_search_helpers.go b/internal/service/subscription_site_search_helpers.go
deleted file mode 100644
index c649124..0000000
--- a/internal/service/subscription_site_search_helpers.go
+++ /dev/null
@@ -1,203 +0,0 @@
-package service
-
-import (
- "context"
- "net/url"
- "strings"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func subscriptionSiteSearchLogFields(sub *model.Subscription, keyword string) []zap.Field {
- fields := []zap.Field{zap.String("keyword", keyword), zap.Strings("search_keywords", siteSearchKeywords(sub))}
- if sub == nil {
- return fields
- }
- fields = append(fields,
- zap.String("subscription_id", sub.ID),
- zap.String("subscription", sub.Name),
- zap.String("filter", sub.Filter),
- zap.String("media_type", sub.MediaType),
- zap.String("media_category", sub.MediaCategory),
- zap.String("search_mode", sub.SearchMode),
- zap.String("imdb_id", sub.IMDBID),
- zap.Bool("wash_enabled", sub.WashEnabled),
- zap.String("wash_priority", sub.WashPriority),
- zap.Int("total_episodes", sub.TotalEpisodes),
- )
- return fields
-}
-
-func appendSiteSearchSelectionLogFields(fields []zap.Field, stats siteSearchSelectionStats) []zap.Field {
- return append(fields,
- zap.Int("results_count", stats.Total),
- zap.Int("query_mismatch_count", stats.QueryMismatch),
- zap.Strings("query_mismatch_examples", stats.QueryMismatchExamples),
- zap.Int("relaxed_query_match_count", stats.RelaxedQueryMatch),
- zap.Int("rule_mismatch_count", stats.RuleMismatch),
- zap.Int("missing_download_count", stats.MissingDownload),
- zap.Int("seen_count", stats.Seen),
- zap.Int("prepared_count", stats.Prepared),
- zap.Int("selected_count", stats.Selected),
- zap.Bool("local_already_satisfied", stats.LocalAlreadySatisfied),
- zap.Bool("local_series_pack_present", stats.LocalSeriesPackPresent),
- zap.Bool("series_complete", stats.SeriesComplete),
- zap.Int("existing_episode_skipped_count", stats.ExistingEpisodeSkipped),
- zap.Int("not_missing_episode_skipped_count", stats.NotMissingEpisodeSkipped),
- zap.Int("no_episode_skipped_count", stats.NoEpisodeSkipped),
- zap.Bool("pack_fallback_available", stats.PackFallbackAvailable),
- zap.Bool("pack_fallback_used", stats.PackFallbackUsed),
- )
-}
-
-func appendAvailabilityLogFields(fields []zap.Field, availability LocalAvailability) []zap.Field {
- missingSample, missingMore := limitedEpisodeSample(availability.MissingEpisodes, 20)
- return append(fields,
- zap.Int("local_media_count", availability.LocalMediaCount),
- zap.Bool("in_library", availability.InLibrary),
- zap.Bool("has_series_pack", availability.HasSeriesPack),
- zap.Int("downloaded_episodes", availability.DownloadedEpisodes),
- zap.Int("availability_total_episodes", availability.TotalEpisodes),
- zap.Int("missing_episode_count", len(availability.MissingEpisodes)),
- zap.Ints("missing_episodes", missingSample),
- zap.Int("missing_episodes_more", missingMore),
- )
-}
-
-func limitedEpisodeSample(values []int, limit int) ([]int, int) {
- if limit <= 0 || len(values) == 0 {
- return nil, len(values)
- }
- if len(values) <= limit {
- out := append([]int(nil), values...)
- return out, 0
- }
- out := append([]int(nil), values[:limit]...)
- return out, len(values) - limit
-}
-
-func (s *SubscriptionService) shouldSkipExistingTorrent(ctx context.Context, mediaType string, candidate siteSearchCandidate) bool {
- if s == nil || s.downloads == nil {
- return false
- }
- if isSubscriptionSeriesType(mediaType) && candidate.Episode > 0 {
- return false
- }
- return s.downloads.TorrentExistsByName(ctx, candidate.Item.Title)
-}
-
-func siteSearchKeywords(sub *model.Subscription) []string {
- if sub == nil {
- return nil
- }
- values := make([]string, 0, 8)
- if strings.EqualFold(strings.TrimSpace(sub.SearchMode), "imdb") && strings.TrimSpace(sub.IMDBID) != "" {
- values = append(values, strings.TrimSpace(sub.IMDBID))
- }
- if u, err := url.Parse(sub.FeedURL); err == nil {
- if keyword := strings.TrimSpace(u.Query().Get("keyword")); keyword != "" {
- values = append(values, keyword)
- }
- }
- if strings.TrimSpace(sub.Filter) != "" {
- values = append(values, sub.Filter)
- }
- if len(values) == 0 && strings.TrimSpace(sub.Name) != "" {
- values = append(values, sub.Name)
- }
- values = append(values, subscriptionFeedAliases(sub)...)
- values = append(values, subscriptionMetadataAliases(sub)...)
- for _, value := range append([]string(nil), values...) {
- if cleaned := cleanAvailabilityTitle(value); cleaned != "" {
- values = append(values, cleaned)
- }
- }
- return compactUniqueStrings(values...)
-}
-
-func siteSearchKeyword(sub *model.Subscription) string {
- keywords := siteSearchKeywords(sub)
- if len(keywords) == 0 {
- return ""
- }
- return keywords[0]
-}
-
-func subscriptionFeedAliases(sub *model.Subscription) []string {
- if sub == nil {
- return nil
- }
- u, err := url.Parse(sub.FeedURL)
- if err != nil {
- return nil
- }
- q := u.Query()
- values := make([]string, 0, len(q["alias"])+2)
- values = append(values, q["alias"]...)
- for _, raw := range q["aliases"] {
- for _, part := range strings.FieldsFunc(raw, func(r rune) bool {
- return r == '|' || r == '\n' || r == '\r' || r == '\t'
- }) {
- values = append(values, part)
- }
- }
- return compactUniqueStrings(values...)
-}
-
-func subscriptionMetadataAliases(sub *model.Subscription) []string {
- if sub == nil {
- return nil
- }
- title := cleanAvailabilityTitle(firstNonEmpty(sub.Filter, sub.Name))
- return buildSubscribeAliases(title, sub.OriginalName, sub.Year)
-}
-
-func compactUniqueStrings(values ...string) []string {
- seen := map[string]struct{}{}
- out := make([]string, 0, len(values))
- for _, value := range values {
- value = strings.TrimSpace(value)
- if value == "" {
- continue
- }
- key := normalizeAvailabilityComparable(value)
- if key == "" {
- continue
- }
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- out = append(out, value)
- }
- return out
-}
-
-func dedupeSiteSearchResults(results []SearchResult) []SearchResult {
- if len(results) < 2 {
- return results
- }
- seen := make(map[string]int, len(results))
- out := make([]SearchResult, 0, len(results))
- for _, item := range results {
- download := strings.TrimSpace(item.DownloadURL)
- if download == "" {
- download = strings.TrimSpace(item.TorrentURL)
- }
- key := stableSiteSearchGUID(item, download)
- if idx, ok := seen[key]; ok {
- out[idx].SearchKeyword = mergeSearchResultTextHints(out[idx].SearchKeyword, item.SearchKeyword)
- out[idx].Subtitle = mergeSearchResultTextHints(out[idx].Subtitle, item.Subtitle)
- continue
- }
- seen[key] = len(out)
- out = append(out, item)
- }
- return out
-}
-
-func mergeSearchResultTextHints(current, next string) string {
- return strings.Join(compactUniqueStrings(current, next), "\n")
-}
diff --git a/internal/service/subscription_site_search_keywords_test.go b/internal/service/subscription_site_search_keywords_test.go
deleted file mode 100644
index 3b02d8e..0000000
--- a/internal/service/subscription_site_search_keywords_test.go
+++ /dev/null
@@ -1,110 +0,0 @@
-package service
-
-import (
- "strings"
- "testing"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func TestSiteSearchKeywordCanUseIMDB(t *testing.T) {
- sub := &model.Subscription{Name: "沙丘 自动订阅", Filter: "Dune 2021", SearchMode: "imdb", IMDBID: "tt1160419"}
- if got := siteSearchKeyword(sub); got != "tt1160419" {
- t.Fatalf("keyword = %q, want imdb id", got)
- }
-}
-
-func TestSiteSearchKeywordsIncludeAliasesAndCleanedKeywords(t *testing.T) {
- sub := &model.Subscription{
- Name: "真人快打2 自动订阅",
- FeedURL: "site-search://search?keyword=%E7%9C%9F%E4%BA%BA%E5%BF%AB%E6%89%932%202026&alias=Mortal%20Kombat%20II%202026",
- Filter: "真人快打2 2026",
- }
-
- got := siteSearchKeywords(sub)
- for _, want := range []string{"真人快打2 2026", "Mortal Kombat II 2026", "真人快打2", "Mortal Kombat II"} {
- if !containsString(got, want) {
- t.Fatalf("keywords = %#v, missing %q", got, want)
- }
- }
- if got[0] != "真人快打2 2026" {
- t.Fatalf("primary keyword = %q, want feed keyword first", got[0])
- }
-}
-
-func TestSiteSearchKeywordsUseCleanMetadataAliases(t *testing.T) {
- sub := &model.Subscription{
- Name: "玩具总动员 4 自动订阅",
- Filter: "玩具总动员 4 2019",
- OriginalName: "Toy Story 4",
- Year: 2019,
- }
-
- got := siteSearchKeywords(sub)
- for _, want := range []string{"玩具总动员 4 2019", "Toy Story 4", "Toy Story 4 2019", "玩具总动员 4"} {
- if !containsString(got, want) {
- t.Fatalf("keywords = %#v, missing %q", got, want)
- }
- }
- for _, unwanted := range []string{"玩具总动员 4 自动订阅", "玩具总动员 4 自动订阅 2019", "玩具总动员 4 2019 2019"} {
- if containsString(got, unwanted) {
- t.Fatalf("keywords = %#v, should not contain %q", got, unwanted)
- }
- }
-}
-
-func TestStableSiteSearchGUIDIgnoresPrivateTokenChanges(t *testing.T) {
- item := SearchResult{
- SiteID: "mteam",
- Title: "Some Show S01E01 1080p",
- Category: "TV",
- Size: 1024,
- }
- first := stableSiteSearchGUID(item, "https://pt.example/download?id=123&passkey=old")
- second := stableSiteSearchGUID(item, "https://pt.example/download?id=123&passkey=new")
- if first != second {
- t.Fatalf("stableSiteSearchGUID changed with token: %q != %q", first, second)
- }
- if strings.Contains(first, "passkey") || strings.Contains(first, "old") || strings.Contains(first, "new") {
- t.Fatalf("stableSiteSearchGUID leaked private token: %q", first)
- }
-}
-
-func TestSelectSiteSearchCandidatesWithStatsExplainsFiltering(t *testing.T) {
- sub := &model.Subscription{Name: "Stats Show 自动订阅", Filter: "Stats Show", MediaType: "tv"}
- seenItem := SearchResult{Title: "Stats Show S01E02 1080p", DownloadURL: "https://pt/download/seen", Seeders: 50}
- seenGUID := stableSiteSearchGUID(seenItem, seenItem.DownloadURL)
- results := []SearchResult{
- {Title: "Different Show S01E01 1080p", DownloadURL: "https://pt/download/wrong", Seeders: 90},
- {Title: "Stats Show S01E01 CAM", DownloadURL: "https://pt/download/cam", Seeders: 80},
- {Title: "Stats Show S01E02 1080p", Seeders: 70},
- seenItem,
- {Title: "Stats Show S01E03 1080p", DownloadURL: "https://pt/download/right", Seeders: 60},
- }
-
- got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{seenGUID: {}}, LocalAvailability{})
- if len(got) != 1 || got[0].Download != "https://pt/download/right" {
- t.Fatalf("selected %#v, want only unfiltered candidate", got)
- }
- if stats.Total != 5 ||
- stats.QueryMismatch != 1 ||
- stats.RuleMismatch != 1 ||
- stats.MissingDownload != 1 ||
- stats.Seen != 1 ||
- stats.Prepared != 1 ||
- stats.Selected != 1 {
- t.Fatalf("unexpected stats: %#v", stats)
- }
- if len(stats.QueryMismatchExamples) != 1 || stats.QueryMismatchExamples[0] != "Different Show S01E01 1080p" {
- t.Fatalf("query mismatch examples = %#v", stats.QueryMismatchExamples)
- }
-}
-
-func containsString(values []string, want string) bool {
- for _, value := range values {
- if value == want {
- return true
- }
- }
- return false
-}
diff --git a/internal/service/subscription_site_search_limit_test.go b/internal/service/subscription_site_search_limit_test.go
deleted file mode 100644
index 38519e4..0000000
--- a/internal/service/subscription_site_search_limit_test.go
+++ /dev/null
@@ -1,129 +0,0 @@
-package service
-
-import (
- "errors"
- "net/http"
- "net/http/httptest"
- "sync/atomic"
- "testing"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestSearchSubscriptionSitesStopsAfterRateLimit(t *testing.T) {
- var requests atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- requests.Add(1)
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`))
- }))
- defer upstream.Close()
-
- db := newServiceTestDB(t, &model.Site{}, &model.Setting{})
- repos := repository.New(db)
- siteSvc := NewSiteService(zap.NewNop(), repos, "")
- limiter := &staticSiteAPIRateLimiter{err: &siteAPIRateLimitError{
- Bucket: "torrent_search_24h",
- Limit: 1500,
- Window: 24 * time.Hour,
- RetryAfter: time.Hour,
- }}
- siteSvc.apiRateLimiter = limiter
- if err := siteSvc.Create(t.Context(), &model.Site{
- Name: "馒头",
- Type: "mteam",
- URL: upstream.URL,
- AuthType: "api_key",
- APIKey: "token-123",
- Enabled: true,
- Timeout: 5,
- }); err != nil {
- t.Fatal(err)
- }
- svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, siteSvc, NewHub(zap.NewNop()))
- sub := &model.Subscription{Name: "问心2 自动订阅", Filter: "问心2", MediaType: "tv"}
-
- _, err := svc.searchSubscriptionSites(t.Context(), sub, []string{"问心2", "问心", "问心2 2023"})
- var limited *siteAPIRateLimitError
- if !errors.As(err, &limited) {
- t.Fatalf("searchSubscriptionSites error = %v, want siteAPIRateLimitError", err)
- }
- if limiter.calls != 1 {
- t.Fatalf("rate limiter calls = %d, want 1 keyword attempt", limiter.calls)
- }
- if got := requests.Load(); got != 0 {
- t.Fatalf("HTTP requests = %d, want 0 after local rate limit", got)
- }
-}
-
-func TestSubscriptionRunAllStopsSweepAfterRateLimit(t *testing.T) {
- var requests atomic.Int32
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- requests.Add(1)
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`))
- }))
- defer upstream.Close()
-
- db := newServiceTestDB(t, &model.Site{}, &model.Setting{}, &model.Subscription{})
- repos := repository.New(db)
- siteSvc := NewSiteService(zap.NewNop(), repos, "")
- limiter := &staticSiteAPIRateLimiter{err: &siteAPIRateLimitError{
- Bucket: "torrent_search_24h",
- Limit: 1500,
- Window: 24 * time.Hour,
- RetryAfter: time.Hour,
- }}
- siteSvc.apiRateLimiter = limiter
- if err := siteSvc.Create(t.Context(), &model.Site{
- Name: "馒头",
- Type: "mteam",
- URL: upstream.URL,
- AuthType: "api_key",
- APIKey: "token-123",
- Enabled: true,
- Timeout: 5,
- }); err != nil {
- t.Fatal(err)
- }
- for _, name := range []string{"问心2 自动订阅", "南部档案 自动订阅"} {
- sub := &model.Subscription{
- Name: name,
- FeedURL: "site-search://search?keyword=" + name,
- Filter: name,
- Enabled: true,
- }
- if err := repos.Subscription.Create(t.Context(), sub); err != nil {
- t.Fatal(err)
- }
- }
- svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, siteSvc, NewHub(zap.NewNop()))
-
- svc.runAll(t.Context())
- if limiter.calls != 1 {
- t.Fatalf("rate limiter calls = %d, want sweep to stop after first quota failure", limiter.calls)
- }
- if got := requests.Load(); got != 0 {
- t.Fatalf("HTTP requests = %d, want 0 after local rate limit", got)
- }
-}
-
-func TestSubscriptionSiteSearchStopsAfterTransientSiteErrors(t *testing.T) {
- for _, errText := range []string{
- `search: Post "https://api.m-team.cc/api/torrent/search": context deadline exceeded`,
- `search: Post "https://api.m-team.cc/api/torrent/search": net/http: TLS handshake timeout`,
- `search: Post "https://api.m-team.cc/api/torrent/search": unexpected EOF`,
- `search: Post "https://api.m-team.cc/api/torrent/search": read tcp 127.0.0.1: connection reset by peer`,
- } {
- if !subscriptionSiteSearchShouldStopOnError(errors.New(errText)) {
- t.Fatalf("subscriptionSiteSearchShouldStopOnError(%q) = false, want true", errText)
- }
- }
- if subscriptionSiteSearchShouldStopOnError(errors.New("temporary parser warning: no matching torrent rows")) {
- t.Fatal("non-upstream-failure errors should not stop alias search")
- }
-}
diff --git a/internal/service/subscription_test.go b/internal/service/subscription_test.go
deleted file mode 100644
index a2b50f1..0000000
--- a/internal/service/subscription_test.go
+++ /dev/null
@@ -1,180 +0,0 @@
-package service
-
-import (
- "net/http"
- "net/http/httptest"
- "sync/atomic"
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-func TestDeleteSubscriptionRemovesDownloaderTaskAndSeenState(t *testing.T) {
- const title = "Delete Subscription Show S01E01 1080p"
- const hash = "abcdef1234567890abcdef1234567890abcdef12"
- var deleteCalls atomic.Int32
- qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/api/v2/auth/login":
- _, _ = w.Write([]byte("Ok."))
- case "/api/v2/torrents/info":
- _, _ = w.Write([]byte(`[{"hash":"` + hash + `","name":"` + title + `","state":"downloading","progress":0.2}]`))
- case "/api/v2/torrents/delete":
- deleteCalls.Add(1)
- if got := r.FormValue("deleteFiles"); got != "false" {
- t.Fatalf("deleteFiles = %q, want false", got)
- }
- _, _ = w.Write([]byte("Ok."))
- default:
- http.NotFound(w, r)
- }
- }))
- defer qb.Close()
-
- db := newServiceTestDB(t, &model.Subscription{}, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{})
- repos := repository.New(db)
- configureTestDefaultQB(t, repos, qb.URL)
- downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
- if err := downloads.ReloadConfig(t.Context()); err != nil {
- t.Fatal(err)
- }
- svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop()))
- sub := &model.Subscription{Name: "Delete Subscription Show 自动订阅", Filter: "Delete Subscription Show", FeedURL: "https://rss.example/feed", UserID: "u1", SavePath: "/downloads/tv"}
- if err := repos.Subscription.Create(t.Context(), sub); err != nil {
- t.Fatal(err)
- }
- task := &model.DownloadTask{
- UserID: "u1",
- SubscriptionID: sub.ID,
- Source: "qbittorrent",
- URL: "https://pt.example/download?id=1",
- Title: title,
- SavePath: "/downloads/tv",
- Status: "downloading",
- Progress: 0.2,
- }
- if err := repos.Download.Create(t.Context(), task); err != nil {
- t.Fatal(err)
- }
- if err := repos.Setting.Set(t.Context(), "subscription."+sub.ID+".seen", "guid-1"); err != nil {
- t.Fatal(err)
- }
-
- if err := svc.Delete(t.Context(), sub.ID); err != nil {
- t.Fatalf("delete subscription: %v", err)
- }
- if got := deleteCalls.Load(); got != 1 {
- t.Fatalf("qb delete calls = %d, want 1", got)
- }
- var updated model.DownloadTask
- if err := db.Where("id = ?", task.ID).First(&updated).Error; err != nil {
- t.Fatal(err)
- }
- if updated.Status != "deleted" {
- t.Fatalf("download task status = %q, want deleted", updated.Status)
- }
- seen, err := repos.Setting.Get(t.Context(), "subscription."+sub.ID+".seen")
- if err != nil {
- t.Fatal(err)
- }
- if seen != "" {
- t.Fatalf("seen state = %q, want cleared", seen)
- }
- var count int64
- if err := db.Model(&model.Subscription{}).Where("id = ?", sub.ID).Count(&count).Error; err != nil {
- t.Fatal(err)
- }
- if count != 0 {
- t.Fatalf("active subscription count = %d, want 0", count)
- }
- var deleted model.Subscription
- if err := db.Unscoped().Where("id = ?", sub.ID).First(&deleted).Error; err != nil {
- t.Fatal(err)
- }
- if deleted.Enabled {
- t.Fatal("deleted subscription stayed enabled; active legacy compatibility would show it again")
- }
- active, err := repos.Subscription.List(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(active) != 0 {
- t.Fatalf("active subscriptions = %#v, want deleted subscription hidden", active)
- }
-}
-
-func TestDeletedDownloadTaskDoesNotBlockSubscriptionReadd(t *testing.T) {
- if downloadTaskBlocksReadd("deleted") {
- t.Fatal("deleted download task must not block subscription re-add")
- }
- if downloadTaskBlocksReadd("removed") {
- t.Fatal("removed download task must not block subscription re-add")
- }
-}
-
-func TestListIncludesEnabledSoftDeletedActiveSubscription(t *testing.T) {
- db := newServiceTestDB(t, &model.Subscription{})
- repos := repository.New(db)
- sub := &model.Subscription{
- Name: "Hidden Active 自动订阅",
- FeedURL: "site-search://search?keyword=Hidden%20Active",
- Filter: "Hidden Active",
- Enabled: true,
- }
- if err := repos.Subscription.Create(t.Context(), sub); err != nil {
- t.Fatal(err)
- }
- if err := db.Where("id = ?", sub.ID).Delete(&model.Subscription{}).Error; err != nil {
- t.Fatal(err)
- }
-
- active, err := repos.Subscription.List(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(active) != 1 || active[0].ID != sub.ID {
- t.Fatalf("active subscriptions = %#v, want soft-deleted enabled subscription recovered", active)
- }
-}
-
-func TestDeleteRecoveredSoftDeletedSubscriptionClearsSeenAndHidesIt(t *testing.T) {
- db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{})
- repos := repository.New(db)
- svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop()))
- sub := &model.Subscription{
- Name: "Recovered Hidden 自动订阅",
- FeedURL: "site-search://search?keyword=Recovered%20Hidden",
- Filter: "Recovered Hidden",
- Enabled: true,
- }
- if err := repos.Subscription.Create(t.Context(), sub); err != nil {
- t.Fatal(err)
- }
- if err := repos.Setting.Set(t.Context(), "subscription."+sub.ID+".seen", "old-guid"); err != nil {
- t.Fatal(err)
- }
- if err := db.Where("id = ?", sub.ID).Delete(&model.Subscription{}).Error; err != nil {
- t.Fatal(err)
- }
-
- if err := svc.Delete(t.Context(), sub.ID); err != nil {
- t.Fatal(err)
- }
- active, err := repos.Subscription.List(t.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(active) != 0 {
- t.Fatalf("active subscriptions = %#v, want recovered deleted subscription hidden", active)
- }
- seen, err := repos.Setting.Get(t.Context(), "subscription."+sub.ID+".seen")
- if err != nil {
- t.Fatal(err)
- }
- if seen != "" {
- t.Fatalf("seen state = %q, want cleared", seen)
- }
-}
diff --git a/internal/service/subscription_torrent_rules.go b/internal/service/subscription_torrent_rules.go
deleted file mode 100644
index 3d790ef..0000000
--- a/internal/service/subscription_torrent_rules.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package service
-
-import (
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-const bytesPerGiB = 1024 * 1024 * 1024
-
-func matchesSubscriptionTorrentRules(sub *model.Subscription, item SearchResult) bool {
- if sub == nil {
- return true
- }
- if sub.MinSeeders > 0 && item.Seeders < sub.MinSeeders {
- return false
- }
- if sub.MaxSeeders > 0 && item.Seeders > sub.MaxSeeders {
- return false
- }
- if !subscriptionSizeInRange(item.Size, sub.MinSizeGB, sub.MaxSizeGB) {
- return false
- }
- if sub.FreeOnly && !subscriptionResultIsFree(item) {
- return false
- }
- return true
-}
-
-func subscriptionSizeInRange(sizeBytes int64, minGB, maxGB float64) bool {
- if minGB <= 0 && maxGB <= 0 {
- return true
- }
- if sizeBytes <= 0 {
- return false
- }
- sizeGB := float64(sizeBytes) / bytesPerGiB
- if minGB > 0 && sizeGB < minGB {
- return false
- }
- if maxGB > 0 && sizeGB > maxGB {
- return false
- }
- return true
-}
-
-func subscriptionResultIsFree(item SearchResult) bool {
- if item.Free {
- return true
- }
- text := strings.ToLower(subscriptionSearchResultText(item))
- return strings.Contains(text, "freeleech") ||
- strings.Contains(text, "2xfree") ||
- strings.Contains(text, "免费") ||
- matchesWordBoundary(text, "free")
-}
diff --git a/internal/service/subscription_torrent_rules_test.go b/internal/service/subscription_torrent_rules_test.go
deleted file mode 100644
index 55e9372..0000000
--- a/internal/service/subscription_torrent_rules_test.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package service
-
-import (
- "testing"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func TestSelectSiteSearchCandidatesAppliesSeederSizeAndFreeRules(t *testing.T) {
- sub := &model.Subscription{
- Name: "Some Show 自动订阅",
- Filter: "Some Show",
- MediaType: "tv",
- MinSeeders: 10,
- MaxSeeders: 100,
- MinSizeGB: 1,
- MaxSizeGB: 8,
- FreeOnly: true,
- }
- results := []SearchResult{
- {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/low-seed", Seeders: 3, Size: 2 * bytesPerGiB, Free: true},
- {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/hot", Seeders: 500, Size: 2 * bytesPerGiB, Free: true},
- {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/small", Seeders: 50, Size: bytesPerGiB / 2, Free: true},
- {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/nonfree", Seeders: 50, Size: 2 * bytesPerGiB},
- {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/right", Seeders: 50, Size: 2 * bytesPerGiB, Free: true},
- }
-
- got, stats := selectSiteSearchCandidatesWithStats(results, sub, nil, LocalAvailability{})
- if len(got) != 1 || got[0].Download != "https://pt/download/right" {
- t.Fatalf("selected %#v, want only torrent matching seed/size/free rules", got)
- }
- if stats.RuleMismatch != 4 || stats.Prepared != 1 || stats.Selected != 1 {
- t.Fatalf("stats = %#v, want four rule mismatches and one selected", stats)
- }
-}
-
-func TestSubscriptionTorrentRulesRecognizeFreeLabels(t *testing.T) {
- sub := &model.Subscription{FreeOnly: true}
- for _, item := range []SearchResult{
- {Title: "Some Movie 2026 1080p WEB-DL FREE"},
- {Title: "Some Movie 2026 1080p WEB-DL FreeLeech"},
- {Title: "Some Movie 2026 1080p WEB-DL 免费"},
- {Title: "Some Movie 2026 1080p WEB-DL", Free: true},
- } {
- if !matchesSubscriptionTorrentRules(sub, item) {
- t.Fatalf("expected free rule to accept %#v", item)
- }
- }
- if matchesSubscriptionTorrentRules(sub, SearchResult{Title: "Some Movie 2026 1080p WEB-DL"}) {
- t.Fatal("free-only rule accepted non-free result")
- }
-}
-
-func TestSubscriptionTorrentRulesRejectUnknownSizeWhenSizeRangeConfigured(t *testing.T) {
- sub := &model.Subscription{MinSizeGB: 1}
- if matchesSubscriptionTorrentRules(sub, SearchResult{Title: "Some Movie 2026 1080p WEB-DL"}) {
- t.Fatal("size range accepted result without size metadata")
- }
-}
diff --git a/internal/service/subtitle.go b/internal/service/subtitle.go
index f7ac854..8ddf4cf 100644
--- a/internal/service/subtitle.go
+++ b/internal/service/subtitle.go
@@ -31,24 +31,13 @@ import (
// SubtitleService is the discovery + conversion entry point.
type SubtitleService struct {
- log *zap.Logger
- repo *repository.Container
- storage *StorageConfigService
+ log *zap.Logger
+ repo *repository.Container
}
// NewSubtitleService is the constructor.
-func NewSubtitleService(log *zap.Logger, repo *repository.Container, storage ...*StorageConfigService) *SubtitleService {
- s := &SubtitleService{log: log, repo: repo}
- if len(storage) > 0 {
- s.storage = storage[0]
- }
- return s
-}
-
-func (s *SubtitleService) SetStorageConfig(storage *StorageConfigService) {
- if s != nil {
- s.storage = storage
- }
+func NewSubtitleService(log *zap.Logger, repo *repository.Container) *SubtitleService {
+ return &SubtitleService{log: log, repo: repo}
}
// SubtitleTrack describes one external subtitle file.
@@ -79,9 +68,6 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti
if m == nil {
return nil, errors.New("media not found")
}
- if strings.HasPrefix(strings.ToLower(strings.TrimSpace(m.Path)), "cloud://") {
- return discoverCloudSubtitles(ctx, s, *m), nil
- }
dir := filepath.Dir(m.Path)
base := strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
@@ -148,9 +134,6 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
if err != nil || m == nil {
return errors.New("media not found")
}
- if typ, ref, name, ok := parseCloudSubtitlePath(sub); ok {
- return serveCloudSubtitle(ctx, s, *m, typ, ref, name, w)
- }
abs, err := filepath.Abs(sub)
if err != nil {
return err
@@ -195,9 +178,6 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
if err != nil || m == nil {
return errors.New("media not found")
}
- if typ, ref, name, ok := parseCloudSubtitlePath(sub); ok {
- return serveCloudSubtitleRaw(ctx, s, *m, typ, ref, name, w)
- }
abs, err := filepath.Abs(sub)
if err != nil {
return err
diff --git a/internal/service/subtitle_cloud.go b/internal/service/subtitle_cloud.go
deleted file mode 100644
index 242ba0d..0000000
--- a/internal/service/subtitle_cloud.go
+++ /dev/null
@@ -1,208 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "net/url"
- "path/filepath"
- "strings"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/service/cloud"
-)
-
-func discoverCloudSubtitles(ctx context.Context, s *SubtitleService, m model.Media) []SubtitleTrack {
- if s == nil || s.storage == nil {
- return []SubtitleTrack{}
- }
- typ, mediaRef, ok := cloudSubtitleMediaRef(m)
- if !ok {
- return []SubtitleTrack{}
- }
- dirRef, mediaName := splitCloudRef(mediaRef)
- if mediaName == "" {
- return []SubtitleTrack{}
- }
- base := strings.TrimSuffix(mediaName, filepath.Ext(mediaName))
- entries, err := s.storage.CloudList(ctx, typ, dirRef)
- if err != nil {
- if s.log != nil {
- s.log.Debug("list cloud subtitles failed", zap.String("provider", typ), zap.String("dir", dirRef), zap.Error(err))
- }
- return []SubtitleTrack{}
- }
- tracks := cloudSubtitleTracks(typ, entries, base, false)
- for _, entry := range entries {
- if !entry.IsDir || !isSubtitleDirectory(entry.Name) || strings.TrimSpace(entry.ID) == "" {
- continue
- }
- subEntries, err := s.storage.CloudList(ctx, typ, entry.ID)
- if err != nil {
- continue
- }
- tracks = append(tracks, cloudSubtitleTracks(typ, subEntries, base, true)...)
- }
- return tracks
-}
-
-func cloudSubtitleTracks(typ string, entries []cloud.FileEntry, base string, subdir bool) []SubtitleTrack {
- tracks := make([]SubtitleTrack, 0)
- baseLower := strings.ToLower(base)
- for _, entry := range entries {
- if entry.IsDir {
- continue
- }
- ext := strings.ToLower(filepath.Ext(entry.Name))
- codec, ok := extToCodec[ext]
- if !ok {
- continue
- }
- fullName := strings.TrimSuffix(entry.Name, ext)
- if !subdir && !strings.HasPrefix(strings.ToLower(fullName), baseLower) {
- continue
- }
- ref := cloudEntryRef(typ, entry.ID, entry.PickCode)
- if ref == "" {
- continue
- }
- lang := detectLang(fullName, base)
- tracks = append(tracks, SubtitleTrack{
- Lang: lang,
- Label: lang,
- Path: buildCloudSubtitlePath(typ, ref, entry.Name),
- Codec: codec,
- })
- }
- return tracks
-}
-
-func cloudSubtitleMediaRef(m model.Media) (typ, ref string, ok bool) {
- if info, parsed := ParseCloudLibraryMount(m.Path); parsed && strings.TrimSpace(info.DisplayDir) != "" {
- return info.Provider, info.DisplayDir, true
- }
- if typ, ref, parsed := parseCloudMediaPlaybackURL(m.STRMURL); parsed {
- return typ, ref, true
- }
- return "", "", false
-}
-
-func splitCloudRef(ref string) (dir, name string) {
- ref = strings.Trim(strings.ReplaceAll(strings.TrimSpace(ref), "\\", "/"), "/")
- if ref == "" {
- return "", ""
- }
- idx := strings.LastIndex(ref, "/")
- if idx < 0 {
- return "", ref
- }
- return ref[:idx], ref[idx+1:]
-}
-
-func isSubtitleDirectory(name string) bool {
- switch strings.ToLower(strings.TrimSpace(name)) {
- case "subs", "sub", ".sub", "subtitles", "subtitle":
- return true
- default:
- return false
- }
-}
-
-func buildCloudSubtitlePath(typ, ref, name string) string {
- u := url.URL{
- Scheme: "cloud",
- Host: strings.TrimSpace(typ),
- Path: "/" + strings.TrimLeft(strings.TrimSpace(ref), "/"),
- }
- q := u.Query()
- q.Set("name", strings.TrimSpace(name))
- u.RawQuery = q.Encode()
- return u.String()
-}
-
-func parseCloudSubtitlePath(raw string) (typ, ref, name string, ok bool) {
- u, err := url.Parse(strings.TrimSpace(raw))
- if err != nil || strings.ToLower(u.Scheme) != "cloud" || strings.TrimSpace(u.Host) == "" {
- return "", "", "", false
- }
- ref = strings.TrimLeft(u.EscapedPath(), "/")
- if decoded, err := url.PathUnescape(ref); err == nil {
- ref = decoded
- }
- return strings.TrimSpace(u.Host), strings.TrimSpace(ref), strings.TrimSpace(u.Query().Get("name")), ref != ""
-}
-
-func serveCloudSubtitle(ctx context.Context, s *SubtitleService, m model.Media, typ, ref, name string, w io.Writer) error {
- if s == nil || s.storage == nil {
- return errors.New("cloud storage service unavailable")
- }
- mediaTyp, _, ok := cloudSubtitleMediaRef(m)
- if !ok || mediaTyp != typ {
- return ErrCloudPlaybackUnavailable
- }
- allowed := false
- for _, track := range discoverCloudSubtitles(ctx, s, m) {
- if track.Path == buildCloudSubtitlePath(typ, ref, name) {
- allowed = true
- break
- }
- }
- if !allowed {
- return fmt.Errorf("path escape")
- }
- body, err := s.storage.CloudReadText(ctx, typ, ref, 8<<20)
- if err != nil {
- return err
- }
- ext := strings.ToLower(filepath.Ext(firstNonEmpty(name, ref)))
- switch ext {
- case ".vtt":
- _, err = io.WriteString(w, body)
- case ".srt":
- _, err = io.WriteString(w, srtToVTT(body))
- case ".ass", ".ssa":
- _, err = io.WriteString(w, assToVTT(body))
- default:
- return errors.New("unsupported subtitle format")
- }
- return err
-}
-
-// serveCloudSubtitleRaw streams a cloud subtitle in its original format
-// without WebVTT conversion, for Emby clients that parse by the advertised
-// Codec. Shares the same access-control (discovery whitelist) checks as
-// serveCloudSubtitle.
-func serveCloudSubtitleRaw(ctx context.Context, s *SubtitleService, m model.Media, typ, ref, name string, w io.Writer) error {
- if s == nil || s.storage == nil {
- return errors.New("cloud storage service unavailable")
- }
- mediaTyp, _, ok := cloudSubtitleMediaRef(m)
- if !ok || mediaTyp != typ {
- return ErrCloudPlaybackUnavailable
- }
- allowed := false
- for _, track := range discoverCloudSubtitles(ctx, s, m) {
- if track.Path == buildCloudSubtitlePath(typ, ref, name) {
- allowed = true
- break
- }
- }
- if !allowed {
- return fmt.Errorf("path escape")
- }
- body, err := s.storage.CloudReadText(ctx, typ, ref, 8<<20)
- if err != nil {
- return err
- }
- ext := strings.ToLower(filepath.Ext(firstNonEmpty(name, ref)))
- switch ext {
- case ".vtt", ".srt", ".ass", ".ssa":
- _, err = io.WriteString(w, body)
- default:
- return errors.New("unsupported subtitle format")
- }
- return err
-}
diff --git a/internal/service/telegram_admin_codes.go b/internal/service/telegram_admin_codes.go
deleted file mode 100644
index 9a86057..0000000
--- a/internal/service/telegram_admin_codes.go
+++ /dev/null
@@ -1,133 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "strconv"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *TelegramBotService) replyCapacity(ctx context.Context) telegramCommandReply {
- c := s.loadCapacity(ctx)
- quota := "未开放"
- if c.OpenRegOn {
- if c.OpenRegLimit > 0 {
- quota = fmt.Sprintf("已开放(%d/%d 名额)", c.OpenRegUsed, c.OpenRegLimit)
- } else {
- quota = "已开放(不限名额,受授权上限约束)"
- }
- }
- text := fmt.Sprintf("容量 / 状态\n\n授权上限:%d 人(随凭证授权实时变化)\n已用:%d 人\n剩余可注册:%d 人\n开注状态:%s",
- c.MaxUsers, c.UsedUsers, c.Remaining(), quota)
- return telegramCommandReply{Text: text, Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}}
-}
-
-func (s *TelegramBotService) replyOpenRegMenu(ctx context.Context) telegramCommandReply {
- c := s.loadCapacity(ctx)
- state := "未开放"
- if c.OpenRegOn {
- state = fmt.Sprintf("已开放(%d/%d)", c.OpenRegUsed, c.OpenRegLimit)
- }
- return telegramCommandReply{
- Text: "开注设置\n当前:" + state + "\n选择要开放的名额:",
- Buttons: [][]telegramInlineButton{
- {{Text: "5 个", Data: "adm_openreg_set:5"}, {Text: "10 个", Data: "adm_openreg_set:10"}, {Text: "20 个", Data: "adm_openreg_set:20"}},
- {{Text: "不限名额", Data: "adm_openreg_set:0"}, {Text: "关闭注册", Data: "adm_openreg_close"}},
- {{Text: "⬅️ 返回菜单", Data: "menu_main"}},
- },
- }
-}
-
-func (s *TelegramBotService) replyGenCodeMenu() telegramCommandReply {
- return telegramCommandReply{
- Text: "生成兑换码\n选择类型与时长:",
- Buttons: [][]telegramInlineButton{
- {{Text: "注册码·30天", Data: "gc:register:30"}, {Text: "注册码·永久", Data: "gc:register:0"}},
- {{Text: "续期码·30天", Data: "gc:renew:30"}, {Text: "续期码·90天", Data: "gc:renew:90"}},
- {{Text: "⬅️ 返回菜单", Data: "menu_main"}},
- },
- }
-}
-
-func (s *TelegramBotService) replyGenCode(ctx context.Context, msg *TelegramMessage, data string) telegramCommandReply {
- parts := strings.Split(data, ":") // gc::
- if len(parts) != 3 {
- return telegramCommandReply{Text: "参数错误。"}
- }
- kind := parts[1]
- days, _ := strconv.Atoi(parts[2])
- createdBy := ""
- if u := s.boundUser(ctx, msg.From.ID); u != nil {
- createdBy = u.ID
- }
- code, err := s.generateCode(ctx, kind, days, 0, createdBy)
- if err != nil {
- return telegramCommandReply{Text: "生成失败:" + err.Error()}
- }
- kindLabel := map[string]string{model.RegistrationCodeRegister: "注册码", model.RegistrationCodeRenew: "续期码"}[code.Kind]
- dur := "永久"
- if days > 0 {
- dur = fmt.Sprintf("%d 天", days)
- }
- return telegramCommandReply{
- Text: fmt.Sprintf("已生成%s(%s):\n\n%s\n\n发给用户在 Bot 中兑换即可。", kindLabel, dur, code.Code),
- Buttons: [][]telegramInlineButton{{{Text: "再生成一个", Data: "adm_gencode"}, {Text: "⬅️ 返回菜单", Data: "menu_main"}}},
- }
-}
-
-func (s *TelegramBotService) cmdGenCode(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
- if len(args) < 2 {
- return telegramCommandReply{Text: "用法:/gencode register|renew 天数 [有效天数] [可用次数]\n示例:/gencode register 30、/gencode renew 90 7 5"}
- }
- kind := strings.ToLower(strings.TrimSpace(args[0]))
- switch kind {
- case "reg", "register", "注册码":
- kind = model.RegistrationCodeRegister
- case "renew", "续期", "续期码":
- kind = model.RegistrationCodeRenew
- default:
- return telegramCommandReply{Text: "类型无效,只支持 register / renew。"}
- }
- days, err := strconv.Atoi(args[1])
- if err != nil || days < 0 {
- return telegramCommandReply{Text: "天数必须是非负整数,0 表示永久。"}
- }
- validDays := 0
- if len(args) > 2 {
- validDays, err = strconv.Atoi(args[2])
- if err != nil || validDays < 0 {
- return telegramCommandReply{Text: "有效天数必须是非负整数。"}
- }
- }
- maxUses := 1
- if len(args) > 3 {
- maxUses, err = strconv.Atoi(args[3])
- if err != nil || maxUses <= 0 {
- return telegramCommandReply{Text: "可用次数必须是正整数。"}
- }
- }
- createdBy := ""
- if u := s.boundUser(ctx, msg.From.ID); u != nil {
- createdBy = u.ID
- }
- code, err := s.generateCodeWithUses(ctx, kind, days, validDays, maxUses, createdBy)
- if err != nil {
- return telegramCommandReply{Text: "生成失败:" + err.Error()}
- }
- kindLabel := map[string]string{model.RegistrationCodeRegister: "注册码", model.RegistrationCodeRenew: "续期码"}[code.Kind]
- dur := "永久"
- if days > 0 {
- dur = fmt.Sprintf("%d 天", days)
- }
- valid := "长期有效"
- if validDays > 0 && code.ExpiresAt != nil {
- valid = "有效至 " + code.ExpiresAt.Format("2006-01-02 15:04")
- }
- uses := "单次使用"
- if code.EffectiveMaxUses() > 1 {
- uses = fmt.Sprintf("最多 %d 次", code.EffectiveMaxUses())
- }
- return telegramCommandReply{Text: fmt.Sprintf("已生成%s(%s,%s,%s):\n\n%s", kindLabel, dur, valid, uses, code.Code)}
-}
diff --git a/internal/service/telegram_admin_users.go b/internal/service/telegram_admin_users.go
deleted file mode 100644
index 2653af2..0000000
--- a/internal/service/telegram_admin_users.go
+++ /dev/null
@@ -1,186 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "strconv"
- "strings"
-)
-
-func (s *TelegramBotService) replyUserList(ctx context.Context) telegramCommandReply {
- users, err := s.repo.User.List(ctx)
- if err != nil {
- return telegramCommandReply{Text: "读取用户失败:" + err.Error()}
- }
- if len(users) == 0 {
- return telegramCommandReply{Text: "暂无用户。"}
- }
- var rows [][]telegramInlineButton
- limit := len(users)
- if limit > 12 {
- limit = 12
- }
- for i := 0; i < limit; i++ {
- u := users[i]
- flag := ""
- if !u.IsActive {
- flag = "🚫"
- }
- if u.Role == "admin" {
- flag = "👑"
- }
- rows = append(rows, []telegramInlineButton{{Text: flag + " " + u.Username, Data: "usr:" + u.ID}})
- }
- rows = append(rows, []telegramInlineButton{{Text: "⬅️ 返回菜单", Data: "menu_main"}})
- return telegramCommandReply{Text: fmt.Sprintf("用户管理(共 %d 人,显示前 %d)\n点击用户进行操作:", len(users), limit), Buttons: rows}
-}
-
-func (s *TelegramBotService) replyUserActions(ctx context.Context, userID string) telegramCommandReply {
- u, err := s.repo.User.FindByID(ctx, userID)
- if err != nil || u == nil {
- return telegramCommandReply{Text: "用户不存在。"}
- }
- protected := UserIsProtectedAccount(ctx, s.repo, u)
- text := fmt.Sprintf("%s\n角色:%s\n状态:%s\n到期:%s\n防共享警告:%d 次",
- u.Username, u.Role, map[bool]string{true: "正常", false: "已禁用"}[u.IsActive], formatExpiry(u.ExpiredAt), u.ShareWarnings)
- if protected {
- return telegramCommandReply{Text: text + "\n\n(受保护账号,不可禁用/删除)", Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回", Data: "adm_users"}}}}
- }
- banBtn := telegramInlineButton{Text: "🚫 禁用", Data: "uban:" + u.ID}
- if !u.IsActive {
- banBtn = telegramInlineButton{Text: "✅ 解禁", Data: "uunban:" + u.ID}
- }
- return telegramCommandReply{
- Text: text,
- Buttons: [][]telegramInlineButton{
- {banBtn, {Text: "⏳ 续期30天", Data: "urenew:" + u.ID + ":30"}},
- {{Text: "🗑 删除用户", Data: "udel:" + u.ID}},
- {{Text: "⬅️ 返回", Data: "adm_users"}},
- },
- }
-}
-
-func (s *TelegramBotService) replyUserBan(ctx context.Context, userID string, unban bool) telegramCommandReply {
- if !unban {
- if reason := s.protectReason(ctx, userID); reason != "" {
- return telegramCommandReply{Text: reason}
- }
- }
- updates := map[string]any{"is_active": unban}
- if unban {
- updates["share_warnings"] = 0
- updates["last_share_warn_at"] = nil
- }
- if err := s.repo.User.UpdateFields(ctx, userID, updates); err != nil {
- return telegramCommandReply{Text: "操作失败:" + err.Error()}
- }
- if unban {
- _ = s.repo.UserDevice.SetKickedByUser(ctx, userID, false)
- }
- return s.replyUserActions(ctx, userID)
-}
-
-func (s *TelegramBotService) replyUserDelete(ctx context.Context, userID string) telegramCommandReply {
- if reason := s.protectReason(ctx, userID); reason != "" {
- return telegramCommandReply{Text: reason}
- }
- u, _ := s.repo.User.FindByID(ctx, userID)
- _ = s.repo.UserDevice.DeleteByUser(ctx, userID)
- if err := s.repo.User.Delete(ctx, userID); err != nil {
- return telegramCommandReply{Text: "删除失败:" + err.Error()}
- }
- name := userID
- if u != nil {
- name = u.Username
- }
- return telegramCommandReply{Text: fmt.Sprintf("已删除用户 %s。", name), Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回", Data: "adm_users"}}}}
-}
-
-func (s *TelegramBotService) replyUserRenew(ctx context.Context, payload string) telegramCommandReply {
- parts := strings.Split(payload, ":") // :
- if len(parts) != 2 {
- return telegramCommandReply{Text: "参数错误。"}
- }
- days, _ := strconv.Atoi(parts[1])
- if err := s.applyRenewal(ctx, parts[0], days); err != nil {
- return telegramCommandReply{Text: "续期失败:" + err.Error()}
- }
- return s.replyUserActions(ctx, parts[0])
-}
-
-func (s *TelegramBotService) cmdUserRenew(ctx context.Context, args []string) telegramCommandReply {
- if len(args) < 2 {
- return telegramCommandReply{Text: "用法:/renew_user 用户名 天数,天数 0 表示永久。"}
- }
- user, _ := s.repo.User.FindByUsername(ctx, args[0])
- if user == nil {
- user, _ = s.repo.User.FindByID(ctx, args[0])
- }
- if user == nil {
- return telegramCommandReply{Text: "未找到用户。"}
- }
- days, err := strconv.Atoi(args[1])
- if err != nil || days < 0 {
- return telegramCommandReply{Text: "天数必须是非负整数。"}
- }
- if err := s.applyRenewal(ctx, user.ID, days); err != nil {
- return telegramCommandReply{Text: "续期失败:" + err.Error()}
- }
- return s.replyUserActions(ctx, user.ID)
-}
-
-func (s *TelegramBotService) cmdUserDelete(ctx context.Context, args []string) telegramCommandReply {
- if len(args) == 0 {
- return telegramCommandReply{Text: "用法:/delete_user 用户名 confirm\n为避免误删,最后一个参数必须是 confirm。"}
- }
- if len(args) < 2 || !strings.EqualFold(args[len(args)-1], "confirm") {
- return telegramCommandReply{Text: "删除用户需要确认:/delete_user 用户名 confirm"}
- }
- user, _ := s.repo.User.FindByUsername(ctx, args[0])
- if user == nil {
- user, _ = s.repo.User.FindByID(ctx, args[0])
- }
- if user == nil {
- return telegramCommandReply{Text: "未找到用户。"}
- }
- return s.replyUserDelete(ctx, user.ID)
-}
-
-// protectReason returns a non-empty message when a user must not be
-// disabled/deleted (admins, default admin and protected-list users).
-func (s *TelegramBotService) protectReason(ctx context.Context, userID string) string {
- u, err := s.repo.User.FindByID(ctx, userID)
- if err != nil || u == nil {
- return "用户不存在。"
- }
- if u.Role == "admin" {
- return "管理员账号受保护,不可禁用/删除。"
- }
- if first, _ := s.repo.User.FirstAdmin(ctx); first != nil && first.ID == u.ID {
- return "默认管理员账号受保护,不可禁用/删除。"
- }
- if _, ok := ProtectedUserIDSet(ctx, s.repo)[u.ID]; ok {
- return "该账号在 Bot 保护名单中,不可禁用/删除。"
- }
- if s.device != nil && s.device.UserRecentlyActive(ctx, u.ID, realtimeSessionTTL) {
- return "该账号最近仍有实时活跃会话,为避免误删/误禁用,请先确认用户已下线。"
- }
- return ""
-}
-
-func (s *TelegramBotService) cmdUserBan(ctx context.Context, args []string, unban bool) telegramCommandReply {
- if len(args) == 0 {
- if unban {
- return telegramCommandReply{Text: "用法:/unban 用户名"}
- }
- return telegramCommandReply{Text: "用法:/ban 用户名"}
- }
- user, _ := s.repo.User.FindByUsername(ctx, args[0])
- if user == nil {
- user, _ = s.repo.User.FindByID(ctx, args[0])
- }
- if user == nil {
- return telegramCommandReply{Text: "未找到用户。"}
- }
- return s.replyUserBan(ctx, user.ID, unban)
-}
diff --git a/internal/service/telegram_api.go b/internal/service/telegram_api.go
deleted file mode 100644
index 6ea8383..0000000
--- a/internal/service/telegram_api.go
+++ /dev/null
@@ -1,232 +0,0 @@
-package service
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "mime/multipart"
- "net/http"
- "net/url"
- "strings"
- "time"
-)
-
-func telegramPostForm(ctx context.Context, cfg map[string]string, method string, form url.Values, timeout time.Duration) error {
- apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method)
- if err != nil {
- return err
- }
- return telegramDoWithFallback(ctx, cfg, http.MethodPost, apiURL, form.Encode(), "application/x-www-form-urlencoded", timeout)
-}
-
-func telegramPostJSON(ctx context.Context, cfg map[string]string, method string, payload any, timeout time.Duration) error {
- apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method)
- if err != nil {
- return err
- }
- body, err := json.Marshal(payload)
- if err != nil {
- return err
- }
- return telegramDoWithFallback(ctx, cfg, http.MethodPost, apiURL, string(body), "application/json", timeout)
-}
-
-func telegramPostMultipart(ctx context.Context, cfg map[string]string, method string, fields map[string]string, fileField, fileName string, file []byte, timeout time.Duration) error {
- apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method)
- if err != nil {
- return err
- }
- var body bytes.Buffer
- writer := multipart.NewWriter(&body)
- for key, value := range fields {
- if strings.TrimSpace(value) == "" {
- continue
- }
- if err := writer.WriteField(key, value); err != nil {
- _ = writer.Close()
- return err
- }
- }
- part, err := writer.CreateFormFile(fileField, fileName)
- if err != nil {
- _ = writer.Close()
- return err
- }
- if _, err := part.Write(file); err != nil {
- _ = writer.Close()
- return err
- }
- if err := writer.Close(); err != nil {
- return err
- }
- return telegramDoWithFallback(ctx, cfg, http.MethodPost, apiURL, body.String(), writer.FormDataContentType(), timeout)
-}
-
-func telegramFetchRemotePhoto(ctx context.Context, cfg map[string]string, rawURL string, timeout time.Duration) ([]byte, string, error) {
- rawURL = strings.TrimSpace(rawURL)
- if rawURL == "" {
- return nil, "", errors.New("telegram photo url required")
- }
- var lastErr error
- for _, client := range telegramHTTPClients(timeout, cfg) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
- if err != nil {
- return nil, "", err
- }
- req.Header.Set("User-Agent", "MediaStationGo/1.0")
- resp, err := client.Do(req)
- if err != nil {
- lastErr = sanitizeTelegramError(err)
- continue
- }
- body, readErr := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024+1))
- _ = resp.Body.Close()
- if readErr != nil {
- lastErr = readErr
- continue
- }
- if resp.StatusCode >= 400 {
- lastErr = fmt.Errorf("photo fetch error %d", resp.StatusCode)
- continue
- }
- if len(body) == 0 {
- lastErr = errors.New("photo fetch returned empty body")
- continue
- }
- if len(body) > 10*1024*1024 {
- lastErr = errors.New("photo too large")
- continue
- }
- contentType := strings.TrimSpace(resp.Header.Get("Content-Type"))
- return body, contentType, nil
- }
- if lastErr != nil {
- return nil, "", lastErr
- }
- return nil, "", errors.New("photo fetch failed")
-}
-
-func deleteTelegramWebhook(ctx context.Context, cfg map[string]string) error {
- payload := map[string]any{
- "drop_pending_updates": false,
- }
- return telegramPostJSON(ctx, cfg, "deleteWebhook", payload, 15*time.Second)
-}
-
-func telegramDo(client *http.Client, req *http.Request) error {
- resp, err := client.Do(req)
- if err != nil {
- return sanitizeTelegramError(err)
- }
- defer resp.Body.Close()
- if resp.StatusCode >= 400 {
- body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
- return fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(body)))
- }
- return nil
-}
-
-func telegramDoWithFallback(ctx context.Context, cfg map[string]string, method, apiURL, body, contentType string, timeout time.Duration) error {
- var lastErr error
- for _, client := range telegramHTTPClients(timeout, cfg) {
- req, err := http.NewRequestWithContext(ctx, method, apiURL, strings.NewReader(body))
- if err != nil {
- return err
- }
- if contentType != "" {
- req.Header.Set("Content-Type", contentType)
- }
- if err := telegramDo(client, req); err != nil {
- lastErr = err
- continue
- }
- return nil
- }
- if lastErr != nil {
- return lastErr
- }
- return errors.New("telegram request failed")
-}
-
-func telegramPostJSONDecode(ctx context.Context, cfg map[string]string, method string, payload any, timeout time.Duration, out any) error {
- apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method)
- if err != nil {
- return err
- }
- body, err := json.Marshal(payload)
- if err != nil {
- return err
- }
- var lastErr error
- for _, client := range telegramHTTPClients(timeout, cfg) {
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(string(body)))
- if err != nil {
- return err
- }
- req.Header.Set("Content-Type", "application/json")
- resp, err := client.Do(req)
- if err != nil {
- lastErr = sanitizeTelegramError(err)
- continue
- }
- respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
- _ = resp.Body.Close()
- if resp.StatusCode >= 400 {
- lastErr = fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(respBody)))
- continue
- }
- if out != nil {
- return json.Unmarshal(respBody, out)
- }
- return nil
- }
- if lastErr != nil {
- return lastErr
- }
- return errors.New("telegram request failed")
-}
-
-func telegramGetJSONDecode(ctx context.Context, cfg map[string]string, method string, timeout time.Duration, out any) error {
- apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method)
- if err != nil {
- return err
- }
- var lastErr error
- for _, client := range telegramHTTPClients(timeout, cfg) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
- if err != nil {
- return err
- }
- resp, err := client.Do(req)
- if err != nil {
- lastErr = sanitizeTelegramError(err)
- continue
- }
- respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
- _ = resp.Body.Close()
- if resp.StatusCode >= 400 {
- lastErr = fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(respBody)))
- continue
- }
- if out != nil {
- return json.Unmarshal(respBody, out)
- }
- return nil
- }
- if lastErr != nil {
- return lastErr
- }
- return errors.New("telegram request failed")
-}
-
-func telegramStringConfigFromAny(cfg map[string]any) map[string]string {
- out := make(map[string]string, len(cfg))
- for key, value := range cfg {
- out[key] = str(value)
- }
- normalizeTelegramConfig(out)
- return out
-}
diff --git a/internal/service/telegram_api_client.go b/internal/service/telegram_api_client.go
deleted file mode 100644
index 3a88709..0000000
--- a/internal/service/telegram_api_client.go
+++ /dev/null
@@ -1,124 +0,0 @@
-package service
-
-import (
- "errors"
- "fmt"
- "net/http"
- "net/url"
- "os"
- "regexp"
- "strings"
- "time"
-)
-
-const defaultTelegramAPIBaseURL = "https://api.telegram.org"
-
-var telegramTokenPattern = regexp.MustCompile(`bot[0-9]+:[^/\s"'?]+`)
-
-func telegramAPIBaseURL(cfg map[string]string) string {
- base := strings.TrimSpace(cfg["api_base_url"])
- if base == "" {
- base = strings.TrimSpace(os.Getenv("MEDIASTATION_TELEGRAM_API_BASE_URL"))
- }
- if base == "" {
- base = defaultTelegramAPIBaseURL
- }
- return strings.TrimRight(base, "/")
-}
-
-func telegramMethodURL(cfg map[string]string, botToken, method string) (string, error) {
- botToken = strings.TrimSpace(botToken)
- method = strings.TrimSpace(method)
- if botToken == "" {
- return "", errors.New("telegram bot_token required")
- }
- if method == "" {
- return "", errors.New("telegram method required")
- }
- base := telegramAPIBaseURL(cfg)
- if _, err := url.ParseRequestURI(base); err != nil {
- return "", fmt.Errorf("telegram api_base_url invalid")
- }
- return fmt.Sprintf("%s/bot%s/%s", base, botToken, method), nil
-}
-
-func telegramHTTPClient(timeout time.Duration, cfg map[string]string) *http.Client {
- clients := telegramHTTPClients(timeout, cfg)
- return clients[0]
-}
-
-func telegramHTTPClients(timeout time.Duration, cfg map[string]string) []*http.Client {
- clients := []*http.Client{}
- seen := map[string]bool{}
- customAPIBase := telegramUsesCustomAPIBase(cfg)
- for _, proxyRaw := range telegramProxyCandidates(cfg) {
- proxyURL, err := normalizeProxyURL(proxyRaw, "http")
- if err != nil || proxyURL == nil {
- continue
- }
- key := proxyURL.String()
- if seen[key] {
- continue
- }
- seen[key] = true
- transport := NewExternalTransport()
- transport.Proxy = http.ProxyURL(proxyURL)
- clients = append(clients, &http.Client{Timeout: timeout, Transport: transport})
- }
- transport := NewExternalTransport()
- if customAPIBase {
- transport = NewInternalTransport()
- }
- clients = append(clients, &http.Client{Timeout: timeout, Transport: transport})
- return clients
-}
-
-func telegramProxyCandidates(cfg map[string]string) []string {
- out := []string{}
- for _, value := range []string{
- cfg["proxy_url"],
- os.Getenv("MEDIASTATION_TELEGRAM_PROXY_URL"),
- } {
- if strings.TrimSpace(value) != "" {
- out = append(out, value)
- }
- }
- if len(out) > 0 {
- return out
- }
- if telegramUsesCustomAPIBase(cfg) {
- return out
- }
- for _, value := range []string{
- "http://127.0.0.1:10808",
- "http://127.0.0.1:10809",
- "http://127.0.0.1:7890",
- "http://127.0.0.1:7891",
- "http://host.docker.internal:7890",
- "http://host.docker.internal:10808",
- "http://172.17.0.1:7890",
- "http://172.17.0.1:10808",
- } {
- out = append(out, value)
- }
- return out
-}
-
-func telegramUsesCustomAPIBase(cfg map[string]string) bool {
- return telegramAPIBaseURL(cfg) != defaultTelegramAPIBaseURL
-}
-
-func sanitizeTelegramError(err error) error {
- if err == nil {
- return nil
- }
- msg := sanitizeTelegramText(err.Error())
- if strings.Contains(msg, "Client.Timeout exceeded") || strings.Contains(msg, "context deadline exceeded") {
- return errors.New("telegram request timeout: 请检查 NAS/Docker 到 Telegram API 的代理、反代或网络连通性")
- }
- return errors.New(msg)
-}
-
-func sanitizeTelegramText(text string) string {
- return telegramTokenPattern.ReplaceAllString(text, "bot")
-}
diff --git a/internal/service/telegram_api_commands_test.go b/internal/service/telegram_api_commands_test.go
deleted file mode 100644
index 302806a..0000000
--- a/internal/service/telegram_api_commands_test.go
+++ /dev/null
@@ -1,192 +0,0 @@
-package service
-
-import (
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func TestRegisterTelegramBotCommands(t *testing.T) {
- var gotPath string
- var payloads []struct {
- Commands []telegramBotCommand `json:"commands"`
- Scope map[string]any `json:"scope"`
- }
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotPath = r.URL.Path
- var payload struct {
- Commands []telegramBotCommand `json:"commands"`
- Scope map[string]any `json:"scope"`
- }
- if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
- t.Fatalf("decode payload: %v", err)
- }
- payloads = append(payloads, payload)
- _, _ = w.Write([]byte(`{"ok":true}`))
- }))
- defer server.Close()
-
- err := registerTelegramBotCommands(t.Context(), map[string]string{
- "bot_token": "123456:ABC",
- "api_base_url": server.URL,
- })
- if err != nil {
- t.Fatal(err)
- }
- if gotPath != "/bot123456:ABC/setMyCommands" {
- t.Fatalf("path = %q", gotPath)
- }
- if len(payloads) < 3 {
- t.Fatalf("expected default/private/group command registrations, got %d", len(payloads))
- }
- if len(payloads[0].Commands) == 0 || payloads[0].Commands[0].Command != "start" {
- t.Fatalf("commands not registered: %#v", payloads[0].Commands)
- }
- var groupCommands []telegramBotCommand
- for _, payload := range payloads {
- if payload.Scope["type"] == "all_group_chats" {
- groupCommands = payload.Commands
- break
- }
- }
- if len(groupCommands) == 0 {
- t.Fatal("group command scope was not registered")
- }
- for _, command := range groupCommands {
- if command.Command == "users" || command.Command == "status" || command.Command == "cleanup" || command.Command == "register" || command.Command == "redeem" {
- t.Fatalf("group commands must not expose private/admin command %q", command.Command)
- }
- }
-}
-
-func TestDeleteTelegramWebhookBeforePolling(t *testing.T) {
- var gotPath string
- var payload map[string]any
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotPath = r.URL.Path
- if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
- t.Fatalf("decode payload: %v", err)
- }
- _, _ = w.Write([]byte(`{"ok":true}`))
- }))
- defer server.Close()
-
- err := deleteTelegramWebhook(t.Context(), map[string]string{
- "bot_token": "123456:ABC",
- "api_base_url": server.URL,
- })
- if err != nil {
- t.Fatal(err)
- }
- if gotPath != "/bot123456:ABC/deleteWebhook" {
- t.Fatalf("path = %q", gotPath)
- }
- if got := payload["drop_pending_updates"]; got != false {
- t.Fatalf("drop_pending_updates = %#v, want false", got)
- }
-}
-
-func TestTelegramCommandMenusSeparateGroupAndAdminCommands(t *testing.T) {
- privateNames := telegramCommandNames(telegramPrivateBotCommandMenu())
- for _, required := range []string{"setname", "setpass"} {
- if !privateNames[required] {
- t.Fatalf("private menu should include %s", required)
- }
- }
- for _, hiddenAlias := range []string{"myinfo", "count"} {
- if privateNames[hiddenAlias] {
- t.Fatalf("private menu should hide compatibility alias %s", hiddenAlias)
- }
- if !telegramSupportedCommand("/" + hiddenAlias) {
- t.Fatalf("compatibility alias /%s should remain executable", hiddenAlias)
- }
- }
-
- groupNames := telegramCommandNames(telegramGroupBotCommandMenu())
- for _, forbidden := range []string{"status", "search", "downloads", "stats", "users", "cleanup", "cleanup_rule", "register", "redeem"} {
- if groupNames[forbidden] {
- t.Fatalf("group menu should not expose %s", forbidden)
- }
- }
- for _, required := range []string{"start", "menu", "help", "account", "signin", "devices", "kick", "hideadult"} {
- if !groupNames[required] {
- t.Fatalf("group menu should include %s", required)
- }
- }
- adminCommands := telegramAdminBotCommandMenu()
- adminNames := telegramCommandNames(adminCommands)
- for _, required := range []string{"users", "status", "cleanup_mode", "cleanup_rule", "ucr", "uinfo", "rmemby", "only_rm_record", "renewall", "userip", "auditip", "auditdevice", "auditclient", "udeviceid", "syncunbound", "syncgroupm", "check_ex", "deleted", "embyadmin", "banall", "unbanall", "prouser", "revuser", "embylibs_blockall", "embylibs_unblockall", "proadmin", "revadmin", "backup_db", "restore_from_db"} {
- if !adminNames[required] {
- t.Fatalf("admin menu should include %s", required)
- }
- }
- for _, hiddenAlias := range []string{"myinfo", "count", "low_activity", "urm", "only_rm_emby", "extraembylibs_blockall", "extraembylibs_unblockall"} {
- if adminNames[hiddenAlias] {
- t.Fatalf("admin menu should hide compatibility alias %s", hiddenAlias)
- }
- }
- for _, command := range adminCommands {
- if strings.Contains(command.Description, "Mgo 兼容") {
- t.Fatalf("admin menu command %s should use native Mgo wording: %q", command.Command, command.Description)
- }
- }
- help := telegramMgoAdminCommandHelp()
- for _, want := range []string{"用户:", "审计:", "清理:", "权限:", "运维:"} {
- if !strings.Contains(help, want) {
- t.Fatalf("mgo admin help should include category %q in %q", want, help)
- }
- }
- if strings.Contains(help, "/setpass") {
- t.Fatalf("mgo admin help should not include user self-service command /setpass")
- }
-}
-
-func telegramCommandNames(commands []telegramBotCommand) map[string]bool {
- names := make(map[string]bool, len(commands))
- for _, command := range commands {
- names[command.Command] = true
- }
- return names
-}
-
-func TestTelegramCommandFiltering(t *testing.T) {
- if telegramIsCommandText("今天看什么") {
- t.Fatal("plain chat message should not be treated as command")
- }
- if !telegramIsCommandText("/start user pass") {
- t.Fatal("/start should be treated as command")
- }
- if got := telegramCommandName("/hideadult@MediaStationGoBot on"); got != "/hideadult" {
- t.Fatalf("telegramCommandName = %q, want /hideadult", got)
- }
- if telegramSupportedCommand("/签到") {
- t.Fatal("unrelated group bot command should not be handled")
- }
- for _, cmd := range []string{"/signin", "/redeem", "/gencode", "/users", "/renew_user", "/delete_user", "/cleanup_rule"} {
- if !telegramSupportedCommand(cmd) {
- t.Fatalf("%s should be supported so group slash commands get feedback", cmd)
- }
- }
- for _, cmd := range []string{"/restart", "/update_bot", "/coins", "/red", "/white_channel", "/config"} {
- if telegramSupportedCommand(cmd) {
- t.Fatalf("%s should not be treated as supported until it has a real Mgo implementation", cmd)
- }
- }
-}
-
-func TestTelegramSupportedCommandSetMatchesRegistry(t *testing.T) {
- _, bot := newBotTestService(t)
- channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`}
- msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "admin"}, Chat: TelegramChat{ID: 9001, Type: "private"}}
- for _, def := range bot.telegramCommandDefinitions(t.Context(), channel, msg) {
- for _, alias := range def.Aliases {
- if !telegramSupportedCommand(alias) {
- t.Fatalf("registered command %s must be in telegramSupportedCommandSet", alias)
- }
- }
- }
-}
diff --git a/internal/service/telegram_api_proxy_test.go b/internal/service/telegram_api_proxy_test.go
deleted file mode 100644
index f7ac75c..0000000
--- a/internal/service/telegram_api_proxy_test.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package service
-
-import (
- "net/http"
- "strings"
- "testing"
- "time"
-)
-
-func TestTelegramProxyCandidatesDefaultLocalFallbacks(t *testing.T) {
- got := telegramProxyCandidates(map[string]string{})
- joined := strings.Join(got, ",")
- for _, want := range []string{"127.0.0.1:10808", "127.0.0.1:7890", "host.docker.internal:7890", "172.17.0.1:7890"} {
- if !strings.Contains(joined, want) {
- t.Fatalf("default proxy candidates %q missing %q", joined, want)
- }
- }
-}
-
-func TestTelegramHTTPClientsCustomAPIBaseSkipsDefaultProxyFallback(t *testing.T) {
- clients := telegramHTTPClients(time.Second, map[string]string{
- "api_base_url": "http://127.0.0.1:18080",
- })
- if len(clients) != 1 {
- t.Fatalf("clients = %d, want direct client only", len(clients))
- }
- if got := telegramClientProxyString(t, clients[0]); got != "" {
- t.Fatalf("custom api_base_url proxy = %q, want direct", got)
- }
-}
-
-func TestTelegramHTTPClientsPreferConfiguredProxy(t *testing.T) {
- clients := telegramHTTPClients(time.Second, map[string]string{
- "proxy_url": "http://proxy.example:7890",
- })
- if len(clients) == 0 {
- t.Fatal("expected telegram clients")
- }
- if got := telegramClientProxyString(t, clients[0]); got != "http://proxy.example:7890" {
- t.Fatalf("first client proxy = %q, want configured proxy", got)
- }
-}
-
-func telegramClientProxyString(t *testing.T, client *http.Client) string {
- t.Helper()
- transport, ok := client.Transport.(*http.Transport)
- if !ok || transport.Proxy == nil {
- return ""
- }
- req, err := http.NewRequest(http.MethodGet, defaultTelegramAPIBaseURL, nil)
- if err != nil {
- t.Fatal(err)
- }
- proxyURL, err := transport.Proxy(req)
- if err != nil {
- t.Fatal(err)
- }
- if proxyURL == nil {
- return ""
- }
- return proxyURL.String()
-}
diff --git a/internal/service/telegram_api_test.go b/internal/service/telegram_api_test.go
deleted file mode 100644
index b6c944b..0000000
--- a/internal/service/telegram_api_test.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package service
-
-import (
- "errors"
- "strings"
- "testing"
-)
-
-func TestTelegramMethodURLUsesCustomAPIBase(t *testing.T) {
- got, err := telegramMethodURL(map[string]string{
- "api_base_url": "https://tg.example.com/",
- }, "123456:ABC-def", "sendMessage")
- if err != nil {
- t.Fatalf("telegramMethodURL returned error: %v", err)
- }
- want := "https://tg.example.com/bot123456:ABC-def/sendMessage"
- if got != want {
- t.Fatalf("got %q, want %q", got, want)
- }
-}
-
-func TestSanitizeTelegramErrorRedactsBotToken(t *testing.T) {
- err := sanitizeTelegramError(errors.New(`Post "https://api.telegram.org/bot123456:SECRET/sendMessage": context deadline exceeded`))
- if err == nil {
- t.Fatal("expected error")
- }
- msg := err.Error()
- if strings.Contains(msg, "SECRET") || strings.Contains(msg, "123456:") {
- t.Fatalf("telegram token leaked in error: %s", msg)
- }
- if !strings.Contains(msg, "timeout") {
- t.Fatalf("expected timeout hint, got: %s", msg)
- }
-}
-
-func TestValidateTelegramChannelDoesNotRequireLegacyChatID(t *testing.T) {
- err := validateChannel(ChannelInput{
- Name: "Telegram",
- Type: "telegram",
- Config: map[string]any{
- "bot_token": "123456:ABC-def",
- "admin_user_ids": "10001",
- },
- })
- if err != nil {
- t.Fatalf("validateChannel returned error: %v", err)
- }
-}
-
-func TestTelegramTargetChatIDsFallsBackToAdmins(t *testing.T) {
- got := telegramTargetChatIDs(map[string]string{
- "admin_user_ids": "10001, 10002",
- })
- if len(got) != 2 || got[0] != "10001" || got[1] != "10002" {
- t.Fatalf("got %#v, want admin user ids", got)
- }
-}
-
-func TestNormalizeTelegramChannelMigratesLegacyChatID(t *testing.T) {
- input := ChannelInput{
- Name: "Telegram",
- Type: "telegram",
- Config: map[string]any{
- "chat_id": "-10001",
- },
- }
- normalizeChannelInput(&input)
- if got := str(input.Config["group_chat_id"]); got != "-10001" {
- t.Fatalf("group_chat_id = %q, want -10001", got)
- }
-}
-
-func TestNormalizeTelegramChannelMigratesLegacyPrivateChatIDToAdmin(t *testing.T) {
- cfg := map[string]string{"chat_id": "5812333517"}
- normalizeTelegramConfig(cfg)
- if got := cfg["admin_user_ids"]; got != "5812333517" {
- t.Fatalf("admin_user_ids = %q, want legacy chat_id", got)
- }
-}
-
-func TestTelegramTargetChatIDsUsesLegacyPrivateChatID(t *testing.T) {
- got := telegramTargetChatIDs(map[string]string{
- "chat_id": "5812333517",
- })
- if len(got) != 1 || got[0] != "5812333517" {
- t.Fatalf("got %#v, want legacy private chat target", got)
- }
-}
diff --git a/internal/service/telegram_api_webhook_test.go b/internal/service/telegram_api_webhook_test.go
deleted file mode 100644
index 81ca0a9..0000000
--- a/internal/service/telegram_api_webhook_test.go
+++ /dev/null
@@ -1,197 +0,0 @@
-package service
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "go.uber.org/zap"
-)
-
-func TestTelegramReplyAutoDeletesSentMessage(t *testing.T) {
- requests := make(chan string, 4)
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch {
- case strings.HasSuffix(r.URL.Path, "/sendMessage"):
- requests <- "sendMessage"
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":777}}`))
- case strings.HasSuffix(r.URL.Path, "/deleteMessage"):
- requests <- "deleteMessage"
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"ok":true,"result":true}`))
- default:
- http.NotFound(w, r)
- }
- }))
- defer server.Close()
-
- cfg, _ := json.Marshal(map[string]string{
- "bot_token": "123456:ABC-def",
- "api_base_url": server.URL,
- "auto_delete_seconds": "0",
- })
- _, bot := newBotTestService(t)
- channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfg)}
- if err := bot.reply(context.Background(), channel, 42, telegramCommandReply{Text: "hello"}); err != nil {
- t.Fatalf("reply: %v", err)
- }
- waitForTelegramMethod(t, requests, "sendMessage")
- waitForTelegramMethod(t, requests, "deleteMessage")
-}
-
-func TestTelegramGroupCommandSendsPanelInGroup(t *testing.T) {
- var payloads []struct {
- ChatID any `json:"chat_id"`
- Text string `json:"text"`
- ReplyMarkup map[string]any `json:"reply_markup"`
- }
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if !strings.HasSuffix(r.URL.Path, "/sendMessage") {
- http.NotFound(w, r)
- return
- }
- var payload struct {
- ChatID any `json:"chat_id"`
- Text string `json:"text"`
- ReplyMarkup map[string]any `json:"reply_markup"`
- }
- if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
- t.Fatalf("decode sendMessage: %v", err)
- }
- payloads = append(payloads, payload)
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":777}}`))
- }))
- defer server.Close()
-
- repos, bot := newBotTestService(t)
- cfg, _ := json.Marshal(map[string]string{
- "bot_token": "123456:ABC-def",
- "api_base_url": server.URL,
- "group_chat_id": "-100123",
- "auto_delete_seconds": "-1",
- })
- if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfg)}).Error; err != nil {
- t.Fatalf("create channel: %v", err)
- }
- update, _ := json.Marshal(TelegramUpdate{
- UpdateID: 1,
- Message: &TelegramMessage{
- MessageID: 55,
- From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"},
- Chat: TelegramChat{ID: -100123, Type: "supergroup"},
- Text: "/menu",
- },
- })
- if err := bot.HandleWebhook(t.Context(), update); err != nil {
- t.Fatalf("handle webhook: %v", err)
- }
- if len(payloads) != 1 {
- t.Fatalf("sendMessage count = %d, payloads=%#v", len(payloads), payloads)
- }
- if got := fmt.Sprint(payloads[0].ChatID); got != "-100123" {
- t.Fatalf("message should stay in group, chat_id=%s payload=%#v", got, payloads[0])
- }
- if strings.Contains(payloads[0].Text, "管理员入口") {
- t.Fatalf("normal group user must not see admin panel: %#v", payloads[0])
- }
-}
-
-func TestTelegramGroupCallbackIsRejected(t *testing.T) {
- var callbackPayloads []struct {
- CallbackID string `json:"callback_query_id"`
- Text string `json:"text"`
- ShowAlert bool `json:"show_alert"`
- }
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if !strings.HasSuffix(r.URL.Path, "/answerCallbackQuery") {
- http.NotFound(w, r)
- return
- }
- var payload struct {
- CallbackID string `json:"callback_query_id"`
- Text string `json:"text"`
- ShowAlert bool `json:"show_alert"`
- }
- if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
- t.Fatalf("decode answerCallbackQuery: %v", err)
- }
- callbackPayloads = append(callbackPayloads, payload)
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"ok":true,"result":true}`))
- }))
- defer server.Close()
-
- ctx := t.Context()
- repos, auth, _, _ := newAuthTestServices(t)
- user, _, err := auth.Register(ctx, "viewer", "secret-pass")
- if err != nil {
- t.Fatalf("register user: %v", err)
- }
- if err := repos.DB.AutoMigrate(&model.NotifyChannel{}); err != nil {
- t.Fatalf("migrate notify channel: %v", err)
- }
- if err := repos.DB.Create(&model.TelegramBinding{
- TelegramUserID: 9002,
- TelegramName: "@viewer",
- ChatID: 9002,
- UserID: user.ID,
- }).Error; err != nil {
- t.Fatalf("create binding: %v", err)
- }
- cfg, _ := json.Marshal(map[string]string{
- "bot_token": "123456:ABC-def",
- "api_base_url": server.URL,
- "group_chat_id": "-100123",
- "auto_delete_seconds": "-1",
- })
- if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfg)}).Error; err != nil {
- t.Fatalf("create channel: %v", err)
- }
- before, _ := repos.User.FindByID(ctx, user.ID)
- bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth)
- update, _ := json.Marshal(TelegramUpdate{
- UpdateID: 2,
- CallbackQuery: &TelegramCallbackQuery{
- ID: "cb-group",
- From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"},
- Message: &TelegramMessage{MessageID: 56, Chat: TelegramChat{ID: -100123, Type: "supergroup"}},
- Data: "adult_toggle",
- },
- })
- if err := bot.HandleWebhook(ctx, update); err != nil {
- t.Fatalf("handle webhook: %v", err)
- }
- if len(callbackPayloads) != 1 {
- t.Fatalf("answerCallbackQuery count = %d", len(callbackPayloads))
- }
- if !callbackPayloads[0].ShowAlert || !strings.Contains(callbackPayloads[0].Text, "群组内按钮面板已禁用") {
- t.Fatalf("unexpected callback answer: %#v", callbackPayloads[0])
- }
- after, _ := repos.User.FindByID(ctx, user.ID)
- if before == nil || after == nil || before.HideAdult != after.HideAdult {
- t.Fatalf("group callback should not mutate user adult visibility: before=%#v after=%#v", before, after)
- }
-}
-
-func waitForTelegramMethod(t *testing.T, requests <-chan string, want string) {
- t.Helper()
- deadline := time.After(2 * time.Second)
- for {
- select {
- case got := <-requests:
- if got == want {
- return
- }
- case <-deadline:
- t.Fatalf("timed out waiting for telegram %s", want)
- }
- }
-}
diff --git a/internal/service/telegram_binding.go b/internal/service/telegram_binding.go
deleted file mode 100644
index cb16c66..0000000
--- a/internal/service/telegram_binding.go
+++ /dev/null
@@ -1,139 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "strings"
-
- "gorm.io/gorm"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *TelegramBotService) telegramBinding(ctx context.Context, telegramUserID int) *model.TelegramBinding {
- if telegramUserID == 0 {
- return nil
- }
- var binding model.TelegramBinding
- err := s.repo.DB.WithContext(ctx).Where("telegram_user_id = ?", int64(telegramUserID)).First(&binding).Error
- if err != nil {
- return nil
- }
- return &binding
-}
-
-func (s *TelegramBotService) unbindTelegramUser(ctx context.Context, telegramUserID int) error {
- if s == nil || s.repo == nil || s.repo.DB == nil || telegramUserID == 0 {
- return nil
- }
- return s.repo.DB.WithContext(ctx).Unscoped().
- Where("telegram_user_id = ?", int64(telegramUserID)).
- Delete(&model.TelegramBinding{}).Error
-}
-
-func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *TelegramMessage, userID string) error {
- name := strings.TrimSpace(msg.From.FirstName)
- if msg.From.Username != "" {
- name = "@" + strings.TrimSpace(msg.From.Username)
- }
- telegramUserID := int64(msg.From.ID)
- return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
- var existing model.TelegramBinding
- err := tx.Where("telegram_user_id = ?", telegramUserID).First(&existing).Error
- if err == nil {
- if err := s.replaceTelegramAccountBindingTx(ctx, tx, userID, telegramUserID); err != nil {
- return err
- }
- if err := tx.Model(&existing).Updates(map[string]any{
- "telegram_name": name,
- "chat_id": telegramBindingChatIDForMessage(msg, &existing),
- "user_id": userID,
- }).Error; telegramBindingUniqueErr(err) {
- return errTelegramAccountAlreadyBound
- } else if err != nil {
- return err
- }
- return nil
- }
- if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
- return err
- }
- if err := tx.Unscoped().Where("telegram_user_id = ?", telegramUserID).Delete(&model.TelegramBinding{}).Error; err != nil {
- return err
- }
- if err := s.replaceTelegramAccountBindingTx(ctx, tx, userID, telegramUserID); err != nil {
- return err
- }
- err = tx.Create(&model.TelegramBinding{
- TelegramUserID: telegramUserID,
- TelegramName: name,
- ChatID: telegramBindingChatIDForMessage(msg, nil),
- UserID: userID,
- }).Error
- if telegramBindingUniqueErr(err) {
- return errTelegramAccountAlreadyBound
- }
- return err
- })
-}
-
-func telegramBindingChatIDForMessage(msg *TelegramMessage, existing *model.TelegramBinding) int64 {
- if msg == nil {
- if existing != nil {
- return existing.ChatID
- }
- return 0
- }
- if msg.Chat.Type == "" || msg.Chat.Type == "private" {
- return int64(msg.Chat.ID)
- }
- if existing != nil && existing.ChatID > 0 {
- return existing.ChatID
- }
- return int64(msg.From.ID)
-}
-
-func telegramPrivateChatIDFromBinding(binding model.TelegramBinding) int64 {
- if binding.ChatID > 0 {
- return binding.ChatID
- }
- return binding.TelegramUserID
-}
-
-func (s *TelegramBotService) replaceTelegramAccountBindingTx(ctx context.Context, tx *gorm.DB, userID string, telegramUserID int64) error {
- return tx.WithContext(ctx).Unscoped().
- Where("user_id = ? AND telegram_user_id <> ?", userID, telegramUserID).
- Delete(&model.TelegramBinding{}).Error
-}
-
-func telegramBindingUniqueErr(err error) bool {
- if err == nil {
- return false
- }
- msg := strings.ToLower(err.Error())
- return strings.Contains(msg, "idx_telegram_bindings_user_id_active") ||
- strings.Contains(msg, "telegram_bindings.user_id") ||
- (strings.Contains(msg, "unique") && strings.Contains(msg, "telegram_bindings"))
-}
-
-func parseStartCredentials(args []string) (string, string) {
- if len(args) >= 2 {
- return strings.TrimSpace(args[0]), strings.TrimSpace(strings.Join(args[1:], " "))
- }
- if len(args) == 1 {
- raw := strings.TrimSpace(args[0])
- for _, sep := range []string{"-", ":", ":"} {
- if parts := strings.SplitN(raw, sep, 2); len(parts) == 2 {
- return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
- }
- }
- }
- return "", ""
-}
-
-func userNameOrFallback(user *model.User) string {
- if user == nil || strings.TrimSpace(user.Username) == "" {
- return "未知用户"
- }
- return user.Username
-}
diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go
deleted file mode 100644
index f98a22e..0000000
--- a/internal/service/telegram_bot.go
+++ /dev/null
@@ -1,298 +0,0 @@
-// Package service — Telegram Bot 交互命令服务。
-//
-// 处理通过 Telegram Bot API 接收的用户命令,提供系统状态查询、
-// 媒体搜索、下载管理等功能。同时支持 Webhook 和 Long Polling 两种模式。
-package service
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "strconv"
- "strings"
- "sync"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "github.com/ShukeBta/MediaStationGo/internal/repository"
-)
-
-// TelegramUpdate 是 Telegram Bot API 推送的 update 对象。
-type TelegramUpdate struct {
- UpdateID int `json:"update_id"`
- Message *TelegramMessage `json:"message,omitempty"`
- CallbackQuery *TelegramCallbackQuery `json:"callback_query,omitempty"`
-}
-
-// TelegramMessage 是 Telegram 消息对象。
-type TelegramMessage struct {
- MessageID int `json:"message_id"`
- From TelegramUser `json:"from"`
- Chat TelegramChat `json:"chat"`
- Text string `json:"text,omitempty"`
- Date int `json:"date"`
-}
-
-type TelegramCallbackQuery struct {
- ID string `json:"id"`
- From TelegramUser `json:"from"`
- Message *TelegramMessage `json:"message,omitempty"`
- Data string `json:"data,omitempty"`
-}
-
-// TelegramUser 是 Telegram 用户对象。
-type TelegramUser struct {
- ID int `json:"id"`
- FirstName string `json:"first_name"`
- Username string `json:"username,omitempty"`
-}
-
-// TelegramChat 是 Telegram 聊天对象。
-type TelegramChat struct {
- ID int `json:"id"`
- Type string `json:"type"`
-}
-
-type telegramCommandReply struct {
- Text string
- Buttons [][]telegramInlineButton
-}
-
-type telegramInlineButton struct {
- Text string `json:"text"`
- Data string `json:"callback_data"`
-}
-
-// TelegramBotService 处理 Telegram Bot 的交互命令。
-type TelegramBotService struct {
- log *zap.Logger
- repo *repository.Container
- crypto *CryptoService
- auth *AuthService
- device *DeviceService
- backup *BackupService
-
- pollingMu sync.Mutex
- pollingCancel map[string]context.CancelFunc // bot_token -> cancel
-
- pendingMu sync.Mutex
- pending map[int64]pendingInput // telegram_user_id -> awaited text input
-}
-
-// pendingInput tracks a button-initiated action that awaits the user's next
-// text message (e.g. tapping「注册」then sending "用户名 密码").
-type pendingInput struct {
- Kind string // register / redeem_register / redeem_renew / setname / setpass / openreg_limit / gencode_user
- CreatedAt time.Time
-}
-
-// SetDeviceService wires the device-management service used by the device
-// menu (list / kick) and enforcement notifications.
-func (s *TelegramBotService) SetDeviceService(d *DeviceService) { s.device = d }
-
-// SetBackupService wires database backup/restore commands.
-func (s *TelegramBotService) SetBackupService(b *BackupService) { s.backup = b }
-
-// NotifyUserByID sends a Telegram message to the local user identified by
-// userID, resolved through their Telegram binding. Used by enforcement to warn
-// users before destructive actions. No-op when the user has no binding.
-func (s *TelegramBotService) NotifyUserByID(ctx context.Context, userID, text string) {
- if userID == "" || strings.TrimSpace(text) == "" {
- return
- }
- var binding model.TelegramBinding
- if err := s.repo.DB.WithContext(ctx).Where("user_id = ?", userID).First(&binding).Error; err != nil {
- return
- }
- targetChatID := telegramPrivateChatIDFromBinding(binding)
- if targetChatID == 0 {
- return
- }
- channel := s.findChannelByChatID(ctx, int(binding.ChatID))
- if channel == nil {
- channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram")
- if err != nil || len(channels) == 0 {
- return
- }
- channel = &channels[0]
- }
- _ = s.reply(ctx, channel, int(targetChatID), telegramCommandReply{Text: text})
-}
-
-// NewTelegramBotService 创建 Telegram Bot 服务。
-func NewTelegramBotService(log *zap.Logger, repo *repository.Container, crypto *CryptoService, auth *AuthService) *TelegramBotService {
- return &TelegramBotService{
- log: log,
- repo: repo,
- crypto: crypto,
- auth: auth,
- pollingCancel: make(map[string]context.CancelFunc),
- pending: make(map[int64]pendingInput),
- }
-}
-
-// TelegramRegistrationSettingKey 控制普通用户是否可以通过 Bot 注册新账号。
-// 默认关闭,只有管理员在系统设置 / Bot 管理命令中显式开启后才允许注册。
-const TelegramRegistrationSettingKey = "telegram.registration_enabled"
-
-var errTelegramAccountAlreadyBound = errors.New("该媒体账号已绑定其他 Telegram,请联系管理员解绑")
-
-// registrationEnabled 读取注册开关;默认关闭。
-func (s *TelegramBotService) registrationEnabled(ctx context.Context) bool {
- v, err := s.repo.Setting.Get(ctx, TelegramRegistrationSettingKey)
- if err != nil {
- return false
- }
- return parseBoolSetting(v, false)
-}
-
-// setRegistrationEnabled 持久化注册开关。
-func (s *TelegramBotService) setRegistrationEnabled(ctx context.Context, enabled bool) error {
- return s.repo.Setting.Set(ctx, TelegramRegistrationSettingKey, strconv.FormatBool(enabled))
-}
-
-// HandleWebhook 处理 Telegram 推送的 Webhook/Polling 消息。
-func (s *TelegramBotService) HandleWebhook(ctx context.Context, body []byte) error {
- var update TelegramUpdate
- if err := json.Unmarshal(body, &update); err != nil {
- return fmt.Errorf("invalid update: %w", err)
- }
- return s.handleTelegramUpdate(ctx, update, nil)
-}
-
-func (s *TelegramBotService) handleTelegramUpdate(ctx context.Context, update TelegramUpdate, channelHint *model.NotifyChannel) error {
- if update.CallbackQuery != nil {
- return s.handleCallback(ctx, update.CallbackQuery, channelHint)
- }
-
- if update.Message == nil || update.Message.Text == "" {
- return nil
- }
-
- msg := update.Message
- text := strings.TrimSpace(msg.Text)
-
- // Button-initiated text prompts (register / redeem / change name·password /
- // open-reg limit) arrive as ordinary messages. Consume them here before the
- // command gate so the button-driven menu can collect free-form input.
- if !telegramIsCommandText(text) {
- if msg.Chat.Type == "" || msg.Chat.Type == "private" {
- if channel := s.channelForMessage(ctx, msg, channelHint); channel != nil {
- if reply, handled := s.handlePendingText(ctx, channel, msg, text); handled {
- if reply.Text != "" {
- if err := s.reply(ctx, channel, msg.Chat.ID, reply); err != nil {
- s.log.Error("reply failed", zap.Error(err))
- }
- }
- s.deleteTelegramSourceMessage(channel, msg.Chat.ID, msg.MessageID)
- return nil
- }
- if looksLikeRedemptionCode(text) {
- reply := s.cmdRedeem(ctx, channel, msg, []string{text})
- if reply.Text != "" {
- if err := s.reply(ctx, channel, msg.Chat.ID, reply); err != nil {
- s.log.Error("reply failed", zap.Error(err))
- }
- }
- s.deleteTelegramSourceMessage(channel, msg.Chat.ID, msg.MessageID)
- return nil
- }
- }
- }
- return nil
- }
- if msg.Chat.Type != "" && msg.Chat.Type != "private" && !telegramSupportedCommand(telegramCommandName(text)) {
- return nil
- }
-
- s.log.Info("telegram command received",
- zap.Int("chat_id", msg.Chat.ID),
- zap.String("user", msg.From.Username),
- zap.String("text", text),
- )
-
- // 获取该消息可使用的 Telegram 通知渠道配置。群组/频道消息必须来自
- // 已配置的群组/频道;私聊消息会选择一个可验证该用户成员身份的 Bot。
- channel := s.channelForMessage(ctx, msg, channelHint)
- if channel == nil {
- s.log.Warn("telegram channel not allowed or not configured",
- zap.Int("chat_id", msg.Chat.ID),
- zap.String("chat_type", msg.Chat.Type),
- zap.Int("telegram_user_id", msg.From.ID),
- )
- return nil
- }
-
- // 解析并执行命令
- reply, err := s.executeCommand(ctx, channel, msg, text)
- if err != nil {
- s.log.Error("command failed", zap.Error(err))
- _ = s.replyForMessage(ctx, channel, msg, telegramCommandReply{Text: "命令执行失败: " + err.Error()})
- s.deleteTelegramSourceMessage(channel, msg.Chat.ID, msg.MessageID)
- return nil
- }
-
- if reply.Text != "" {
- if err := s.replyForMessage(ctx, channel, msg, reply); err != nil {
- s.log.Error("reply failed", zap.Error(err))
- }
- s.deleteTelegramSourceMessage(channel, msg.Chat.ID, msg.MessageID)
- }
-
- return nil
-}
-
-func telegramIsCommandText(text string) bool {
- return strings.HasPrefix(strings.TrimSpace(text), "/") && telegramCommandName(text) != ""
-}
-
-func telegramCommandName(text string) string {
- parts := strings.Fields(strings.TrimSpace(text))
- if len(parts) == 0 {
- return ""
- }
- cmd := strings.ToLower(strings.TrimSpace(parts[0]))
- if !strings.HasPrefix(cmd, "/") {
- return ""
- }
- if at := strings.Index(cmd, "@"); at > 0 {
- cmd = cmd[:at]
- }
- return cmd
-}
-
-func telegramIsGroupChat(chatType string) bool {
- return chatType != "" && chatType != "private"
-}
-
-func telegramPrivateMessageForUser(msg *TelegramMessage) *TelegramMessage {
- if msg == nil || !telegramIsGroupChat(msg.Chat.Type) {
- return msg
- }
- copied := *msg
- copied.Chat = TelegramChat{ID: msg.From.ID, Type: "private"}
- return &copied
-}
-
-func telegramGroupPrivateAdminHint() string {
- return "群组内不展示管理面板;管理员可在已绑定群组直接发送文本管理命令,涉及账号凭据的操作仍请私聊 Bot。"
-}
-
-func telegramGroupPrivateUserHint(action string) string {
- action = strings.TrimSpace(action)
- if action == "" {
- action = "此操作"
- }
- return action + "包含账号凭据或敏感信息,请私聊 Bot 操作;群组内仅开放账号状态、签到、设备与成人目录开关。"
-}
-
-func telegramGroupPrivateDeliverySentHint() string {
- return "已把你的 Bot 面板/执行结果私聊发送给你。若没收到,请先私聊 Bot 发送 /start。"
-}
-
-func telegramGroupPrivateDeliveryFailedHint() string {
- return "无法私聊发送给你。请先打开 Bot 私聊窗口发送 /start,再回群里使用命令。"
-}
diff --git a/internal/service/telegram_bot_binding_test.go b/internal/service/telegram_bot_binding_test.go
deleted file mode 100644
index 9c2209d..0000000
--- a/internal/service/telegram_bot_binding_test.go
+++ /dev/null
@@ -1,293 +0,0 @@
-package service
-
-import (
- "encoding/json"
- "errors"
- "strings"
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func TestTelegramStartClearsStaleUserBinding(t *testing.T) {
- repos, auth, _, _ := newAuthTestServices(t)
- if err := repos.DB.Create(&model.TelegramBinding{
- TelegramUserID: 20001,
- TelegramName: "@viewer",
- ChatID: 20001,
- UserID: "deleted-user",
- }).Error; err != nil {
- t.Fatalf("create binding: %v", err)
- }
- bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth)
-
- reply := bot.cmdStart(t.Context(), &TelegramMessage{
- From: TelegramUser{ID: 20001, Username: "viewer", FirstName: "Viewer"},
- Chat: TelegramChat{ID: 20001, Type: "private"},
- }, nil)
-
- if !strings.Contains(reply.Text, "已不存在") {
- t.Fatalf("expected stale binding message, got %q", reply.Text)
- }
- var count int64
- if err := repos.DB.Model(&model.TelegramBinding{}).Where("telegram_user_id = ?", 20001).Count(&count).Error; err != nil {
- t.Fatalf("count binding: %v", err)
- }
- if count != 0 {
- t.Fatalf("stale binding should be removed, got %d", count)
- }
-}
-
-func TestTelegramStartReplacesAccountBindingFromAnotherTelegram(t *testing.T) {
- ctx := t.Context()
- repos, auth, _, _ := newAuthTestServices(t)
- user, _, err := auth.Register(ctx, "viewer", "secret-pass")
- if err != nil {
- t.Fatalf("register: %v", err)
- }
- if err := repos.DB.Create(&model.TelegramBinding{
- TelegramUserID: 20001,
- TelegramName: "@viewer-one",
- ChatID: 20001,
- UserID: user.ID,
- }).Error; err != nil {
- t.Fatalf("create binding: %v", err)
- }
-
- bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth)
- cfgJSON, _ := json.Marshal(map[string]string{"admin_user_ids": "20002"})
- if err := repos.DB.AutoMigrate(&model.NotifyChannel{}); err != nil {
- t.Fatalf("migrate notify channel: %v", err)
- }
- if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfgJSON)}).Error; err != nil {
- t.Fatalf("create notify channel: %v", err)
- }
- msg := &TelegramMessage{
- From: TelegramUser{ID: 20002, Username: "viewer-two", FirstName: "Viewer Two"},
- Chat: TelegramChat{ID: 20002, Type: "private"},
- }
- reply := bot.cmdStart(ctx, msg, []string{"viewer", "secret-pass"})
-
- if !strings.Contains(reply.Text, "绑定成功") {
- t.Fatalf("expected new telegram account to replace old binding, got %q", reply.Text)
- }
- var accountBindings int64
- if err := repos.DB.Model(&model.TelegramBinding{}).Where("user_id = ?", user.ID).Count(&accountBindings).Error; err != nil {
- t.Fatalf("count account bindings: %v", err)
- }
- if accountBindings != 1 {
- t.Fatalf("account should keep exactly one telegram binding, got %d", accountBindings)
- }
- if binding := bot.telegramBinding(ctx, 20002); binding == nil || binding.UserID != user.ID {
- t.Fatalf("new telegram account should be bound to user, got %#v", binding)
- }
- if binding := bot.telegramBinding(ctx, 20001); binding != nil {
- t.Fatalf("old telegram binding should be removed, got %#v", binding)
- }
-}
-
-func TestTelegramStartUnbindsWhenBoundPasswordChanged(t *testing.T) {
- ctx := t.Context()
- repos, auth, _, _ := newAuthTestServices(t)
- user, _, err := auth.Register(ctx, "viewer", "old-password")
- if err != nil {
- t.Fatalf("register: %v", err)
- }
- if err := repos.DB.Create(&model.TelegramBinding{
- TelegramUserID: 20003,
- TelegramName: "@viewer",
- ChatID: 20003,
- UserID: user.ID,
- }).Error; err != nil {
- t.Fatalf("create binding: %v", err)
- }
- if err := auth.ResetPassword(ctx, user.ID, "new-password"); err != nil {
- t.Fatalf("reset password: %v", err)
- }
- if err := repos.DB.AutoMigrate(&model.NotifyChannel{}); err != nil {
- t.Fatalf("migrate notify channel: %v", err)
- }
- cfgJSON, _ := json.Marshal(map[string]string{"admin_user_ids": "20003"})
- if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfgJSON)}).Error; err != nil {
- t.Fatalf("create notify channel: %v", err)
- }
-
- bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth)
- msg := &TelegramMessage{
- From: TelegramUser{ID: 20003, Username: "viewer", FirstName: "Viewer"},
- Chat: TelegramChat{ID: 20003, Type: "private"},
- }
- reply := bot.cmdStart(ctx, msg, []string{"viewer", "old-password"})
-
- if !strings.Contains(reply.Text, "已自动解绑") {
- t.Fatalf("expected auto unbind reply, got %q", reply.Text)
- }
- if binding := bot.telegramBinding(ctx, 20003); binding != nil {
- t.Fatal("stale binding should be removed after password mismatch")
- }
-}
-
-func TestTelegramSelfSetNameRequiresCurrentPassword(t *testing.T) {
- ctx := t.Context()
- repos, auth, _, _ := newAuthTestServices(t)
- user, _, err := auth.Register(ctx, "viewer", "old-password")
- if err != nil {
- t.Fatalf("register: %v", err)
- }
- if err := repos.DB.Create(&model.TelegramBinding{
- TelegramUserID: 20004,
- TelegramName: "@viewer",
- ChatID: 20004,
- UserID: user.ID,
- }).Error; err != nil {
- t.Fatalf("create binding: %v", err)
- }
-
- bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth)
- msg := &TelegramMessage{From: TelegramUser{ID: 20004, Username: "viewer"}, Chat: TelegramChat{ID: 20004, Type: "private"}}
- if reply := bot.selfSetName(ctx, msg, "renamed"); !strings.Contains(reply.Text, "当前密码 新用户名") {
- t.Fatalf("expected usage reply, got %q", reply.Text)
- }
- if reply := bot.selfSetName(ctx, msg, "old-password renamed"); !strings.Contains(reply.Text, "用户名已修改") {
- t.Fatalf("expected rename success, got %q", reply.Text)
- }
- updated, _ := repos.User.FindByID(ctx, user.ID)
- if updated == nil || updated.Username != "renamed" {
- t.Fatalf("username not updated: %#v", updated)
- }
-}
-
-func TestTelegramSelfSetPassWrongCurrentPasswordUnbinds(t *testing.T) {
- ctx := t.Context()
- repos, auth, _, _ := newAuthTestServices(t)
- user, _, err := auth.Register(ctx, "viewer", "old-password")
- if err != nil {
- t.Fatalf("register: %v", err)
- }
- if err := repos.DB.Create(&model.TelegramBinding{
- TelegramUserID: 20005,
- TelegramName: "@viewer",
- ChatID: 20005,
- UserID: user.ID,
- }).Error; err != nil {
- t.Fatalf("create binding: %v", err)
- }
-
- bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth)
- msg := &TelegramMessage{From: TelegramUser{ID: 20005, Username: "viewer"}, Chat: TelegramChat{ID: 20005, Type: "private"}}
- reply := bot.selfSetPass(ctx, msg, "wrong-password new-password")
-
- if !strings.Contains(reply.Text, "已自动解绑") {
- t.Fatalf("expected auto unbind reply, got %q", reply.Text)
- }
- if binding := bot.telegramBinding(ctx, 20005); binding != nil {
- t.Fatal("binding should be removed after wrong current password")
- }
- if _, err := auth.Login(ctx, "viewer", "old-password"); err != nil {
- t.Fatalf("old password should remain valid after failed change: %v", err)
- }
-}
-
-func TestTelegramSelfSetPassChangesPasswordWithCurrentPassword(t *testing.T) {
- ctx := t.Context()
- repos, auth, _, _ := newAuthTestServices(t)
- user, _, err := auth.Register(ctx, "viewer", "old-password")
- if err != nil {
- t.Fatalf("register: %v", err)
- }
- if err := repos.DB.Create(&model.TelegramBinding{
- TelegramUserID: 20006,
- TelegramName: "@viewer",
- ChatID: 20006,
- UserID: user.ID,
- }).Error; err != nil {
- t.Fatalf("create binding: %v", err)
- }
-
- bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth)
- msg := &TelegramMessage{From: TelegramUser{ID: 20006, Username: "viewer"}, Chat: TelegramChat{ID: 20006, Type: "private"}}
- reply := bot.selfSetPass(ctx, msg, "old-password new-password")
-
- if !strings.Contains(reply.Text, "密码已修改") {
- t.Fatalf("expected password change success, got %q", reply.Text)
- }
- if _, err := auth.Login(ctx, "viewer", "old-password"); !errors.Is(err, ErrInvalidCredentials) {
- t.Fatalf("old password should fail, got %v", err)
- }
- if _, err := auth.Login(ctx, "viewer", "new-password"); err != nil {
- t.Fatalf("new password should login: %v", err)
- }
- if binding := bot.telegramBinding(ctx, 20006); binding == nil {
- t.Fatal("successful password change should keep telegram binding")
- }
-}
-
-func TestTelegramBindingFromGroupStoresPrivateUserChatID(t *testing.T) {
- ctx := t.Context()
- repos, auth, _, _ := newAuthTestServices(t)
- user, _, err := auth.Register(ctx, "viewer", "secret-pass")
- if err != nil {
- t.Fatalf("register: %v", err)
- }
- bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth)
- msg := &TelegramMessage{
- From: TelegramUser{ID: 21001, Username: "viewer", FirstName: "Viewer"},
- Chat: TelegramChat{ID: -100123456, Type: "group"},
- }
-
- if err := bot.upsertTelegramBinding(ctx, msg, user.ID); err != nil {
- t.Fatalf("upsert binding: %v", err)
- }
- binding := bot.telegramBinding(ctx, 21001)
- if binding == nil {
- t.Fatal("binding should be created")
- }
- if binding.ChatID != 21001 {
- t.Fatalf("group binding must store private user chat id, got %d", binding.ChatID)
- }
-}
-
-func TestTelegramBindingFromGroupPreservesExistingPrivateChatID(t *testing.T) {
- ctx := t.Context()
- repos, auth, _, _ := newAuthTestServices(t)
- user, _, err := auth.Register(ctx, "viewer", "secret-pass")
- if err != nil {
- t.Fatalf("register: %v", err)
- }
- if err := repos.DB.Create(&model.TelegramBinding{
- TelegramUserID: 21002,
- TelegramName: "@viewer",
- ChatID: 987654,
- UserID: user.ID,
- }).Error; err != nil {
- t.Fatalf("seed binding: %v", err)
- }
- bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth)
- msg := &TelegramMessage{
- From: TelegramUser{ID: 21002, Username: "viewer", FirstName: "Viewer"},
- Chat: TelegramChat{ID: -100123456, Type: "supergroup"},
- }
-
- if err := bot.upsertTelegramBinding(ctx, msg, user.ID); err != nil {
- t.Fatalf("upsert binding: %v", err)
- }
- binding := bot.telegramBinding(ctx, 21002)
- if binding == nil {
- t.Fatal("binding should exist")
- }
- if binding.ChatID != 987654 {
- t.Fatalf("group command must not overwrite existing private chat id, got %d", binding.ChatID)
- }
-}
-
-func TestTelegramPrivateNotifyChatIDFallsBackFromLegacyGroupBinding(t *testing.T) {
- binding := model.TelegramBinding{
- TelegramUserID: 21003,
- ChatID: -100123456,
- }
- if got := telegramPrivateChatIDFromBinding(binding); got != 21003 {
- t.Fatalf("legacy group binding should notify private user chat, got %d", got)
- }
-}
diff --git a/internal/service/telegram_bot_commands_core.go b/internal/service/telegram_bot_commands_core.go
deleted file mode 100644
index 7d0d203..0000000
--- a/internal/service/telegram_bot_commands_core.go
+++ /dev/null
@@ -1,225 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "strconv"
- "strings"
-
- "golang.org/x/crypto/bcrypt"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-// cmdStart 处理 /start 命令。
-func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
- name := msg.From.FirstName
- if msg.From.Username != "" {
- name = "@" + msg.From.Username
- }
- if telegramIsGroupChat(msg.Chat.Type) && len(args) > 0 {
- return telegramCommandReply{Text: telegramGroupPrivateUserHint("绑定账号")}
- }
- if len(args) == 0 {
- if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil {
- user, _ := s.repo.User.FindByID(ctx, binding.UserID)
- if user == nil {
- _ = s.repo.DB.WithContext(ctx).Unscoped().Delete(&model.TelegramBinding{}, "id = ?", binding.ID).Error
- return telegramCommandReply{Text: "之前绑定的媒体中心账号已不存在,请重新绑定:\n/start 用户名 密码"}
- }
- status := "未隐藏"
- if user.HideAdult {
- status = "已隐藏"
- }
- return telegramCommandReply{
- Text: fmt.Sprintf("MediaStationGo 已绑定\n\n你好 %s,当前账号:%s\n成人目录:%s", name, userNameOrFallback(user), status),
- Buttons: [][]telegramInlineButton{{{
- Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult],
- Data: "adult_toggle",
- }}},
- }
- }
- hint := "如果没有账号,请联系管理员注册。"
- if s.openRegEnabled(ctx) {
- hint = "如果还没有账号,可直接注册:\n/register 用户名 密码\n或:/register 用户名-密码"
- }
- return telegramCommandReply{Text: "欢迎使用 MediaStationGo\n\n普通用户请先绑定账号:\n/start 用户名 密码\n或:/start 用户名-密码\n\n" + hint}
- }
- channel := s.findChannelForMessage(ctx, msg)
- if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed {
- return telegramCommandReply{Text: telegramBindRejectText(dec, "绑定媒体中心账号")}
- }
- username, password := parseStartCredentials(args)
- if username == "" || password == "" {
- return telegramCommandReply{Text: "绑定格式不正确,请使用:\n/start 用户名 密码\n或:/start 用户名-密码"}
- }
- existingBinding := s.telegramBinding(ctx, msg.From.ID)
- user, err := s.repo.User.FindByUsername(ctx, username)
- if err != nil || user == nil {
- if existingBinding != nil {
- _ = s.unbindTelegramUser(ctx, msg.From.ID)
- return telegramCommandReply{Text: "当前绑定的媒体账号信息已失效,已自动解绑。请使用新的用户名和密码重新绑定。"}
- }
- return telegramCommandReply{Text: "未找到此用户,请联系管理员注册。"}
- }
- if !user.IsActive {
- return telegramCommandReply{Text: "此账号已被禁用,请联系管理员。"}
- }
- if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
- if existingBinding != nil && existingBinding.UserID == user.ID {
- _ = s.unbindTelegramUser(ctx, msg.From.ID)
- return telegramCommandReply{Text: "当前绑定账号的密码已失效,已自动解绑。请使用新密码重新绑定。"}
- }
- return telegramCommandReply{Text: "账号或密码错误。"}
- }
- if err := s.upsertTelegramBinding(ctx, msg, user.ID); err != nil {
- return telegramCommandReply{Text: "绑定失败:" + err.Error()}
- }
- return telegramCommandReply{
- Text: fmt.Sprintf("绑定成功:%s\n\n普通用户只能使用此 Bot 管理自己的成人目录隐藏状态;系统状态、搜索、下载和统计命令仅管理员可用。", user.Username),
- Buttons: [][]telegramInlineButton{{{
- Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult],
- Data: "adult_toggle",
- }}},
- }
-}
-
-// cmdRegister 处理 /register 命令:在管理员开启注册后,普通用户可通过 Bot
-// 注册一个新的媒体中心账号,并自动绑定到当前 Telegram 账号。
-func (s *TelegramBotService) cmdRegister(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, args []string) telegramCommandReply {
- if len(args) == 1 && looksLikeRedemptionCode(args[0]) {
- return s.redeemRegisterFlow(ctx, channel, msg, args[0])
- }
- if !s.openRegEnabled(ctx) {
- return telegramCommandReply{Text: "注册功能未开放,请联系管理员开启后再试。"}
- }
- // 开注名额已用尽则拦截(容量随凭证授权实时变化,名额单独计数)。
- if c := s.loadCapacity(ctx); c.Remaining() <= 0 {
- return telegramCommandReply{Text: "注册名额已满,请等待管理员重新开放或扩容授权。"}
- }
- if s.auth == nil {
- return telegramCommandReply{Text: "注册功能暂不可用,请联系管理员。"}
- }
- if channel == nil {
- channel = s.findChannelForMessage(ctx, msg)
- }
- if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed {
- return telegramCommandReply{Text: telegramBindRejectText(dec, "注册账号")}
- }
- if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil {
- if user, _ := s.repo.User.FindByID(ctx, binding.UserID); user != nil {
- return telegramCommandReply{Text: fmt.Sprintf("当前 Telegram 已绑定账号:%s,无需重复注册。\n如需切换账号请使用 /start 用户名 密码。", userNameOrFallback(user))}
- }
- }
- username, password := parseStartCredentials(args)
- if username == "" || password == "" {
- return telegramCommandReply{Text: "注册格式不正确,请使用:\n/register 用户名 密码\n或:/register 用户名-密码"}
- }
- user, _, err := s.auth.Register(ctx, username, password)
- if err != nil {
- switch {
- case errors.Is(err, ErrUsernameTaken):
- return telegramCommandReply{Text: "该用户名已被占用,请换一个;如果是你本人的账号,请改用 /start 用户名 密码 绑定。"}
- case errors.Is(err, ErrUserLimitReached):
- return telegramCommandReply{Text: "注册失败:已达到用户数量上限,请联系管理员。"}
- default:
- return telegramCommandReply{Text: "注册失败:" + err.Error()}
- }
- }
- // 注册成功,扣减一个开注名额(名额用尽自动关闭注册)。
- s.consumeOpenRegSlot(ctx)
- if err := s.upsertTelegramBinding(ctx, msg, user.ID); err != nil {
- return telegramCommandReply{Text: fmt.Sprintf("账号 %s 注册成功,但自动绑定失败:%s\n请稍后使用 /start %s 密码 重新绑定。", user.Username, err.Error(), user.Username)}
- }
- return telegramCommandReply{
- Text: fmt.Sprintf("注册并绑定成功:%s\n\n你现在可以用此账号登录网页与第三方客户端。普通用户只能在此 Bot 管理成人目录显隐;其他功能仅管理员可用。", user.Username),
- Buttons: [][]telegramInlineButton{{{
- Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult],
- Data: "adult_toggle",
- }}},
- }
-}
-
-// cmdRegistrationToggle handles /registration and /openreg. It uses the same
-// quota-aware open-registration state as the inline Bot menu.
-func (s *TelegramBotService) cmdRegistrationToggle(ctx context.Context, args []string) telegramCommandReply {
- if len(args) == 0 || strings.EqualFold(strings.TrimSpace(args[0]), "status") {
- c := s.loadCapacity(ctx)
- state := "已关闭"
- if c.OpenRegOn {
- if c.OpenRegLimit > 0 {
- state = fmt.Sprintf("已开启(%d/%d 名额)", c.OpenRegUsed, c.OpenRegLimit)
- } else {
- state = "已开启(不限名额,受授权上限约束)"
- }
- }
- return telegramCommandReply{Text: fmt.Sprintf("普通用户 Bot 注册功能当前%s。\n剩余可注册:%d 人。\n\n开启:/registration on 10\n不限:/registration on 0\n关闭:/registration off", state, c.Remaining())}
- }
- switch strings.ToLower(strings.TrimSpace(args[0])) {
- case "on", "true", "1", "open", "enable", "enabled", "开启", "打开", "开":
- limit := 0
- if len(args) > 1 {
- n, err := strconv.Atoi(strings.TrimSpace(args[1]))
- if err != nil || n < 0 {
- return telegramCommandReply{Text: "名额必须是非负整数,0 表示不限名额。"}
- }
- limit = n
- }
- if err := s.openRegistration(ctx, limit); err != nil {
- return telegramCommandReply{Text: "开启失败:" + err.Error()}
- }
- label := "不限名额"
- if limit > 0 {
- label = fmt.Sprintf("%d 个名额", limit)
- }
- return telegramCommandReply{Text: "普通用户 Bot 注册功能已开启:" + label + "。"}
- case "off", "false", "0", "close", "disable", "disabled", "关闭", "关":
- if err := s.closeRegistration(ctx); err != nil {
- return telegramCommandReply{Text: "关闭失败:" + err.Error()}
- }
- return telegramCommandReply{Text: "普通用户 Bot 注册功能已关闭。"}
- default:
- return telegramCommandReply{Text: "参数无效,请使用 /registration on [名额] 或 /registration off。"}
- }
-}
-
-// cmdStatus 处理 /status 命令。
-func (s *TelegramBotService) cmdHideAdult(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
- channel := s.findChannelForMessage(ctx, msg)
- if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed {
- return telegramCommandReply{Text: telegramBindRejectText(dec, "使用成人目录隐藏开关")}
- }
- binding := s.telegramBinding(ctx, msg.From.ID)
- if binding == nil {
- return telegramCommandReply{Text: "请先绑定账号:/start 用户名 密码"}
- }
- user, err := s.repo.User.FindByID(ctx, binding.UserID)
- if err != nil || user == nil {
- return telegramCommandReply{Text: "绑定用户不存在,请重新 /start 绑定。"}
- }
- next := true
- if len(args) > 0 {
- switch strings.ToLower(strings.TrimSpace(args[0])) {
- case "off", "false", "0", "show", "显示", "关闭":
- next = false
- case "on", "true", "1", "hide", "隐藏", "开启":
- next = true
- default:
- next = !user.HideAdult
- }
- } else {
- next = !user.HideAdult
- }
- if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"hide_adult": next}); err != nil {
- return telegramCommandReply{Text: "更新失败:" + err.Error()}
- }
- status := map[bool]string{true: "已隐藏", false: "已显示"}[next]
- return telegramCommandReply{
- Text: "成人目录" + status + "。此设置会同步影响网页与第三方客户端。",
- Buttons: [][]telegramInlineButton{{{
- Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[next],
- Data: "adult_toggle",
- }}},
- }
-}
diff --git a/internal/service/telegram_bot_commands_help.go b/internal/service/telegram_bot_commands_help.go
deleted file mode 100644
index 6390753..0000000
--- a/internal/service/telegram_bot_commands_help.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package service
-
-import "context"
-
-// cmdHelp 处理 /help 命令。
-func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage) string {
- channel := s.findChannelForMessage(ctx, msg)
- if telegramIsGroupChat(msg.Chat.Type) {
- adminHint := ""
- if s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
- adminHint = "\n\n管理员可在已绑定群组直接发送文本管理命令;管理面板和账号凭据操作请私聊 Bot。"
- }
- return "MediaStationGo 群组可用命令\n\n" +
- "/menu — 打开群组自助菜单\n" +
- "/account — 查看账号状态\n" +
- "/signin — 签到\n" +
- "/devices — 查看登录设备\n" +
- "/kick all|编号 — 踢下线设备\n" +
- "/hideadult on|off — 隐藏或显示成人目录\n\n" +
- "绑定、注册、兑换、改名、改密等包含敏感信息的操作请私聊 Bot。" +
- adminHint
- }
- if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
- register := ""
- if s.openRegEnabled(ctx) {
- register = "/register 用户名 密码 — 注册新账号\n"
- }
- return "MediaStationGo 用户命令\n\n" +
- register +
- "/start 用户名 密码 — 绑定账号\n" +
- "/account — 查看账号状态\n" +
- "/signin — 签到\n" +
- "/devices — 查看登录设备\n" +
- "/kick all|编号 — 踢下线设备\n" +
- "/setname 当前密码 新用户名 — 修改用户名\n" +
- "/setpass 当前密码 新密码 — 修改密码\n" +
- "/redeem 兑换码 — 注册或续期兑换\n" +
- "/hideadult on|off — 隐藏或显示成人目录\n\n" +
- "系统状态、搜索、下载列表与统计命令仅管理员可用。"
- }
- return "MediaStationGo 命令列表\n\n" +
- "/start — 开始使用\n" +
- "/help — 帮助信息\n" +
- "/account / /devices / /kick all|编号 — 用户自助设备管理\n" +
- "/signin / /redeem 兑换码 — 签到与兑换\n" +
- "/setname 当前密码 新用户名 / /setpass 当前密码 新密码 — 用户自助改名改密\n" +
- "/register 用户名 密码 — 注册新账号(需管理员开启)\n" +
- "/registration on [名额]|off — 开启/关闭普通用户注册(管理员)\n" +
- "/capacity / /users — 容量与用户管理(管理员)\n" +
- "/gencode register|renew 天数 [有效天数] — 生成兑换码(管理员)\n" +
- "/renew_user 用户名 天数 / /delete_user 用户名 confirm — 续期/删除用户(管理员)\n" +
- "/unbind 用户1 用户2 — 批量解绑 Telegram 绑定(管理员)\n" +
- "/unbind_duplicates / /unbind_inactive 天数 — 清理重复/无效绑定或久未登录绑定(管理员)\n" +
- "/antishare on play=3 login=3 warn=2 — 防共享策略(管理员)\n" +
- "/cleanup run — 预览保号清理候选(管理员)\n" +
- "/cleanup run confirm — 确认清理候选账号(管理员)\n" +
- "/cleanup on|off — 保号规则开关(管理员)\n" +
- "/cleanup_rule list|add|edit|修改|del|enable|disable — Mgo 保号规则(管理员)\n" +
- "/ban 用户名 / /unban 用户名 — 禁用/解禁用户(管理员)\n" +
- "/hideadult on|off — 隐藏/显示当前绑定账号的成人目录\n" +
- "/status — 系统运行状态\n" +
- "/search 关键词 — 搜索媒体库\n" +
- "/downloads — 下载列表\n" +
- "/stats — 媒体库统计\n\n" +
- telegramMgoAdminCommandHelp() + "\n\n" +
- "自动推送事件:\n" +
- "• 订阅命中新资源\n" +
- "• 下载任务完成\n" +
- "• 刮削失败告警\n" +
- "• 系统异常通知"
-}
-
-func telegramMgoAdminCommandHelp() string {
- return "Mgo 管理命令(管理员可用,已注册到命令栏):\n" +
- "用户:/ucr 用户名 密码 [天数] 创建账号;/uinfo 用户名 查询账号;/rmemby 用户名 confirm 删除账号;/only_rm_record tg:ID|用户名 仅删 Bot 绑定;/renewall 天数 confirm 批量续期。\n" +
- "审计:/userip 用户名 查用户 IP;/auditip IP 按 IP 审计;/auditdevice 关键词 按终端设备审计;/auditclient 关键词 按客户端审计;/udeviceid 设备ID 按设备指纹审计。\n" +
- "清理:/syncunbound 检查未绑定账号;/syncgroupm 校验群成员;/check_ex 检查过期账号;/deleted 按保号规则预览清理候选。\n" +
- "权限:/embyadmin 用户名 on|off 设置管理员;/banall confirm//unbanall confirm 批量禁用/解禁;/prouser 用户名//revuser 用户名 管理保护名单;/embylibs_blockall//embylibs_unblockall 批量禁用/开放媒体库权限。\n" +
- "运维:/proadmin TelegramID//revadmin TelegramID 管理 Bot 管理员;/backup_db 备份数据库;/restore_from_db 文件名 confirm 恢复数据库。\n" +
- "说明:重复别名如 /low_activity、/urm 仍可兼容识别,但不显示在命令栏。"
-}
diff --git a/internal/service/telegram_bot_user_test.go b/internal/service/telegram_bot_user_test.go
deleted file mode 100644
index d506f41..0000000
--- a/internal/service/telegram_bot_user_test.go
+++ /dev/null
@@ -1,286 +0,0 @@
-package service
-
-import (
- "encoding/json"
- "strings"
- "testing"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func TestTelegramUpdateActionableDispatchesCallbackQuery(t *testing.T) {
- if !telegramUpdateActionable(TelegramUpdate{CallbackQuery: &TelegramCallbackQuery{Data: "adult_toggle"}}) {
- t.Fatal("callback_query update must be dispatched, otherwise inline buttons break")
- }
- if !telegramUpdateActionable(TelegramUpdate{Message: &TelegramMessage{Text: "/help"}}) {
- t.Fatal("text command message must be dispatched")
- }
- if telegramUpdateActionable(TelegramUpdate{}) {
- t.Fatal("empty update must be skipped")
- }
- if telegramUpdateActionable(TelegramUpdate{Message: &TelegramMessage{}}) {
- t.Fatal("message without text must be skipped")
- }
-}
-
-func TestTelegramCallbackTogglesAdultVisibility(t *testing.T) {
- ctx := t.Context()
- repos, auth, _, _ := newAuthTestServices(t)
- user, _, err := auth.Register(ctx, "viewer", "secret-pass")
- if err != nil {
- t.Fatalf("register user: %v", err)
- }
- if err := repos.DB.Create(&model.TelegramBinding{
- TelegramUserID: 30001,
- TelegramName: "@viewer",
- ChatID: 30001,
- UserID: user.ID,
- }).Error; err != nil {
- t.Fatalf("create binding: %v", err)
- }
- if err := repos.DB.AutoMigrate(&model.NotifyChannel{}); err != nil {
- t.Fatalf("migrate notify_channels: %v", err)
- }
- // 配置一个绑定该 Telegram 用户的渠道(无 bot_token,避免测试触发网络请求)。
- cfg, _ := json.Marshal(map[string]string{"admin_user_ids": "30001"})
- if err := repos.DB.Create(&model.NotifyChannel{
- Name: "Telegram",
- Type: "telegram",
- Enabled: true,
- Config: string(cfg),
- }).Error; err != nil {
- t.Fatalf("create channel: %v", err)
- }
-
- before, err := repos.User.FindByID(ctx, user.ID)
- if err != nil || before == nil {
- t.Fatalf("load user before toggle: %v", err)
- }
-
- bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth)
- update, _ := json.Marshal(TelegramUpdate{
- UpdateID: 1,
- CallbackQuery: &TelegramCallbackQuery{
- ID: "cb1",
- From: TelegramUser{ID: 30001, Username: "viewer", FirstName: "Viewer"},
- Message: &TelegramMessage{MessageID: 5, Chat: TelegramChat{ID: 30001, Type: "private"}},
- Data: "adult_toggle",
- },
- })
- // reply 因 bot_token 为空会返回错误,但成人目录状态应已在数据库中被切换。
- _ = bot.HandleWebhook(ctx, update)
-
- updated, err := repos.User.FindByID(ctx, user.ID)
- if err != nil || updated == nil {
- t.Fatalf("reload user: %v", err)
- }
- if updated.HideAdult == before.HideAdult {
- t.Fatalf("adult_toggle callback should have flipped HideAdult (was %v)", before.HideAdult)
- }
-}
-
-func TestTelegramRegisterRespectsAdminToggle(t *testing.T) {
- ctx := t.Context()
- repos, auth, _, _ := newAuthTestServices(t)
- if err := repos.DB.AutoMigrate(&model.Setting{}, &model.NotifyChannel{}); err != nil {
- t.Fatalf("migrate: %v", err)
- }
- // 预置一个管理员,确保通过 Bot 注册的用户是普通角色而非首个管理员。
- if _, _, err := auth.Register(ctx, "rootadmin", "admin-pass"); err != nil {
- t.Fatalf("seed admin: %v", err)
- }
-
- bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth)
- // 把注册者放进 admin_user_ids,即可让 telegramUserCanBind 通过(私聊场景,
- // 无需走 getChatMember 网络校验);注册流程本身不依赖角色。
- cfgJSON, _ := json.Marshal(map[string]string{"admin_user_ids": "999"})
- channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfgJSON)}
-
- msg := &TelegramMessage{From: TelegramUser{ID: 999, Username: "newbie", FirstName: "Newbie"}, Chat: TelegramChat{ID: 999, Type: "private"}}
-
- // 默认关闭:拒绝且不创建用户。
- if reply := bot.cmdRegister(ctx, channel, msg, []string{"newbie", "secret-pass"}); !strings.Contains(reply.Text, "未开放") {
- t.Fatalf("registration disabled by default, got %q", reply.Text)
- }
- if u, _ := repos.User.FindByUsername(ctx, "newbie"); u != nil {
- t.Fatal("no user should be created while registration disabled")
- }
-
- // 管理员开启后注册成功并自动绑定。
- if err := bot.setRegistrationEnabled(ctx, true); err != nil {
- t.Fatalf("enable registration: %v", err)
- }
- reply := bot.cmdRegister(ctx, channel, msg, []string{"newbie", "secret-pass"})
- if !strings.Contains(reply.Text, "注册并绑定成功") {
- t.Fatalf("expected success reply, got %q", reply.Text)
- }
- created, err := repos.User.FindByUsername(ctx, "newbie")
- if err != nil || created == nil {
- t.Fatalf("user should be created after enabling: %v", err)
- }
- if created.Role != "user" {
- t.Fatalf("bot-registered account should be a regular user, got role %q", created.Role)
- }
- if binding := bot.telegramBinding(ctx, 999); binding == nil || binding.UserID != created.ID {
- t.Fatalf("telegram should be bound to the newly registered user")
- }
-
- // 重复注册:已绑定 → 提示无需重复注册。
- if reply := bot.cmdRegister(ctx, channel, msg, []string{"another", "pass-2"}); !strings.Contains(reply.Text, "无需重复注册") {
- t.Fatalf("expected already-bound reply, got %q", reply.Text)
- }
-}
-
-func TestTelegramGroupHidesAdminPanelFromRegularUsers(t *testing.T) {
- ctx := t.Context()
- _, bot := newBotTestService(t)
- channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"group_chat_id":"-100123","admin_user_ids":"9001"}`}
- msg := &TelegramMessage{
- From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"},
- Chat: TelegramChat{ID: -100123, Type: "supergroup"},
- }
-
- menu := bot.mainMenu(ctx, channel, msg)
- if strings.Contains(menu.Text, "管理员") || telegramReplyHasButtonPrefix(menu, "adm_") {
- t.Fatalf("regular group user must not see admin panel: text=%q buttons=%#v", menu.Text, menu.Buttons)
- }
-
- reply, err := bot.executeCommand(ctx, channel, msg, "/users")
- if err != nil {
- t.Fatal(err)
- }
- if reply.Text != "" || len(reply.Buttons) != 0 {
- t.Fatalf("regular group user admin command should be ignored, got %#v", reply)
- }
-
- reply, err = bot.executeCommand(ctx, channel, msg, "/start viewer secret-pass")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "请私聊 Bot") {
- t.Fatalf("group credential command should point to private chat, got %q", reply.Text)
- }
-}
-
-func TestTelegramGroupAdminMenuExposesButtonsOnlyToAdmins(t *testing.T) {
- ctx := t.Context()
- repos, bot := newBotTestService(t)
- admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}
- if err := repos.User.Create(ctx, admin); err != nil {
- t.Fatal(err)
- }
- channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"group_chat_id":"-100123","admin_user_ids":"9001"}`}
- msg := &TelegramMessage{
- From: TelegramUser{ID: 9001, Username: "admin", FirstName: "Admin"},
- Chat: TelegramChat{ID: -100123, Type: "group"},
- }
-
- menu := bot.mainMenu(ctx, channel, msg)
- if !telegramReplyHasButtonPrefix(menu, "adm_") {
- t.Fatalf("admin group menu should expose admin buttons, got %#v", menu.Buttons)
- }
- if !strings.Contains(menu.Text, "管理员入口") {
- t.Fatalf("admin group menu should label admin section, got %q", menu.Text)
- }
-
- reply, handled := bot.handleMenuCallback(ctx, channel, msg, "adm_users")
- if !handled {
- t.Fatal("admin callback should be handled")
- }
- if !strings.Contains(reply.Text, "用户管理") {
- t.Fatalf("group admin callback should render admin panel, got %#v", reply)
- }
-
- normal := &TelegramMessage{
- From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"},
- Chat: TelegramChat{ID: -100123, Type: "group"},
- }
- normalMenu := bot.mainMenu(ctx, channel, normal)
- if telegramReplyHasButtonPrefix(normalMenu, "adm_") || strings.Contains(normalMenu.Text, "管理员入口") {
- t.Fatalf("normal group user must not see admin controls: %#v", normalMenu)
- }
- normalReply, handled := bot.handleMenuCallback(ctx, channel, normal, "adm_users")
- if !handled || normalReply.Text != "" || len(normalReply.Buttons) != 0 {
- t.Fatalf("normal group user must not use admin callbacks: %#v handled=%v", normalReply, handled)
- }
-
- reply, err := bot.executeCommand(ctx, channel, msg, "/users")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "用户管理") {
- t.Fatalf("bound group admin text command should run, got %q", reply.Text)
- }
- if len(reply.Buttons) == 0 {
- t.Fatalf("group admin text command should expose admin action buttons: %#v", reply.Buttons)
- }
-}
-
-func TestTelegramPollingChannelHintWinsForPrivateMessages(t *testing.T) {
- ctx := t.Context()
- repos, bot := newBotTestService(t)
- msg := &TelegramMessage{
- From: TelegramUser{ID: 9101, Username: "viewer", FirstName: "Viewer"},
- Chat: TelegramChat{ID: 9101, Type: "private"},
- }
- bad := model.NotifyChannel{Name: "BadToken", Type: "telegram", Enabled: true, Config: `{"bot_token":"bad","admin_user_ids":"9101"}`}
- good := model.NotifyChannel{Name: "GoodToken", Type: "telegram", Enabled: true, Config: `{"bot_token":"good","admin_user_ids":"9101"}`}
- if err := repos.DB.Create(&bad).Error; err != nil {
- t.Fatal(err)
- }
- if err := repos.DB.Create(&good).Error; err != nil {
- t.Fatal(err)
- }
-
- if first := bot.findChannelForMessage(ctx, msg); first == nil || first.ID != bad.ID {
- t.Fatalf("setup expected normal private lookup to pick first channel, got %#v", first)
- }
- if hinted := bot.channelForMessage(ctx, msg, &good); hinted == nil || hinted.ID != good.ID {
- t.Fatalf("polling channel hint should route replies through the token that received the update, got %#v", hinted)
- }
-}
-
-func TestTelegramMgoCompatibleUserCommands(t *testing.T) {
- ctx := t.Context()
- repos, bot := newBotTestService(t)
- user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user", IsActive: true}
- if err := repos.User.Create(ctx, user); err != nil {
- t.Fatal(err)
- }
- if err := repos.DB.Create(&model.TelegramBinding{TelegramUserID: 9102, ChatID: 9102, UserID: user.ID}).Error; err != nil {
- t.Fatal(err)
- }
- channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9102"}`}
- msg := &TelegramMessage{
- From: TelegramUser{ID: 9102, Username: "viewer", FirstName: "Viewer"},
- Chat: TelegramChat{ID: 9102, Type: "private"},
- }
-
- info, err := bot.executeCommand(ctx, channel, msg, "/myinfo")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(info.Text, "我的账号") {
- t.Fatalf("/myinfo should show account info, got %q", info.Text)
- }
- count, err := bot.executeCommand(ctx, channel, msg, "/count")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(count.Text, "媒体库统计") {
- t.Fatalf("/count should show library counts, got %q", count.Text)
- }
-}
-
-func telegramReplyHasButtonPrefix(reply telegramCommandReply, prefix string) bool {
- for _, row := range reply.Buttons {
- for _, button := range row {
- if strings.HasPrefix(button.Data, prefix) {
- return true
- }
- }
- }
- return false
-}
diff --git a/internal/service/telegram_callback.go b/internal/service/telegram_callback.go
deleted file mode 100644
index 08816ae..0000000
--- a/internal/service/telegram_callback.go
+++ /dev/null
@@ -1,77 +0,0 @@
-package service
-
-import (
- "context"
- "strings"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *TelegramBotService) handleCallback(ctx context.Context, cb *TelegramCallbackQuery, channelHint *model.NotifyChannel) error {
- if cb == nil || cb.Message == nil {
- return nil
- }
- msg := *cb.Message
- msg.From = cb.From
- channel := s.channelForMessage(ctx, &msg, channelHint)
- if channel == nil {
- channel = s.findChannelByChatID(ctx, cb.Message.Chat.ID)
- }
- // 立即应答回调,关闭按钮上的加载状态,避免客户端长时间转圈。
- if telegramIsGroupChat(cb.Message.Chat.Type) {
- s.answerCallbackWithText(ctx, channel, cb.ID, "为了隐私,群组内按钮面板已禁用。请私聊 Bot 或在群里发送 /menu,我会把面板私聊给你。", true)
- s.deleteTelegramSourceMessage(channel, cb.Message.Chat.ID, cb.Message.MessageID)
- return nil
- }
- if cb.Message.Chat.Type == "private" && cb.Message.Chat.ID != cb.From.ID {
- s.answerCallbackWithText(ctx, channel, cb.ID, "这个面板不属于你,请发送 /menu 打开自己的面板。", true)
- return nil
- }
- s.answerCallback(ctx, channel, cb.ID)
- data := strings.TrimSpace(cb.Data)
- if data == "adult_toggle" {
- reply := s.cmdHideAdult(ctx, &msg, nil)
- if reply.Text != "" {
- err := s.reply(ctx, channel, cb.Message.Chat.ID, reply)
- s.deleteTelegramSourceMessage(channel, cb.Message.Chat.ID, cb.Message.MessageID)
- return err
- }
- return nil
- }
- if reply, handled := s.handleMenuCallback(ctx, channel, &msg, data); handled {
- if reply.Text != "" {
- err := s.reply(ctx, channel, cb.Message.Chat.ID, reply)
- s.deleteTelegramSourceMessage(channel, cb.Message.Chat.ID, cb.Message.MessageID)
- return err
- }
- }
- return nil
-}
-
-// answerCallback 应答 Telegram 回调查询,关闭按钮上的加载提示。
-func (s *TelegramBotService) answerCallback(ctx context.Context, channel *model.NotifyChannel, callbackID string) {
- s.answerCallbackWithText(ctx, channel, callbackID, "", false)
-}
-
-func (s *TelegramBotService) answerCallbackWithText(ctx context.Context, channel *model.NotifyChannel, callbackID, text string, showAlert bool) {
- if channel == nil || strings.TrimSpace(callbackID) == "" {
- return
- }
- cfg := s.telegramChannelConfig(channel)
- if strings.TrimSpace(cfg["bot_token"]) == "" {
- return
- }
- payload := map[string]interface{}{
- "callback_query_id": callbackID,
- }
- if strings.TrimSpace(text) != "" {
- payload["text"] = text
- payload["show_alert"] = showAlert
- }
- if err := telegramPostJSON(ctx, cfg, "answerCallbackQuery", payload, 8*time.Second); err != nil {
- s.log.Debug("telegram answerCallbackQuery failed", zap.Error(sanitizeTelegramError(err)))
- }
-}
diff --git a/internal/service/telegram_channel_resolve.go b/internal/service/telegram_channel_resolve.go
deleted file mode 100644
index af30068..0000000
--- a/internal/service/telegram_channel_resolve.go
+++ /dev/null
@@ -1,160 +0,0 @@
-package service
-
-import (
- "context"
- "encoding/json"
- "strconv"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-// findChannelByChatID 根据 chat_id 查找已配置的通知渠道。
-func (s *TelegramBotService) findChannelByChatID(ctx context.Context, chatID int) *model.NotifyChannel {
- channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram")
- if err != nil {
- return nil
- }
- target := strconv.Itoa(chatID)
- for _, ch := range channels {
- if !ch.Enabled {
- continue
- }
- configStr := ch.Config
- if s.crypto != nil && configStr != "" {
- configStr = s.crypto.Decrypt(configStr)
- }
- var cfg map[string]string
- if err := json.Unmarshal([]byte(configStr), &cfg); err != nil {
- continue
- }
- if cfg["chat_id"] == target || cfg["command_chat_id"] == target ||
- cfg["group_chat_id"] == target || cfg["channel_chat_id"] == target {
- return &ch
- }
- }
- if len(channels) == 1 && channels[0].Enabled {
- return &channels[0]
- }
- return nil
-}
-
-func (s *TelegramBotService) findChannelForMessage(ctx context.Context, msg *TelegramMessage) *model.NotifyChannel {
- if msg == nil {
- return nil
- }
- if msg.Chat.Type != "" && msg.Chat.Type != "private" {
- return s.findChannelByChatID(ctx, msg.Chat.ID)
- }
- channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram")
- if err != nil {
- return nil
- }
- var first *model.NotifyChannel
- for i := range channels {
- ch := channels[i]
- if !ch.Enabled {
- continue
- }
- if first == nil {
- first = &ch
- }
- if s.telegramUserIsAdmin(ctx, &ch, msg.From.ID) || s.telegramUserCanBind(ctx, &ch, msg.From.ID) {
- return &ch
- }
- }
- return first
-}
-
-func (s *TelegramBotService) channelForMessage(ctx context.Context, msg *TelegramMessage, hint *model.NotifyChannel) *model.NotifyChannel {
- if hint == nil {
- return s.findChannelForMessage(ctx, msg)
- }
- if msg == nil {
- return hint
- }
- if msg.Chat.Type != "" && msg.Chat.Type != "private" && !s.telegramChatAllowed(hint, msg.Chat.ID) {
- return nil
- }
- return hint
-}
-
-func (s *TelegramBotService) telegramChatAllowed(channel *model.NotifyChannel, chatID int) bool {
- if channel == nil {
- return false
- }
- configStr := channel.Config
- if s.crypto != nil && configStr != "" {
- configStr = s.crypto.Decrypt(configStr)
- }
- var cfg map[string]string
- if err := json.Unmarshal([]byte(configStr), &cfg); err != nil {
- return false
- }
- target := strconv.Itoa(chatID)
- for _, key := range []string{"group_chat_id", "channel_chat_id", "command_chat_id"} {
- if configured := strings.TrimSpace(cfg[key]); configured != "" && configured == target {
- return true
- }
- }
- if strings.TrimSpace(cfg["group_chat_id"]) != "" || strings.TrimSpace(cfg["channel_chat_id"]) != "" || strings.TrimSpace(cfg["command_chat_id"]) != "" {
- return false
- }
- return strings.TrimSpace(cfg["chat_id"]) == target
-}
-
-func (s *TelegramBotService) telegramUserIDConfigured(channel *model.NotifyChannel, telegramUserID int) bool {
- if channel == nil || telegramUserID == 0 {
- return false
- }
- cfg := s.telegramChannelConfig(channel)
- target := strconv.Itoa(telegramUserID)
- for _, value := range telegramConfiguredUserIDs(cfg["admin_user_ids"]) {
- if value == target {
- return true
- }
- }
- if strings.TrimSpace(cfg["admin_user_ids"]) == "" && strings.TrimSpace(cfg["chat_id"]) == target {
- return true
- }
- return false
-}
-
-func (s *TelegramBotService) telegramChannelConfig(channel *model.NotifyChannel) map[string]string {
- return telegramConfigFromChannel(s.crypto, channel)
-}
-
-func telegramConfigFromChannel(crypto *CryptoService, channel *model.NotifyChannel) map[string]string {
- if channel == nil {
- return map[string]string{}
- }
- configStr := channel.Config
- if crypto != nil && configStr != "" {
- configStr = crypto.Decrypt(configStr)
- }
- var cfg map[string]string
- if err := json.Unmarshal([]byte(configStr), &cfg); err != nil || cfg == nil {
- return map[string]string{}
- }
- normalizeTelegramConfig(cfg)
- return cfg
-}
-
-func normalizeTelegramConfig(cfg map[string]string) {
- if cfg == nil {
- return
- }
- chatID := strings.TrimSpace(cfg["chat_id"])
- if chatID == "" {
- return
- }
- if strings.HasPrefix(chatID, "-") {
- if strings.TrimSpace(cfg["group_chat_id"]) == "" && strings.TrimSpace(cfg["channel_chat_id"]) == "" && strings.TrimSpace(cfg["command_chat_id"]) == "" {
- cfg["group_chat_id"] = chatID
- }
- return
- }
- if strings.TrimSpace(cfg["admin_user_ids"]) == "" {
- cfg["admin_user_ids"] = chatID
- }
-}
diff --git a/internal/service/telegram_cleanup_rules.go b/internal/service/telegram_cleanup_rules.go
deleted file mode 100644
index 6103b83..0000000
--- a/internal/service/telegram_cleanup_rules.go
+++ /dev/null
@@ -1,220 +0,0 @@
-package service
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "strconv"
- "strings"
-)
-
-func (s *TelegramBotService) currentCleanupRules(ctx context.Context) []accountCleanupRule {
- cfg := loadBotConfig(ctx, s.repo)
- return cfg.AccountCleanupRules
-}
-
-func (s *TelegramBotService) saveCleanupRules(ctx context.Context, rules []accountCleanupRule) error {
- raw, err := json.Marshal(normalizeCleanupRules(rules))
- if err != nil {
- return err
- }
- return s.repo.Setting.Set(ctx, SettingAccountCleanupRules, string(raw))
-}
-
-func parseCommandBool(value string) (bool, bool) {
- switch strings.ToLower(strings.TrimSpace(value)) {
- case "on", "true", "1", "yes", "enable", "enabled", "开启", "开":
- return true, true
- case "off", "false", "0", "no", "disable", "disabled", "关闭", "关":
- return false, true
- default:
- return false, false
- }
-}
-
-func parseCleanupRuleCommand(args []string) (accountCleanupRule, error) {
- if len(args) < 2 {
- return accountCleanupRule{}, fmt.Errorf("新增规则参数不足")
- }
- rule := accountCleanupRule{
- Type: strings.ToLower(strings.TrimSpace(args[0])),
- ID: strings.TrimSpace(args[1]),
- Enabled: true,
- WindowDaysMin: 3,
- WindowDaysMax: 5,
- MinHours: 6,
- MinCount: 1,
- }
- switch rule.Type {
- case "watch_hours":
- name, values := cleanupRuleNameAndValues(args[2:], 3)
- rule.Name = name
- if len(values) >= 3 {
- rule.WindowDaysMin, _ = strconv.Atoi(values[0])
- rule.WindowDaysMax, _ = strconv.Atoi(values[1])
- rule.MinHours, _ = strconv.ParseFloat(values[2], 64)
- if rule.Name == "" {
- rule.Name = fmt.Sprintf("%d~%d 天观看满 %s 小时", rule.WindowDaysMin, rule.WindowDaysMax, formatRuleHours(rule.MinHours))
- }
- }
- case "recent_login":
- name, values := cleanupRuleNameAndValues(args[2:], 1)
- rule.Name = name
- if len(values) >= 1 {
- rule.WindowDaysMax, _ = strconv.Atoi(values[0])
- if rule.Name == "" {
- rule.Name = fmt.Sprintf("%d 天内登录", rule.WindowDaysMax)
- }
- }
- case "signin_streak", "account_age_grace":
- name, values := cleanupRuleNameAndValues(args[2:], 1)
- rule.Name = name
- if len(values) >= 1 {
- rule.MinCount, _ = strconv.Atoi(values[0])
- if rule.Name == "" {
- if rule.Type == "signin_streak" {
- rule.Name = fmt.Sprintf("连续签到 %d 天", rule.MinCount)
- } else {
- rule.Name = fmt.Sprintf("新号宽限 %d 天", rule.MinCount)
- }
- }
- }
- default:
- return accountCleanupRule{}, fmt.Errorf("不支持的规则类型:%s", rule.Type)
- }
- normalized := normalizeCleanupRules([]accountCleanupRule{rule})
- if len(normalized) == 0 {
- return accountCleanupRule{}, fmt.Errorf("规则无效")
- }
- return normalized[0], nil
-}
-
-func cleanupRuleNameAndValues(args []string, numericCount int) (string, []string) {
- if len(args) == 0 {
- return "", nil
- }
- if len(args) >= numericCount && cleanupRuleValuesAreNumeric(args[:numericCount]) {
- return "", args
- }
- return strings.TrimSpace(args[0]), args[1:]
-}
-
-func cleanupRuleValuesAreNumeric(values []string) bool {
- for _, value := range values {
- if _, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err != nil {
- return false
- }
- }
- return true
-}
-
-func formatCleanupRules(rules []accountCleanupRule) string {
- if len(rules) == 0 {
- return "保号规则\n\n暂无规则。"
- }
- var sb strings.Builder
- sb.WriteString("保号规则\n")
- for i, r := range rules {
- state := map[bool]string{true: "启用", false: "停用"}[r.Enabled]
- detail := cleanupRuleDetail(r)
- parts := []string{
- fmt.Sprintf("\n%d. %s", i+1, r.ID),
- }
- if shouldShowCleanupRuleName(r, detail) {
- parts = append(parts, r.Name)
- }
- parts = append(parts, cleanupRuleTypeLabel(r.Type), state)
- if detail != "" {
- parts = append(parts, detail)
- }
- sb.WriteString(strings.Join(parts, " · "))
- }
- return sb.String()
-}
-
-func shouldShowCleanupRuleName(r accountCleanupRule, detail string) bool {
- name := strings.TrimSpace(r.Name)
- if name == "" || strings.EqualFold(name, r.ID) {
- return false
- }
- if detail != "" && strings.EqualFold(name, detail) {
- return false
- }
- return true
-}
-
-func cleanupRuleDetail(r accountCleanupRule) string {
- switch r.Type {
- case "watch_hours":
- return fmt.Sprintf("%d~%d 天 %s 小时", r.WindowDaysMin, r.WindowDaysMax, formatRuleHours(r.MinHours))
- case "recent_login":
- return fmt.Sprintf("%d 天内登录", r.WindowDaysMax)
- case "signin_streak":
- return fmt.Sprintf("连续签到 %d 天", r.MinCount)
- case "account_age_grace":
- return fmt.Sprintf("新号宽限 %d 天", r.MinCount)
- default:
- return ""
- }
-}
-
-func formatRuleHours(hours float64) string {
- if hours == float64(int(hours)) {
- return strconv.Itoa(int(hours))
- }
- return fmt.Sprintf("%.1f", hours)
-}
-
-func cleanupRuleTypeLabel(t string) string {
- switch t {
- case "watch_hours":
- return "观看时长"
- case "recent_login":
- return "最近登录"
- case "signin_streak":
- return "连续签到"
- case "account_age_grace":
- return "新号宽限"
- default:
- return t
- }
-}
-
-func cleanupRuleHelp() string {
- return "Mgo 保号规则命令\n\n" +
- "/cleanup_rule list — 查看规则\n" +
- "/cleanup_rule add watch_hours watch_3_5d_6h 观看3到5天满6小时 3 5 6\n" +
- "/cleanup_rule add recent_login login_7d 七天内登录 7\n" +
- "/cleanup_rule add signin_streak sign_3 连续签到3天 3\n" +
- "/cleanup_rule add account_age_grace new_7d 新号宽限7天 7\n" +
- "/cleanup_rule edit 规则类型 规则ID 名称 参数... — 修改同 ID 规则\n" +
- "/cleanup_rule 修改 规则类型 规则ID 名称 参数... — 中文修改入口\n" +
- "/cleanup_rule enable 规则ID / disable 规则ID\n" +
- "/cleanup_rule del 规则ID\n\n" +
- "保号模式固定为:满足任意一条启用规则即保留;全部不满足才会清理。"
-}
-
-func onOff(b bool) string {
- return map[bool]string{true: "已开启", false: "已关闭"}[b]
-}
-
-func toggleLabel(name string, enabled bool) string {
- if enabled {
- return "关闭" + name
- }
- return "开启" + name
-}
-
-func cleanupModeLabel(mode string) string {
- return "满足任意一条"
-}
-
-func countEnabledCleanupRules(rules []accountCleanupRule) int {
- n := 0
- for _, r := range rules {
- if r.Enabled {
- n++
- }
- }
- return n
-}
diff --git a/internal/service/telegram_cleanup_rules_test.go b/internal/service/telegram_cleanup_rules_test.go
deleted file mode 100644
index 47f5fdf..0000000
--- a/internal/service/telegram_cleanup_rules_test.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package service
-
-import (
- "strings"
- "testing"
-)
-
-func TestParseCleanupRuleCommandWithNamedWatchHours(t *testing.T) {
- rule, err := parseCleanupRuleCommand([]string{"watch_hours", "watch_3_5d_6h", "观看3到5天满6小时", "3", "5", "6"})
- if err != nil {
- t.Fatal(err)
- }
- if rule.Type != "watch_hours" || rule.ID != "watch_3_5d_6h" || rule.Name != "观看3到5天满6小时" {
- t.Fatalf("unexpected rule identity: %+v", rule)
- }
- if !rule.Enabled || rule.WindowDaysMin != 3 || rule.WindowDaysMax != 5 || rule.MinHours != 6 {
- t.Fatalf("unexpected watch-hours rule values: %+v", rule)
- }
-}
-
-func TestFormatCleanupRulesShowsUsefulDetails(t *testing.T) {
- text := formatCleanupRules([]accountCleanupRule{{
- ID: "login_7d",
- Type: "recent_login",
- Name: "七天内登录",
- Enabled: true,
- WindowDaysMax: 7,
- }})
- for _, want := range []string{"保号规则", "login_7d", "七天内登录", "最近登录", "启用"} {
- if !strings.Contains(text, want) {
- t.Fatalf("formatCleanupRules() missing %q in %q", want, text)
- }
- }
-}
diff --git a/internal/service/telegram_command_menus.go b/internal/service/telegram_command_menus.go
deleted file mode 100644
index e7aefbc..0000000
--- a/internal/service/telegram_command_menus.go
+++ /dev/null
@@ -1,158 +0,0 @@
-package service
-
-import (
- "context"
- "strings"
- "time"
-)
-
-func telegramSupportedCommand(cmd string) bool {
- cmd = telegramCommandName(cmd)
- if cmd == "" {
- return false
- }
- _, ok := telegramSupportedCommandSet[cmd]
- return ok
-}
-
-var telegramSupportedCommandSet = map[string]struct{}{
- "/start": {}, "/menu": {}, "/cancel": {}, "/help": {}, "/hideadult": {}, "/hide_adult": {}, "/adult": {},
- "/account": {}, "/me": {}, "/myinfo": {}, "/count": {}, "/signin": {}, "/checkin": {}, "/devices": {}, "/kick": {}, "/setname": {}, "/rename": {}, "/setpass": {}, "/passwd": {}, "/password": {},
- "/redeem": {}, "/redeem_register": {}, "/redeem_renew": {},
- "/register": {}, "/reg": {}, "/signup": {}, "/registration": {}, "/reg_switch": {}, "/openreg": {},
- "/capacity": {}, "/users": {}, "/kk": {}, "/gencode": {}, "/renew_user": {}, "/delete_user": {}, "/unbind": {}, "/unbind_duplicates": {}, "/unbind_inactive": {},
- "/devicepolicy": {}, "/policy": {}, "/antishare": {}, "/cleanup": {}, "/cleanup_mode": {}, "/cleanup_rule": {},
- "/ban": {}, "/unban": {}, "/status": {}, "/search": {}, "/downloads": {}, "/stats": {},
- "/renew": {}, "/ucr": {}, "/uinfo": {}, "/rmemby": {}, "/urm": {}, "/only_rm_emby": {}, "/only_rm_record": {},
- "/userip": {}, "/udeviceid": {}, "/auditip": {}, "/auditdevice": {}, "/auditclient": {},
- "/renewall": {}, "/callall": {}, "/syncunbound": {}, "/syncgroupm": {}, "/kick_not_emby": {}, "/scan_embyname": {},
- "/check_ex": {}, "/deleted": {}, "/low_activity": {}, "/uranks": {}, "/days_ranks": {}, "/week_ranks": {},
- "/embyadmin": {}, "/unbanall": {}, "/banall": {}, "/embylibs_unblockall": {}, "/embylibs_blockall": {},
- "/extraembylibs_unblockall": {}, "/extraembylibs_blockall": {}, "/proadmin": {}, "/revadmin": {},
- "/backup_db": {}, "/restore_from_db": {}, "/prouser": {}, "/revuser": {},
-}
-
-type telegramBotCommand struct {
- Command string `json:"command"`
- Description string `json:"description"`
-}
-
-func telegramBotCommandMenu() []telegramBotCommand {
- return telegramPrivateBotCommandMenu()
-}
-
-func telegramPrivateBotCommandMenu() []telegramBotCommand {
- return []telegramBotCommand{
- {Command: "start", Description: "绑定账号或打开主菜单"},
- {Command: "menu", Description: "打开功能菜单"},
- {Command: "help", Description: "查看命令帮助"},
- {Command: "account", Description: "查看账号状态"},
- {Command: "signin", Description: "签到"},
- {Command: "devices", Description: "查看登录设备"},
- {Command: "kick", Description: "踢下线设备"},
- {Command: "setname", Description: "修改用户名(需当前密码)"},
- {Command: "setpass", Description: "修改密码(需当前密码)"},
- {Command: "hideadult", Description: "隐藏/显示成人媒体库"},
- {Command: "redeem", Description: "兑换注册码或续期码"},
- {Command: "register", Description: "注册新账号"},
- }
-}
-
-func telegramGroupBotCommandMenu() []telegramBotCommand {
- return []telegramBotCommand{
- {Command: "start", Description: "打开群组自助菜单"},
- {Command: "menu", Description: "打开群组自助菜单"},
- {Command: "help", Description: "查看群组可用命令"},
- {Command: "account", Description: "查看账号状态"},
- {Command: "signin", Description: "签到"},
- {Command: "devices", Description: "查看登录设备"},
- {Command: "kick", Description: "踢下线设备"},
- {Command: "hideadult", Description: "隐藏/显示成人媒体库"},
- }
-}
-
-func telegramAdminBotCommandMenu() []telegramBotCommand {
- commands := append([]telegramBotCommand{}, telegramPrivateBotCommandMenu()...)
- commands = append(commands,
- telegramBotCommand{Command: "status", Description: "系统运行状态(管理员)"},
- telegramBotCommand{Command: "search", Description: "搜索媒体库(管理员)"},
- telegramBotCommand{Command: "downloads", Description: "下载列表(管理员)"},
- telegramBotCommand{Command: "stats", Description: "媒体库统计(管理员)"},
- telegramBotCommand{Command: "users", Description: "用户管理(管理员)"},
- telegramBotCommand{Command: "cleanup", Description: "保号清理预览/确认(管理员)"},
- telegramBotCommand{Command: "cleanup_mode", Description: "查看固定保号模式(管理员)"},
- telegramBotCommand{Command: "cleanup_rule", Description: "Mgo保号规则管理(管理员)"},
- )
- commands = append(commands, telegramMgoAdminBotCommandMenu()...)
- return commands
-}
-
-func telegramMgoAdminBotCommandMenu() []telegramBotCommand {
- return []telegramBotCommand{
- // 用户管理:保留 Sakura/Mgo 常用命令名,剔除 /urm、/only_rm_emby 等重复别名。
- {Command: "ucr", Description: "Mgo用户: 创建账号"},
- {Command: "uinfo", Description: "Mgo用户: 查询账号"},
- {Command: "rmemby", Description: "Mgo用户: 删除账号"},
- {Command: "only_rm_record", Description: "Mgo用户: 仅删Bot绑定"},
- {Command: "renewall", Description: "Mgo用户: 批量续期"},
-
- // 审计:按 IP、设备指纹、客户端和 Telegram 绑定信息排查共享。
- {Command: "userip", Description: "Mgo审计: 查询用户IP"},
- {Command: "auditip", Description: "Mgo审计: 按IP审计"},
- {Command: "auditdevice", Description: "Mgo审计: 按设备审计"},
- {Command: "auditclient", Description: "Mgo审计: 按客户端审计"},
- {Command: "udeviceid", Description: "Mgo审计: 按设备ID审计"},
-
- // 清理:/low_activity 是 /deleted 的兼容别名,不显示在命令栏。
- {Command: "syncunbound", Description: "Mgo清理: 未绑定账号"},
- {Command: "syncgroupm", Description: "Mgo清理: 校验群成员"},
- {Command: "check_ex", Description: "Mgo清理: 检查过期账号"},
- {Command: "deleted", Description: "Mgo清理: 保号清理预览"},
-
- // 权限:批量禁用、保护用户、媒体库播放权限。
- {Command: "embyadmin", Description: "Mgo权限: 设置管理员"},
- {Command: "banall", Description: "Mgo权限: 批量禁用用户"},
- {Command: "unbanall", Description: "Mgo权限: 批量解禁用户"},
- {Command: "prouser", Description: "Mgo权限: 加入保护名单"},
- {Command: "revuser", Description: "Mgo权限: 移出保护名单"},
- {Command: "embylibs_blockall", Description: "Mgo权限: 批量禁用媒体库"},
- {Command: "embylibs_unblockall", Description: "Mgo权限: 批量开放媒体库"},
-
- // 运维:Bot 管理员与数据库备份恢复。
- {Command: "proadmin", Description: "Mgo运维: 添加Bot管理员"},
- {Command: "revadmin", Description: "Mgo运维: 移除Bot管理员"},
- {Command: "backup_db", Description: "Mgo运维: 备份数据库"},
- {Command: "restore_from_db", Description: "Mgo运维: 恢复数据库"},
- }
-}
-
-func registerTelegramBotCommands(ctx context.Context, cfg map[string]string) error {
- if strings.TrimSpace(cfg["bot_token"]) == "" {
- return nil
- }
- normalizeTelegramConfig(cfg)
- if err := telegramSetBotCommands(ctx, cfg, telegramPrivateBotCommandMenu(), nil); err != nil {
- return err
- }
- if err := telegramSetBotCommands(ctx, cfg, telegramPrivateBotCommandMenu(), map[string]interface{}{"type": "all_private_chats"}); err != nil {
- return err
- }
- if err := telegramSetBotCommands(ctx, cfg, telegramGroupBotCommandMenu(), map[string]interface{}{"type": "all_group_chats"}); err != nil {
- return err
- }
- _ = telegramSetBotCommands(ctx, cfg, telegramAdminBotCommandMenu(), map[string]interface{}{"type": "all_chat_administrators"})
-
- adminCommands := telegramAdminBotCommandMenu()
- for _, adminID := range telegramConfiguredUserIDs(cfg["admin_user_ids"]) {
- _ = telegramSetBotCommands(ctx, cfg, adminCommands, map[string]interface{}{"type": "chat", "chat_id": adminID})
- }
- return nil
-}
-
-func telegramSetBotCommands(ctx context.Context, cfg map[string]string, commands []telegramBotCommand, scope map[string]interface{}) error {
- payload := map[string]interface{}{"commands": commands}
- if scope != nil {
- payload["scope"] = scope
- }
- return telegramPostJSON(ctx, cfg, "setMyCommands", payload, 15*time.Second)
-}
diff --git a/internal/service/telegram_commands.go b/internal/service/telegram_commands.go
deleted file mode 100644
index 6bdb682..0000000
--- a/internal/service/telegram_commands.go
+++ /dev/null
@@ -1,224 +0,0 @@
-// Package service — Telegram command registry and dispatch.
-package service
-
-import (
- "context"
- "fmt"
- "strings"
- "time"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-type telegramCommandHandler func(args []string) (telegramCommandReply, error)
-
-type telegramCommandDefinition struct {
- Aliases []string
- AdminOnly bool
- AdminOnlyText string
- GroupAllowed bool
- Handle telegramCommandHandler
-}
-
-func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) []telegramCommandDefinition {
- adminOnly := "此命令仅管理员可用。"
- defs := s.telegramCoreCommandDefinitions(ctx, channel, msg)
- defs = append(defs, s.telegramSelfServiceCommandDefinitions(ctx, channel, msg)...)
- defs = append(defs, s.telegramAdminCoreCommandDefinitions(ctx, msg, adminOnly)...)
- defs = append(defs, s.telegramMgoUserCommandDefinitions(ctx, adminOnly)...)
- defs = append(defs, s.telegramMgoAuditCommandDefinitions(ctx, adminOnly)...)
- defs = append(defs, s.telegramMgoMaintenanceCommandDefinitions(ctx, channel, adminOnly)...)
- defs = append(defs, s.telegramMgoPolicyCommandDefinitions(ctx, channel, adminOnly)...)
- return defs
-}
-
-func (s *TelegramBotService) telegramCoreCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) []telegramCommandDefinition {
- return []telegramCommandDefinition{
- {Aliases: []string{"/start"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) {
- if len(args) == 0 {
- return s.mainMenu(ctx, channel, msg), nil
- }
- return s.cmdStart(ctx, msg, args), nil
- }},
- {Aliases: []string{"/menu"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) {
- return s.mainMenu(ctx, channel, msg), nil
- }},
- {Aliases: []string{"/cancel"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) {
- s.takePending(int64(msg.From.ID))
- return telegramCommandReply{Text: "已取消当前操作。"}, nil
- }},
- {Aliases: []string{"/help"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) {
- return telegramCommandReply{Text: s.cmdHelp(ctx, msg)}, nil
- }},
- }
-}
-
-func (s *TelegramBotService) telegramSelfServiceCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) []telegramCommandDefinition {
- return []telegramCommandDefinition{
- {Aliases: []string{"/hideadult", "/hide_adult", "/adult"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdHideAdult(ctx, msg, args), nil }},
- {Aliases: []string{"/account", "/me", "/myinfo"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.replyAccount(ctx, msg), nil }},
- {Aliases: []string{"/count"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdStats(ctx) }},
- {Aliases: []string{"/signin", "/checkin"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.replySignIn(ctx, msg), nil }},
- {Aliases: []string{"/devices"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.replyDevices(ctx, msg), nil }},
- {Aliases: []string{"/kick"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdKick(ctx, msg, args), nil }},
- {Aliases: []string{"/setname", "/rename"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSetName(ctx, msg, args), nil }},
- {Aliases: []string{"/setpass", "/passwd", "/password"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSetPass(ctx, msg, args), nil }},
- {Aliases: []string{"/redeem"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRedeem(ctx, channel, msg, args), nil }},
- {Aliases: []string{"/redeem_register"}, Handle: func(args []string) (telegramCommandReply, error) {
- return s.cmdRedeemRegister(ctx, channel, msg, args), nil
- }},
- {Aliases: []string{"/redeem_renew"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRedeemRenew(ctx, msg, args), nil }},
- {Aliases: []string{"/register", "/reg", "/signup"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRegister(ctx, channel, msg, args), nil }},
- }
-}
-
-func (s *TelegramBotService) telegramAdminCoreCommandDefinitions(ctx context.Context, msg *TelegramMessage, adminOnly string) []telegramCommandDefinition {
- return []telegramCommandDefinition{
- {Aliases: []string{"/registration", "/reg_switch", "/openreg"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRegistrationToggle(ctx, args), nil }},
- {Aliases: []string{"/capacity"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.replyCapacity(ctx), nil }},
- {Aliases: []string{"/users", "/kk"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.replyUserList(ctx), nil }},
- {Aliases: []string{"/gencode"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdGenCode(ctx, msg, args), nil }},
- {Aliases: []string{"/renew_user"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserRenew(ctx, args), nil }},
- {Aliases: []string{"/delete_user"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserDelete(ctx, args), nil }},
- {Aliases: []string{"/unbind"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUnbind(ctx, args), nil }},
- {Aliases: []string{"/unbind_duplicates"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUnbindDuplicates(ctx), nil }},
- {Aliases: []string{"/unbind_inactive"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUnbindInactive(ctx, args), nil }},
- {Aliases: []string{"/devicepolicy", "/policy"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdDevicePolicy(ctx, args), nil }},
- {Aliases: []string{"/antishare"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdAntiShare(ctx, args), nil }},
- {Aliases: []string{"/cleanup"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanup(ctx, args), nil }},
- {Aliases: []string{"/cleanup_mode"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanupMode(ctx, args), nil }},
- {Aliases: []string{"/cleanup_rule"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanupRule(ctx, args), nil }},
- {Aliases: []string{"/ban"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserBan(ctx, args, false), nil }},
- {Aliases: []string{"/unban"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserBan(ctx, args, true), nil }},
- {Aliases: []string{"/status"}, AdminOnly: true, AdminOnlyText: "此命令仅管理员可用。普通用户只能使用 /start 绑定账号,并通过按钮隐藏成人目录。", Handle: func(args []string) (telegramCommandReply, error) { return s.cmdStatus(ctx) }},
- {Aliases: []string{"/search"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSearch(ctx, args) }},
- {Aliases: []string{"/downloads"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdDownloads(ctx) }},
- {Aliases: []string{"/stats"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdStats(ctx) }},
- {Aliases: []string{"/renew"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserRenew(ctx, args), nil }},
- }
-}
-
-func (s *TelegramBotService) telegramMgoUserCommandDefinitions(ctx context.Context, adminOnly string) []telegramCommandDefinition {
- return []telegramCommandDefinition{
- {Aliases: []string{"/ucr"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoCreateUser(ctx, args), nil }},
- {Aliases: []string{"/uinfo"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoUserInfo(ctx, args), nil }},
- {Aliases: []string{"/rmemby", "/urm", "/only_rm_emby"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoDeleteUser(ctx, args), nil }},
- {Aliases: []string{"/only_rm_record"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoOnlyRemoveRecord(ctx, args), nil }},
- {Aliases: []string{"/userip"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoUserIP(ctx, args), nil }},
- }
-}
-
-func (s *TelegramBotService) telegramMgoAuditCommandDefinitions(ctx context.Context, adminOnly string) []telegramCommandDefinition {
- return []telegramCommandDefinition{
- {Aliases: []string{"/udeviceid"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
- return s.cmdMgoAuditDevices(ctx, "udeviceid", args), nil
- }},
- {Aliases: []string{"/auditip"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
- return s.cmdMgoAuditDevices(ctx, "auditip", args), nil
- }},
- {Aliases: []string{"/auditdevice"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
- return s.cmdMgoAuditDevices(ctx, "auditdevice", args), nil
- }},
- {Aliases: []string{"/auditclient"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
- return s.cmdMgoAuditDevices(ctx, "auditclient", args), nil
- }},
- }
-}
-
-func (s *TelegramBotService) telegramMgoMaintenanceCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, adminOnly string) []telegramCommandDefinition {
- return []telegramCommandDefinition{
- {Aliases: []string{"/renewall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoRenewAll(ctx, args), nil }},
- {Aliases: []string{"/callall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoCallAll(ctx, channel, args), nil }},
- {Aliases: []string{"/syncunbound"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoSyncUnbound(ctx, args), nil }},
- {Aliases: []string{"/syncgroupm"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
- return s.cmdMgoSyncGroup(ctx, channel, args), nil
- }},
- {Aliases: []string{"/kick_not_emby"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
- return s.cmdMgoUnsupported("群内无号用户清理", "/syncgroupm 可检查已绑定账号是否仍在群内;Telegram Bot API 无法枚举全部群成员,因此不能可靠找出“在群但无号”的用户。"), nil
- }},
- {Aliases: []string{"/scan_embyname"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoScanNames(ctx), nil }},
- {Aliases: []string{"/check_ex"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoCheckExpired(ctx, args), nil }},
- {Aliases: []string{"/deleted", "/low_activity"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanup(ctx, []string{"run"}), nil }},
- {Aliases: []string{"/uranks"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoRanks(ctx, 0, true), nil }},
- {Aliases: []string{"/days_ranks"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
- return s.cmdMgoRanks(ctx, 24*time.Hour, false), nil
- }},
- {Aliases: []string{"/week_ranks"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
- return s.cmdMgoRanks(ctx, 7*24*time.Hour, false), nil
- }},
- }
-}
-
-func (s *TelegramBotService) telegramMgoPolicyCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, adminOnly string) []telegramCommandDefinition {
- return []telegramCommandDefinition{
- {Aliases: []string{"/embyadmin"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoAdminRole(ctx, args), nil }},
- {Aliases: []string{"/unbanall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoBanAll(ctx, true, args), nil }},
- {Aliases: []string{"/banall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoBanAll(ctx, false, args), nil }},
- {Aliases: []string{"/embylibs_unblockall", "/extraembylibs_unblockall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
- return s.cmdMgoMediaAccessAll(ctx, true), nil
- }},
- {Aliases: []string{"/embylibs_blockall", "/extraembylibs_blockall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
- return s.cmdMgoMediaAccessAll(ctx, false), nil
- }},
- {Aliases: []string{"/proadmin"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
- return s.cmdMgoBotAdmin(ctx, channel, args, true), nil
- }},
- {Aliases: []string{"/revadmin"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
- return s.cmdMgoBotAdmin(ctx, channel, args, false), nil
- }},
- {Aliases: []string{"/backup_db"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoBackupDB(ctx), nil }},
- {Aliases: []string{"/restore_from_db"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoRestoreDB(ctx, args), nil }},
- {Aliases: []string{"/prouser"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
- return s.cmdMgoProtectedUser(ctx, args, true), nil
- }},
- {Aliases: []string{"/revuser"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
- return s.cmdMgoProtectedUser(ctx, args, false), nil
- }},
- }
-}
-
-func (s *TelegramBotService) telegramCommandRegistry(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) map[string]telegramCommandDefinition {
- defs := s.telegramCommandDefinitions(ctx, channel, msg)
- registry := make(map[string]telegramCommandDefinition, len(defs)*2)
- for _, def := range defs {
- for _, alias := range def.Aliases {
- registry[alias] = def
- }
- }
- return registry
-}
-
-// executeCommand parses and dispatches Telegram commands through a registry so
-// adding a command does not grow a monolithic switch.
-func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, text string) (telegramCommandReply, error) {
- parts := strings.Fields(text)
- if len(parts) == 0 {
- return telegramCommandReply{}, nil
- }
-
- cmd := telegramCommandName(parts[0])
- args := parts[1:]
- if msg.Chat.Type != "" && msg.Chat.Type != "private" && !s.telegramChatAllowed(channel, msg.Chat.ID) {
- return telegramCommandReply{Text: "此群组/频道未绑定到 Bot 管理入口,请在通知渠道里填写「绑定群组 ID」或「绑定频道 ID」。"}, nil
- }
-
- def, ok := s.telegramCommandRegistry(ctx, channel, msg)[cmd]
- if !ok {
- return telegramCommandReply{Text: fmt.Sprintf("未知命令: %s\n\n输入 /help 查看可用命令列表。", cmd)}, nil
- }
- if telegramIsGroupChat(msg.Chat.Type) && !def.GroupAllowed {
- if !def.AdminOnly || !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
- return telegramCommandReply{}, nil
- }
- }
- if def.AdminOnly && !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
- return telegramCommandReply{Text: def.AdminOnlyText}, nil
- }
- reply, err := def.Handle(args)
- if telegramIsGroupChat(msg.Chat.Type) && def.AdminOnly && !def.GroupAllowed {
- if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
- reply.Buttons = nil
- }
- }
- return reply, err
-}
diff --git a/internal/service/telegram_device_policy.go b/internal/service/telegram_device_policy.go
deleted file mode 100644
index 79eeb57..0000000
--- a/internal/service/telegram_device_policy.go
+++ /dev/null
@@ -1,252 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "strconv"
- "strings"
-)
-
-func (s *TelegramBotService) replyDevicePolicy(ctx context.Context) telegramCommandReply {
- cfg := loadBotConfig(ctx, s.repo)
- text := fmt.Sprintf(
- "设备策略\n\n① 防共享:%s\n 并发播放终端上限 %d / 登录终端上限 %d;同一终端多个 App 只算 1 台,App 作为登录渠道记录。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② Mgo 保号规则:%s\n 保号模式:%s;启用规则 %d 条。\n\n命令:\n/antishare on play=3 login=3 warn=2\n/cleanup run 预览候选\n/cleanup run confirm 确认清理\n/cleanup on|off\n/cleanup_rule list|add|edit|修改|del|enable|disable\n\n策略默认关闭;清理前会先预览候选;满足任意一条保号规则即保留;管理员/受保护账号永不自动处理。",
- onOff(cfg.AntiShareEnabled), cfg.MaxConcurrentPlay, cfg.MaxLoggedClients, cfg.WarnThreshold,
- onOff(cfg.AccountCleanupEnabled), cleanupModeLabel(cfg.AccountCleanupKeepMode), countEnabledCleanupRules(cfg.AccountCleanupRules))
- return telegramCommandReply{
- Text: text,
- Buttons: [][]telegramInlineButton{
- {{Text: toggleLabel("防共享", cfg.AntiShareEnabled), Data: "dp_toggle:antishare"}},
- {{Text: toggleLabel("保号规则", cfg.AccountCleanupEnabled), Data: "dp_toggle:cleanup"}},
- {{Text: "⬅️ 返回菜单", Data: "menu_main"}},
- },
- }
-}
-
-func (s *TelegramBotService) cmdDevicePolicy(ctx context.Context, args []string) telegramCommandReply {
- if len(args) == 0 || strings.EqualFold(args[0], "status") {
- return s.replyDevicePolicy(ctx)
- }
- switch strings.ToLower(strings.TrimSpace(args[0])) {
- case "run", "sweep":
- return s.cmdCleanup(ctx, []string{"run"})
- default:
- return telegramCommandReply{Text: "用法:/devicepolicy 查看策略,或使用 /antishare、/cleanup、/cleanup_rule 管理。"}
- }
-}
-
-func (s *TelegramBotService) cmdAntiShare(ctx context.Context, args []string) telegramCommandReply {
- if len(args) == 0 || strings.EqualFold(args[0], "status") {
- return s.replyDevicePolicy(ctx)
- }
- enabled, ok := parseCommandBool(args[0])
- if !ok {
- return telegramCommandReply{Text: "用法:/antishare on|off [play=3] [login=3] [warn=2],login 表示登录终端设备上限,同一终端多个 App 不重复计数。"}
- }
- if err := s.repo.Setting.Set(ctx, SettingAntiShareEnabled, strconv.FormatBool(enabled)); err != nil {
- return telegramCommandReply{Text: "更新失败:" + err.Error()}
- }
- for _, arg := range args[1:] {
- key, value, ok := strings.Cut(arg, "=")
- if !ok {
- continue
- }
- n, err := strconv.Atoi(strings.TrimSpace(value))
- if err != nil || n < 1 {
- continue
- }
- switch strings.ToLower(strings.TrimSpace(key)) {
- case "play", "maxplay", "播放":
- _ = s.repo.Setting.Set(ctx, SettingMaxConcurrentPlay, strconv.Itoa(n))
- case "login", "client", "clients", "登录":
- _ = s.repo.Setting.Set(ctx, SettingMaxLoggedClients, strconv.Itoa(n))
- case "warn", "warnings", "警告":
- _ = s.repo.Setting.Set(ctx, SettingWarnThreshold, strconv.Itoa(n))
- }
- }
- return s.replyDevicePolicy(ctx)
-}
-
-func (s *TelegramBotService) cmdCleanup(ctx context.Context, args []string) telegramCommandReply {
- if len(args) == 0 || strings.EqualFold(args[0], "status") {
- return s.replyDevicePolicy(ctx)
- }
- switch strings.ToLower(strings.TrimSpace(args[0])) {
- case "on", "true", "1", "开启", "enable":
- if err := s.repo.Setting.Set(ctx, SettingAccountCleanupEnabled, "true"); err != nil {
- return telegramCommandReply{Text: "开启失败:" + err.Error()}
- }
- return s.replyDevicePolicy(ctx)
- case "off", "false", "0", "关闭", "disable":
- if err := s.repo.Setting.Set(ctx, SettingAccountCleanupEnabled, "false"); err != nil {
- return telegramCommandReply{Text: "关闭失败:" + err.Error()}
- }
- return s.replyDevicePolicy(ctx)
- case "run", "sweep", "巡检", "preview", "预览":
- device := s.device
- if device == nil {
- device = NewDeviceService(s.log, s.repo)
- }
- if len(args) > 1 && isCleanupConfirmArg(args[1]) {
- cfg := loadBotConfig(ctx, s.repo)
- if !cfg.AccountCleanupEnabled {
- return telegramCommandReply{Text: "保号规则未开启,不会清理账号。"}
- }
- if countEnabledCleanupRules(cfg.AccountCleanupRules) == 0 {
- return telegramCommandReply{Text: "没有启用的保号规则,不会清理账号。"}
- }
- removed, err := device.SweepAccountCleanup(ctx)
- if err != nil {
- return telegramCommandReply{Text: "确认清理失败:" + err.Error()}
- }
- return telegramCommandReply{Text: fmt.Sprintf("保号规则确认清理完成,已清理 %d 个账号。", removed)}
- }
- candidates, err := device.PreviewAccountCleanup(ctx)
- if err != nil {
- return telegramCommandReply{Text: "巡检预览失败:" + err.Error()}
- }
- return telegramCommandReply{Text: s.formatCleanupPreview(ctx, candidates)}
- default:
- return telegramCommandReply{Text: "用法:/cleanup on|off、/cleanup run 预览、/cleanup run confirm 确认清理"}
- }
-}
-
-func isCleanupConfirmArg(arg string) bool {
- switch strings.ToLower(strings.TrimSpace(arg)) {
- case "confirm", "yes", "delete", "确认", "清理", "删除":
- return true
- default:
- return false
- }
-}
-
-func (s *TelegramBotService) formatCleanupPreview(ctx context.Context, candidates []accountCleanupCandidate) string {
- cfg := loadBotConfig(ctx, s.repo)
- if !cfg.AccountCleanupEnabled {
- return "保号规则未开启,不会清理账号。"
- }
- if countEnabledCleanupRules(cfg.AccountCleanupRules) == 0 {
- return "没有启用的保号规则,不会清理账号。"
- }
- if len(candidates) == 0 {
- return "保号规则预览完成:没有需要清理的账号。"
- }
- var sb strings.Builder
- sb.WriteString(fmt.Sprintf("保号规则预览\n\n将清理候选:%d 个账号。\n当前只是预览,未删除任何账号。\n\n", len(candidates)))
- limit := len(candidates)
- if limit > 10 {
- limit = 10
- }
- for i := 0; i < limit; i++ {
- candidate := candidates[i]
- sb.WriteString(fmt.Sprintf("%d. %s\n%s\n", i+1, escapeHTML(candidate.Username), escapeHTML(candidate.Details)))
- }
- if len(candidates) > limit {
- sb.WriteString(fmt.Sprintf("……另有 %d 个候选未展示。\n", len(candidates)-limit))
- }
- sb.WriteString("\n确认无误后再执行:/cleanup run confirm")
- return sb.String()
-}
-
-func (s *TelegramBotService) cmdCleanupMode(ctx context.Context, args []string) telegramCommandReply {
- if err := s.repo.Setting.Set(ctx, SettingAccountCleanupKeepMode, "any"); err != nil {
- return telegramCommandReply{Text: "更新失败:" + err.Error()}
- }
- if err := s.repo.Setting.Set(ctx, SettingAccountCleanupRequiredCount, "1"); err != nil {
- return telegramCommandReply{Text: "更新失败:" + err.Error()}
- }
- reply := s.replyDevicePolicy(ctx)
- reply.Text = "Mgo 保号模式固定为:满足任意一条启用规则即保留;只有全部规则都不满足才进入清理候选。\n\n" + reply.Text
- return reply
-}
-
-func (s *TelegramBotService) cmdCleanupRule(ctx context.Context, args []string) telegramCommandReply {
- rules := s.currentCleanupRules(ctx)
- if len(args) == 0 {
- return telegramCommandReply{Text: formatCleanupRules(rules)}
- }
- action := strings.ToLower(strings.TrimSpace(args[0]))
- switch action {
- case "list", "ls", "status":
- return telegramCommandReply{Text: formatCleanupRules(rules)}
- case "help", "?", "帮助":
- return telegramCommandReply{Text: cleanupRuleHelp()}
- case "del", "delete", "rm":
- if len(args) < 2 {
- return telegramCommandReply{Text: "用法:/cleanup_rule del 规则ID"}
- }
- next := make([]accountCleanupRule, 0, len(rules))
- removed := false
- for _, r := range rules {
- if r.ID == args[1] {
- removed = true
- continue
- }
- next = append(next, r)
- }
- if !removed {
- return telegramCommandReply{Text: "未找到该规则。"}
- }
- if err := s.saveCleanupRules(ctx, next); err != nil {
- return telegramCommandReply{Text: "保存失败:" + err.Error()}
- }
- return telegramCommandReply{Text: "已删除规则。\n\n" + formatCleanupRules(next)}
- case "enable", "on", "disable", "off":
- if len(args) < 2 {
- return telegramCommandReply{Text: "用法:/cleanup_rule enable|disable 规则ID"}
- }
- enable := action == "enable" || action == "on"
- changed := false
- for i := range rules {
- if rules[i].ID == args[1] {
- rules[i].Enabled = enable
- changed = true
- }
- }
- if !changed {
- return telegramCommandReply{Text: "未找到该规则。"}
- }
- if err := s.saveCleanupRules(ctx, rules); err != nil {
- return telegramCommandReply{Text: "保存失败:" + err.Error()}
- }
- return telegramCommandReply{Text: "已更新规则状态。\n\n" + formatCleanupRules(rules)}
- case "add", "set", "edit", "update", "修改", "更新", "改":
- rule, err := parseCleanupRuleCommand(args[1:])
- if err != nil {
- return telegramCommandReply{Text: err.Error() + "\n\n" + cleanupRuleHelp()}
- }
- updated := false
- for i := range rules {
- if rules[i].ID == rule.ID {
- rules[i] = rule
- updated = true
- break
- }
- }
- if !updated {
- rules = append(rules, rule)
- }
- rules = normalizeCleanupRules(rules)
- if err := s.saveCleanupRules(ctx, rules); err != nil {
- return telegramCommandReply{Text: "保存失败:" + err.Error()}
- }
- actionText := "已新增规则。"
- if updated {
- actionText = "已更新规则。"
- }
- return telegramCommandReply{Text: actionText + "\n\n" + formatCleanupRules(rules)}
- default:
- return telegramCommandReply{Text: cleanupRuleHelp()}
- }
-}
-
-func (s *TelegramBotService) replyDevicePolicyToggle(ctx context.Context, which string) telegramCommandReply {
- cfg := loadBotConfig(ctx, s.repo)
- switch which {
- case "antishare":
- _ = s.repo.Setting.Set(ctx, SettingAntiShareEnabled, strconv.FormatBool(!cfg.AntiShareEnabled))
- case "cleanup":
- _ = s.repo.Setting.Set(ctx, SettingAccountCleanupEnabled, strconv.FormatBool(!cfg.AccountCleanupEnabled))
- }
- return s.replyDevicePolicy(ctx)
-}
diff --git a/internal/service/telegram_membership.go b/internal/service/telegram_membership.go
deleted file mode 100644
index 37f3c30..0000000
--- a/internal/service/telegram_membership.go
+++ /dev/null
@@ -1,122 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "strings"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *TelegramBotService) telegramUserIsAdmin(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) bool {
- if s.telegramUserIDConfigured(channel, telegramUserID) {
- return true
- }
- binding := s.telegramBinding(ctx, telegramUserID)
- if binding == nil {
- return false
- }
- user, err := s.repo.User.FindByID(ctx, binding.UserID)
- return err == nil && user != nil && user.Role == "admin" && user.IsActive
-}
-
-// telegramBindDecision 表示成员资格校验的三态结果:通过 / 明确不通过 /
-// 无法验证(getChatMember 出错,如 Bot 不在群、群 ID 失效、网络或代理不可达)。
-// 区分「明确不是成员」和「查不了」,是为了避免把验证失败误报成「你不在群」。
-type telegramBindDecision int
-
-const (
- bindDenied telegramBindDecision = iota // 已查实:不在任何绑定群组/频道
- bindAllowed // 管理员,或查实是某绑定群组/频道成员
- bindUnverifiable // 配了群组/频道但 getChatMember 全部失败
-)
-
-// telegramMembership 表示单个 chat 的成员资格三态。
-type telegramMembership int
-
-const (
- membershipNo telegramMembership = iota // 查实不是成员(left/kicked 等)
- membershipYes // 查实是成员
- membershipUnknown // getChatMember 出错,无法判定
-)
-
-func (s *TelegramBotService) telegramUserBindDecision(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) telegramBindDecision {
- if telegramUserID == 0 || channel == nil {
- return bindDenied
- }
- if s.telegramUserIDConfigured(channel, telegramUserID) {
- return bindAllowed
- }
- chatIDs := s.telegramMembershipChatIDs(channel)
- if len(chatIDs) == 0 {
- return bindDenied
- }
- sawUnknown := false
- for _, chatID := range chatIDs {
- switch s.telegramChatMembership(ctx, channel, chatID, telegramUserID) {
- case membershipYes:
- return bindAllowed
- case membershipUnknown:
- sawUnknown = true
- }
- }
- if sawUnknown {
- return bindUnverifiable
- }
- return bindDenied
-}
-
-// telegramUserCanBind 是 telegramUserBindDecision 的布尔包装,供尽力而为的场景
-// 使用(如私聊时挑选可用渠道):只有查实通过才返回 true。
-func (s *TelegramBotService) telegramUserCanBind(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) bool {
- return s.telegramUserBindDecision(ctx, channel, telegramUserID) == bindAllowed
-}
-
-// telegramBindRejectText 根据三态结果生成面向用户的提示。action 形如「兑换注册账号」
-// 「绑定媒体中心账号」。bindUnverifiable 时不再误导用户「你不在群」,而是提示
-// 管理员检查 Bot 权限与群组 ID。
-func telegramBindRejectText(decision telegramBindDecision, action string) string {
- if decision == bindUnverifiable {
- return fmt.Sprintf("暂时无法验证你的群组/频道成员身份,%s未成功。这通常是因为 Bot 未加入绑定群组、在频道中不是管理员,或群组 ID 配置有误(如超级群需带 -100 前缀)。请联系管理员检查 Bot 权限与「绑定群组/频道 ID」。", action)
- }
- return fmt.Sprintf("当前 Telegram 账号不在管理员配置的绑定群组/频道中,无法%s。请先加入管理员配置的群组或频道;如果尚未配置,请联系管理员。", action)
-}
-
-func (s *TelegramBotService) telegramChatMembership(ctx context.Context, channel *model.NotifyChannel, chatID string, telegramUserID int) telegramMembership {
- cfg := s.telegramChannelConfig(channel)
- if strings.TrimSpace(cfg["bot_token"]) == "" || chatID == "" || telegramUserID == 0 {
- return membershipUnknown
- }
- payload := map[string]interface{}{
- "chat_id": chatID,
- "user_id": telegramUserID,
- }
- var result struct {
- OK bool `json:"ok"`
- Result struct {
- Status string `json:"status"`
- } `json:"result"`
- }
- if err := telegramPostJSONDecode(ctx, cfg, "getChatMember", payload, 15*time.Second, &result); err != nil {
- s.log.Warn("telegram getChatMember failed", zap.String("chat_id", chatID), zap.Int("telegram_user_id", telegramUserID), zap.Error(sanitizeTelegramError(err)))
- return membershipUnknown
- }
- if !result.OK {
- return membershipUnknown
- }
- switch strings.ToLower(result.Result.Status) {
- case "creator", "administrator", "member", "restricted":
- return membershipYes
- default:
- return membershipNo
- }
-}
-
-// telegramUserIsChatMember 是 telegramChatMembership 的布尔包装,仅在查实是成员时
-// 返回 true(查不了也视为非成员,供尽力而为的场景使用)。
-func (s *TelegramBotService) telegramUserIsChatMember(ctx context.Context, channel *model.NotifyChannel, chatID string, telegramUserID int) bool {
- return s.telegramChatMembership(ctx, channel, chatID, telegramUserID) == membershipYes
-}
diff --git a/internal/service/telegram_menu.go b/internal/service/telegram_menu.go
deleted file mode 100644
index 064d543..0000000
--- a/internal/service/telegram_menu.go
+++ /dev/null
@@ -1,219 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "strconv"
- "strings"
- "time"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-// pendingTTL bounds how long a button-initiated text prompt stays valid.
-const pendingTTL = 5 * time.Minute
-
-func (s *TelegramBotService) setPending(userID int64, kind string) {
- s.pendingMu.Lock()
- s.pending[userID] = pendingInput{Kind: kind, CreatedAt: time.Now()}
- s.pendingMu.Unlock()
-}
-
-func (s *TelegramBotService) takePending(userID int64) (pendingInput, bool) {
- s.pendingMu.Lock()
- defer s.pendingMu.Unlock()
- p, ok := s.pending[userID]
- if ok {
- delete(s.pending, userID)
- }
- if ok && time.Since(p.CreatedAt) > pendingTTL {
- return pendingInput{}, false
- }
- return p, ok
-}
-
-// boundUser resolves the local user bound to a Telegram account, or nil.
-func (s *TelegramBotService) boundUser(ctx context.Context, telegramUserID int) *model.User {
- binding := s.telegramBinding(ctx, telegramUserID)
- if binding == nil {
- return nil
- }
- u, _ := s.repo.User.FindByID(ctx, binding.UserID)
- return u
-}
-
-// handleMenuCallback routes inline-button taps. Returns (reply, handled).
-func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, data string) (telegramCommandReply, bool) {
- isAdmin := s.telegramUserIsAdmin(ctx, channel, msg.From.ID)
- isGroup := telegramIsGroupChat(msg.Chat.Type)
- if reply, handled := s.handleUserMenuCallback(ctx, channel, msg, data, isGroup); handled {
- return reply, true
- }
- if !isAdmin {
- if isGroup {
- return telegramCommandReply{}, true
- }
- return telegramCommandReply{Text: "此功能仅管理员可用。"}, true
- }
- return s.handleAdminMenuCallback(ctx, msg, data)
-}
-
-func (s *TelegramBotService) handleUserMenuCallback(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, data string, isGroup bool) (telegramCommandReply, bool) {
- switch {
- case data == "noop":
- return telegramCommandReply{}, true
- case data == "menu_main":
- return s.mainMenu(ctx, channel, msg), true
- case data == "act_account":
- return s.replyAccount(ctx, msg), true
- case data == "act_signin":
- return s.replySignIn(ctx, msg), true
- case data == "act_devices":
- return s.replyDevices(ctx, msg), true
- case strings.HasPrefix(data, "kick:"):
- return s.replyKick(ctx, msg, strings.TrimPrefix(data, "kick:")), true
- }
- return s.handlePrivatePromptMenuCallback(ctx, msg, data, isGroup)
-}
-
-func (s *TelegramBotService) handlePrivatePromptMenuCallback(ctx context.Context, msg *TelegramMessage, data string, isGroup bool) (telegramCommandReply, bool) {
- switch data {
- case "act_bind":
- return telegramPrivateOnlyMenuReply(isGroup, "绑定账号", "请发送:/start 用户名 密码 绑定已有账号。"), true
- case "act_register":
- if isGroup {
- return telegramCommandReply{Text: telegramGroupPrivateUserHint("注册账号")}, true
- }
- if !s.openRegEnabled(ctx) {
- return telegramCommandReply{Text: "注册功能未开放,请联系管理员。"}, true
- }
- s.setPending(int64(msg.From.ID), "register")
- return telegramCommandReply{Text: "请发送新账号的 用户名 密码(空格分隔),例如:alice mypass123"}, true
- case "act_redeem_register":
- if isGroup {
- return telegramCommandReply{Text: telegramGroupPrivateUserHint("兑换码注册")}, true
- }
- s.setPending(int64(msg.From.ID), "redeem_register")
- return telegramCommandReply{Text: "请发送你的注册兑换码,例如:ABCD2345EFGH\n(兑换后会要求设置用户名密码)"}, true
- case "act_redeem_renew":
- if isGroup {
- return telegramCommandReply{Text: telegramGroupPrivateUserHint("兑换码续期")}, true
- }
- s.setPending(int64(msg.From.ID), "redeem_renew")
- return telegramCommandReply{Text: "请发送你的续期兑换码,将为当前绑定账号续期。"}, true
- case "act_setname":
- return s.setPendingPrivatePrompt(msg, isGroup, "修改用户名", "setname", "请发送:当前密码 新用户名。"), true
- case "act_setpass":
- return s.setPendingPrivatePrompt(msg, isGroup, "修改密码", "setpass", "请发送:当前密码 新密码(新密码至少 6 位)。"), true
- }
- return telegramCommandReply{}, false
-}
-
-func telegramPrivateOnlyMenuReply(isGroup bool, action, privateText string) telegramCommandReply {
- if isGroup {
- return telegramCommandReply{Text: telegramGroupPrivateUserHint(action)}
- }
- return telegramCommandReply{Text: privateText}
-}
-
-func (s *TelegramBotService) setPendingPrivatePrompt(msg *TelegramMessage, isGroup bool, action, kind, text string) telegramCommandReply {
- if isGroup {
- return telegramCommandReply{Text: telegramGroupPrivateUserHint(action)}
- }
- s.setPending(int64(msg.From.ID), kind)
- return telegramCommandReply{Text: text}
-}
-
-func (s *TelegramBotService) handleAdminMenuCallback(ctx context.Context, msg *TelegramMessage, data string) (telegramCommandReply, bool) {
- if reply, handled := s.handleAdminRegistrationCallback(ctx, msg, data); handled {
- return reply, true
- }
- if reply, handled := s.handleAdminUserCallback(ctx, data); handled {
- return reply, true
- }
- switch {
- case data == "adm_capacity":
- return s.replyCapacity(ctx), true
- case data == "adm_devicepolicy":
- return s.replyDevicePolicy(ctx), true
- case data == "adm_mgo_commands":
- return telegramCommandReply{Text: telegramMgoAdminCommandHelp(), Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}}, true
- case strings.HasPrefix(data, "dp_toggle:"):
- return s.replyDevicePolicyToggle(ctx, strings.TrimPrefix(data, "dp_toggle:")), true
- }
- return telegramCommandReply{}, false
-}
-
-func (s *TelegramBotService) handleAdminRegistrationCallback(ctx context.Context, msg *TelegramMessage, data string) (telegramCommandReply, bool) {
- switch {
- case data == "adm_openreg":
- return s.replyOpenRegMenu(ctx), true
- case data == "adm_openreg_close":
- _ = s.closeRegistration(ctx)
- return telegramCommandReply{Text: "已关闭注册。"}, true
- case strings.HasPrefix(data, "adm_openreg_set:"):
- n, _ := strconv.Atoi(strings.TrimPrefix(data, "adm_openreg_set:"))
- if err := s.openRegistration(ctx, n); err != nil {
- return telegramCommandReply{Text: "开注失败:" + err.Error()}, true
- }
- label := "不限"
- if n > 0 {
- label = fmt.Sprintf("%d 个名额", n)
- }
- return telegramCommandReply{Text: "已开放注册:" + label + "。"}, true
- case data == "adm_gencode":
- return s.replyGenCodeMenu(), true
- case strings.HasPrefix(data, "gc:"):
- return s.replyGenCode(ctx, msg, data), true
- }
- return telegramCommandReply{}, false
-}
-
-func (s *TelegramBotService) handleAdminUserCallback(ctx context.Context, data string) (telegramCommandReply, bool) {
- switch {
- case data == "adm_users":
- return s.replyUserList(ctx), true
- case strings.HasPrefix(data, "usr:"):
- return s.replyUserActions(ctx, strings.TrimPrefix(data, "usr:")), true
- case strings.HasPrefix(data, "uban:"):
- return s.replyUserBan(ctx, strings.TrimPrefix(data, "uban:"), false), true
- case strings.HasPrefix(data, "uunban:"):
- return s.replyUserBan(ctx, strings.TrimPrefix(data, "uunban:"), true), true
- case strings.HasPrefix(data, "udel:"):
- return s.replyUserDelete(ctx, strings.TrimPrefix(data, "udel:")), true
- case strings.HasPrefix(data, "urenew:"):
- return s.replyUserRenew(ctx, strings.TrimPrefix(data, "urenew:")), true
- }
- return telegramCommandReply{}, false
-}
-
-// handlePendingText consumes a button-initiated text prompt. Returns (reply,
-// handled). handled=false means there was no pending prompt for this user.
-func (s *TelegramBotService) handlePendingText(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, text string) (telegramCommandReply, bool) {
- p, ok := s.takePending(int64(msg.From.ID))
- if !ok {
- return telegramCommandReply{}, false
- }
- switch p.Kind {
- case "register":
- return s.cmdRegister(ctx, channel, msg, strings.Fields(text)), true
- case "redeem_register":
- return s.redeemRegisterFlow(ctx, channel, msg, text), true
- case "redeem_renew":
- return s.redeemRenewFlow(ctx, msg, text), true
- case "setname":
- return s.selfSetName(ctx, msg, text), true
- case "setpass":
- return s.selfSetPass(ctx, msg, text), true
- case "openreg_limit":
- n, err := strconv.Atoi(strings.TrimSpace(text))
- if err != nil || n < 0 {
- return telegramCommandReply{Text: "请输入有效的非负整数。"}, true
- }
- if err := s.openRegistration(ctx, n); err != nil {
- return telegramCommandReply{Text: "开注失败:" + err.Error()}, true
- }
- return telegramCommandReply{Text: fmt.Sprintf("已开放注册:%d 个名额。", n)}, true
- }
- return telegramCommandReply{}, false
-}
diff --git a/internal/service/telegram_menu_layout.go b/internal/service/telegram_menu_layout.go
deleted file mode 100644
index 851ca67..0000000
--- a/internal/service/telegram_menu_layout.go
+++ /dev/null
@@ -1,115 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-// mainMenu builds the button-based menu, tailored to the user's binding and
-// admin status. Ordinary users only see self-service actions; admins get an
-// extra management section.
-func (s *TelegramBotService) mainMenu(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) telegramCommandReply {
- isAdmin := s.telegramUserIsAdmin(ctx, channel, msg.From.ID)
- user := s.boundUser(ctx, msg.From.ID)
- if telegramIsGroupChat(msg.Chat.Type) {
- return s.groupMainMenu(isAdmin, user)
- }
- return s.privateMainMenu(ctx, isAdmin, user)
-}
-
-func (s *TelegramBotService) groupMainMenu(isAdmin bool, user *model.User) telegramCommandReply {
- header := "MediaStationGo 群组自助菜单\n\n你还没有绑定媒体中心账号。绑定、注册、兑换等包含敏感信息的操作请私聊 Bot。"
- var rows [][]telegramInlineButton
- if user != nil {
- header = telegramUserMenuHeader("MediaStationGo 群组自助菜单", user)
- rows = telegramBoundUserMenuRows(user, false)
- }
- if isAdmin {
- header += "\n\n管理员入口"
- rows = append(rows, telegramAdminMenuRows()...)
- }
- return telegramCommandReply{Text: header, Buttons: rows}
-}
-
-func (s *TelegramBotService) privateMainMenu(ctx context.Context, isAdmin bool, user *model.User) telegramCommandReply {
- header := "MediaStationGo\n\n你还没有绑定媒体中心账号。"
- rows := s.privateUnboundMenuRows(ctx)
- if user != nil {
- header = telegramUserMenuHeader("MediaStationGo", user)
- rows = telegramBoundUserMenuRows(user, true)
- }
- if isAdmin {
- rows = append(rows, telegramAdminMenuRows()...)
- }
- return telegramCommandReply{Text: header, Buttons: rows}
-}
-
-func (s *TelegramBotService) privateUnboundMenuRows(ctx context.Context) [][]telegramInlineButton {
- rows := [][]telegramInlineButton{{{Text: "🔗 绑定账号", Data: "act_bind"}}}
- if s.openRegEnabled(ctx) {
- rows = append(rows, []telegramInlineButton{{Text: "📝 注册新账号", Data: "act_register"}})
- }
- return append(rows, []telegramInlineButton{{Text: "🎟 兑换码注册", Data: "act_redeem_register"}})
-}
-
-func telegramUserMenuHeader(title string, user *model.User) string {
- return fmt.Sprintf("%s\n\n账号:%s\n到期:%s\n成人目录:%s",
- title, user.Username, formatExpiry(user.ExpiredAt), telegramAdultVisibilityLabel(user.HideAdult))
-}
-
-func telegramAdultVisibilityLabel(hidden bool) string {
- if hidden {
- return "已隐藏"
- }
- return "已显示"
-}
-
-func telegramAdultToggleText(hidden bool) string {
- if hidden {
- return "🔞 显示成人目录"
- }
- return "🔞 隐藏成人目录"
-}
-
-func telegramBoundUserMenuRows(user *model.User, includePrivateActions bool) [][]telegramInlineButton {
- rows := [][]telegramInlineButton{
- {
- {Text: "👤 我的账号", Data: "act_account"},
- {Text: "📅 签到", Data: "act_signin"},
- },
- {
- {Text: "📱 我的设备", Data: "act_devices"},
- {Text: telegramAdultToggleText(user.HideAdult), Data: "adult_toggle"},
- },
- }
- if includePrivateActions {
- rows = append(rows,
- []telegramInlineButton{
- {Text: "✏️ 改用户名", Data: "act_setname"},
- {Text: "🔑 改密码", Data: "act_setpass"},
- },
- []telegramInlineButton{{Text: "🎟 兑换码续期", Data: "act_redeem_renew"}},
- )
- }
- return rows
-}
-
-func telegramAdminMenuRows() [][]telegramInlineButton {
- return [][]telegramInlineButton{
- {{Text: "—— 管理员 ——", Data: "noop"}},
- {
- {Text: "📊 容量/状态", Data: "adm_capacity"},
- {Text: "👥 用户管理", Data: "adm_users"},
- },
- {
- {Text: "🔓 开注设置", Data: "adm_openreg"},
- {Text: "🎟 生成兑换码", Data: "adm_gencode"},
- },
- {
- {Text: "⚙️ 设备策略", Data: "adm_devicepolicy"},
- {Text: "🛠 管理命令", Data: "adm_mgo_commands"},
- },
- }
-}
diff --git a/internal/service/telegram_mgo_admin.go b/internal/service/telegram_mgo_admin.go
deleted file mode 100644
index 728d50f..0000000
--- a/internal/service/telegram_mgo_admin.go
+++ /dev/null
@@ -1,159 +0,0 @@
-package service
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "sort"
- "strconv"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *TelegramBotService) cmdMgoAdminRole(ctx context.Context, args []string) telegramCommandReply {
- if len(args) < 2 {
- return telegramCommandReply{Text: "用法:/embyadmin 用户名 on|off"}
- }
- user := s.findMgoBotUser(ctx, args[0])
- if user == nil {
- return telegramCommandReply{Text: "未找到用户。"}
- }
- enable := parseOnOff(args[1])
- if enable == nil {
- return telegramCommandReply{Text: "第二个参数请使用 on/off。"}
- }
- if !*enable {
- if first, _ := s.repo.User.FirstAdmin(ctx); first != nil && first.ID == user.ID {
- return telegramCommandReply{Text: "默认管理员不可降级。"}
- }
- }
- role := "user"
- if *enable {
- role = "admin"
- }
- if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"role": role}); err != nil {
- return telegramCommandReply{Text: "更新失败:" + err.Error()}
- }
- return telegramCommandReply{Text: fmt.Sprintf("已将 %s 角色设置为 %s。", user.Username, role)}
-}
-
-func (s *TelegramBotService) cmdMgoMediaAccessAll(ctx context.Context, allow bool) telegramCommandReply {
- users, err := s.repo.User.List(ctx)
- if err != nil {
- return telegramCommandReply{Text: "读取用户失败:" + err.Error()}
- }
- updated := 0
- for _, user := range users {
- if user.Role == "admin" {
- continue
- }
- perm, err := s.repo.Permission.FindByUserID(ctx, user.ID)
- if err != nil {
- continue
- }
- if perm == nil {
- perm = DefaultPermissions(user.ID)
- perm.CanPlayMedia = allow
- if err := s.repo.Permission.Create(ctx, perm); err != nil {
- continue
- }
- }
- if err := s.repo.DB.WithContext(ctx).Model(&model.UserPermission{}).
- Where("user_id = ?", user.ID).
- Update("can_play_media", allow).Error; err == nil {
- updated++
- }
- }
- state := "关闭"
- if allow {
- state = "开启"
- }
- return telegramCommandReply{Text: fmt.Sprintf("已为普通用户%s媒体播放权限:%d 个。", state, updated)}
-}
-
-func (s *TelegramBotService) cmdMgoBotAdmin(ctx context.Context, channel *model.NotifyChannel, args []string, add bool) telegramCommandReply {
- if channel == nil {
- return telegramCommandReply{Text: "Telegram 渠道不存在。"}
- }
- if len(args) == 0 {
- return telegramCommandReply{Text: "用法:/proadmin TelegramID 或 /revadmin TelegramID"}
- }
- tgID := strings.TrimPrefix(strings.TrimSpace(args[0]), "tg:")
- if _, err := strconv.ParseInt(tgID, 10, 64); err != nil {
- return telegramCommandReply{Text: "TelegramID 必须是数字。"}
- }
- cfg := s.telegramChannelConfig(channel)
- ids := telegramConfiguredUserIDs(cfg["admin_user_ids"])
- seen := make(map[string]bool, len(ids)+1)
- var next []string
- for _, id := range ids {
- if id == tgID {
- seen[id] = true
- if add {
- next = append(next, id)
- }
- continue
- }
- if id != "" {
- next = append(next, id)
- }
- }
- if add && !seen[tgID] {
- next = append(next, tgID)
- }
- cfg["admin_user_ids"] = strings.Join(next, ",")
- raw, _ := json.Marshal(cfg)
- updated := *channel
- updated.Config = string(raw)
- if s.crypto != nil {
- updated.Config = s.crypto.Encrypt(updated.Config)
- }
- if err := s.repo.NotifyChannel.Update(ctx, &updated); err != nil {
- return telegramCommandReply{Text: "更新管理员列表失败:" + err.Error()}
- }
- if add {
- return telegramCommandReply{Text: "已添加 Bot 管理员:" + tgID + ""}
- }
- return telegramCommandReply{Text: "已移除 Bot 管理员:" + tgID + ""}
-}
-
-func (s *TelegramBotService) cmdMgoProtectedUser(ctx context.Context, args []string, protect bool) telegramCommandReply {
- if len(args) == 0 || strings.EqualFold(args[0], "list") {
- return s.cmdMgoProtectedUserList(ctx)
- }
- user := s.findMgoBotUser(ctx, args[0])
- if user == nil {
- return telegramCommandReply{Text: "未找到用户。"}
- }
- ids := ProtectedUserIDSet(ctx, s.repo)
- if protect {
- ids[user.ID] = struct{}{}
- if err := SaveProtectedUserIDSet(ctx, s.repo, ids); err != nil {
- return telegramCommandReply{Text: "保存保护名单失败:" + err.Error()}
- }
- return telegramCommandReply{Text: fmt.Sprintf("已加入保护名单:%s。\n该用户不会被 Bot 自动清理、批量禁用或删除。", user.Username)}
- }
- delete(ids, user.ID)
- if err := SaveProtectedUserIDSet(ctx, s.repo, ids); err != nil {
- return telegramCommandReply{Text: "保存保护名单失败:" + err.Error()}
- }
- return telegramCommandReply{Text: fmt.Sprintf("已移出保护名单:%s。", user.Username)}
-}
-
-func (s *TelegramBotService) cmdMgoProtectedUserList(ctx context.Context) telegramCommandReply {
- ids := ProtectedUserIDSet(ctx, s.repo)
- if len(ids) == 0 {
- return telegramCommandReply{Text: "保护名单为空。管理员和默认管理员始终自动保护。"}
- }
- names := make([]string, 0, len(ids))
- for id := range ids {
- if user, _ := s.repo.User.FindByID(ctx, id); user != nil {
- names = append(names, user.Username)
- } else {
- names = append(names, id+"(用户不存在)")
- }
- }
- sort.Strings(names)
- return telegramCommandReply{Text: fmt.Sprintf("保护名单:%d 个。\n%s", len(names), telegramInlineCodeList(names))}
-}
diff --git a/internal/service/telegram_mgo_audit.go b/internal/service/telegram_mgo_audit.go
deleted file mode 100644
index b696852..0000000
--- a/internal/service/telegram_mgo_audit.go
+++ /dev/null
@@ -1,133 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "strings"
- "time"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *TelegramBotService) cmdMgoSyncUnbound(ctx context.Context, args []string) telegramCommandReply {
- var users []model.User
- if err := s.repo.DB.WithContext(ctx).
- Where("role <> ?", "admin").
- Where("NOT EXISTS (SELECT 1 FROM telegram_bindings WHERE telegram_bindings.user_id = users.id AND telegram_bindings.deleted_at IS NULL)").
- Order("created_at asc").Find(&users).Error; err != nil {
- return telegramCommandReply{Text: "查询失败:" + err.Error()}
- }
- if len(args) >= 2 && strings.EqualFold(args[0], "delete") && strings.EqualFold(args[1], "confirm") {
- deleted := 0
- for _, user := range users {
- if UserIsProtectedAccount(ctx, s.repo, &user) {
- continue
- }
- _ = s.repo.UserDevice.DeleteByUser(ctx, user.ID)
- if err := s.repo.User.Delete(ctx, user.ID); err == nil {
- deleted++
- }
- }
- return telegramCommandReply{Text: fmt.Sprintf("已删除未绑定 Bot 的普通用户:%d 个。", deleted)}
- }
- if len(users) == 0 {
- return telegramCommandReply{Text: "没有未绑定 Bot 的普通用户。"}
- }
- names := make([]string, 0, minInt(len(users), 20))
- for i, user := range users {
- if i >= 20 {
- break
- }
- names = append(names, user.Username)
- }
- return telegramCommandReply{Text: fmt.Sprintf("未绑定 Bot 的普通用户:%d 个。\n%s\n\n如需删除:/syncunbound delete confirm", len(users), telegramInlineCodeList(names))}
-}
-
-func (s *TelegramBotService) cmdMgoCheckExpired(ctx context.Context, args []string) telegramCommandReply {
- now := time.Now()
- var users []model.User
- if err := s.repo.DB.WithContext(ctx).Where("expired_at IS NOT NULL AND expired_at < ?", now).Order("expired_at asc").Find(&users).Error; err != nil {
- return telegramCommandReply{Text: "查询失败:" + err.Error()}
- }
- if len(args) >= 2 && strings.EqualFold(args[0], "disable") && strings.EqualFold(args[1], "confirm") {
- disabled := 0
- for _, user := range users {
- if UserIsProtectedAccount(ctx, s.repo, &user) {
- continue
- }
- if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"is_active": false}); err == nil {
- disabled++
- }
- }
- return telegramCommandReply{Text: fmt.Sprintf("已禁用过期普通用户:%d 个。", disabled)}
- }
- if len(users) == 0 {
- return telegramCommandReply{Text: "没有过期用户。"}
- }
- lines := make([]string, 0, minInt(len(users), 20))
- for i, user := range users {
- if i >= 20 {
- break
- }
- lines = append(lines, fmt.Sprintf("%s(%s)", user.Username, formatExpiry(user.ExpiredAt)))
- }
- return telegramCommandReply{Text: fmt.Sprintf("过期用户:%d 个。\n%s\n\n如需禁用:/check_ex disable confirm", len(users), telegramInlineCodeList(lines))}
-}
-
-func (s *TelegramBotService) cmdMgoScanNames(ctx context.Context) telegramCommandReply {
- var rows []struct {
- Username string
- Count int64
- }
- if err := s.repo.DB.WithContext(ctx).Table("users").
- Select("LOWER(username) AS username, COUNT(*) AS count").
- Group("LOWER(username)").Having("COUNT(*) > 1").Scan(&rows).Error; err != nil {
- return telegramCommandReply{Text: "扫描失败:" + err.Error()}
- }
- if len(rows) == 0 {
- return telegramCommandReply{Text: "未发现同名用户记录。"}
- }
- var out []string
- for _, row := range rows {
- out = append(out, fmt.Sprintf("%s x%d", row.Username, row.Count))
- }
- return telegramCommandReply{Text: "同名用户记录\n" + telegramInlineCodeList(out)}
-}
-
-func (s *TelegramBotService) cmdMgoRanks(ctx context.Context, window time.Duration, byDuration bool) telegramCommandReply {
- since := time.Now().Add(-window)
- title := "播放次数排行"
- selectExpr := "COUNT(*) AS score"
- if byDuration {
- title = "观影时长排行"
- selectExpr = "COALESCE(SUM(position_ms), 0) AS score"
- }
- q := s.repo.DB.WithContext(ctx).Table("playback_histories").
- Select("users.username, " + selectExpr).
- Joins("JOIN users ON users.id = playback_histories.user_id").
- Group("users.username").
- Order("score DESC").
- Limit(10)
- if window > 0 {
- q = q.Where("playback_histories.watched_at >= ?", since)
- }
- var rows []struct {
- Username string
- Score int64
- }
- if err := q.Scan(&rows).Error; err != nil {
- return telegramCommandReply{Text: "排行查询失败:" + err.Error()}
- }
- if len(rows) == 0 {
- return telegramCommandReply{Text: "暂无排行数据。"}
- }
- var out []string
- for i, row := range rows {
- score := fmt.Sprintf("%d 次", row.Score)
- if byDuration {
- score = humanDurationFromMillis(row.Score)
- }
- out = append(out, fmt.Sprintf("%d. %s — %s", i+1, row.Username, score))
- }
- return telegramCommandReply{Text: "" + title + "\n\n" + strings.Join(out, "\n") + ""}
-}
diff --git a/internal/service/telegram_mgo_backup.go b/internal/service/telegram_mgo_backup.go
deleted file mode 100644
index 7ba58c9..0000000
--- a/internal/service/telegram_mgo_backup.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "strings"
-)
-
-func (s *TelegramBotService) cmdMgoBackupDB(ctx context.Context) telegramCommandReply {
- if s.backup == nil {
- return telegramCommandReply{Text: "备份服务暂不可用。"}
- }
- info, err := s.backup.Create(ctx)
- if err != nil {
- return telegramCommandReply{Text: "数据库备份失败:" + err.Error()}
- }
- return telegramCommandReply{Text: fmt.Sprintf("数据库备份完成:%s\n大小:%d bytes", info.Filename, info.Size)}
-}
-
-func (s *TelegramBotService) cmdMgoRestoreDB(ctx context.Context, args []string) telegramCommandReply {
- if s.backup == nil {
- return telegramCommandReply{Text: "备份服务暂不可用。"}
- }
- if len(args) == 0 || strings.EqualFold(args[0], "list") {
- items, err := s.backup.List()
- if err != nil {
- return telegramCommandReply{Text: "读取备份列表失败:" + err.Error()}
- }
- if len(items) == 0 {
- return telegramCommandReply{Text: "暂无数据库备份。可先使用 /backup_db 创建。"}
- }
- lines := make([]string, 0, minInt(len(items), 10))
- for i, item := range items {
- if i >= 10 {
- break
- }
- lines = append(lines, fmt.Sprintf("%s(%d bytes)", item.Filename, item.Size))
- }
- return telegramCommandReply{Text: "可恢复备份:\n" + telegramInlineCodeList(lines) + "\n\n恢复需要确认:/restore_from_db 文件名 confirm"}
- }
- if len(args) < 2 || !strings.EqualFold(args[len(args)-1], "confirm") {
- return telegramCommandReply{Text: "恢复数据库会覆盖当前数据,需要确认:/restore_from_db 文件名 confirm"}
- }
- filename := strings.TrimSpace(args[0])
- if err := s.backup.Restore(ctx, filename); err != nil {
- return telegramCommandReply{Text: "恢复失败:" + err.Error()}
- }
- return telegramCommandReply{Text: "数据库已从备份恢复,请重启 MediaStationGo 后生效。"}
-}
diff --git a/internal/service/telegram_mgo_bulk.go b/internal/service/telegram_mgo_bulk.go
deleted file mode 100644
index e06f554..0000000
--- a/internal/service/telegram_mgo_bulk.go
+++ /dev/null
@@ -1,94 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "strconv"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *TelegramBotService) cmdMgoRenewAll(ctx context.Context, args []string) telegramCommandReply {
- if len(args) < 2 || !strings.EqualFold(args[len(args)-1], "confirm") {
- return telegramCommandReply{Text: "批量续期需要确认:/renewall 天数 confirm"}
- }
- days, err := strconv.Atoi(args[0])
- if err != nil || days < 0 {
- return telegramCommandReply{Text: "天数必须是非负整数,0 表示永久。"}
- }
- users, err := s.repo.User.List(ctx)
- if err != nil {
- return telegramCommandReply{Text: "读取用户失败:" + err.Error()}
- }
- var count int
- for _, user := range users {
- if user.Role == "admin" {
- continue
- }
- if err := s.applyRenewal(ctx, user.ID, days); err == nil {
- count++
- }
- }
- return telegramCommandReply{Text: fmt.Sprintf("批量续期完成:%d 个普通用户。", count)}
-}
-
-func (s *TelegramBotService) cmdMgoBanAll(ctx context.Context, active bool, args []string) telegramCommandReply {
- if len(args) == 0 || !strings.EqualFold(args[len(args)-1], "confirm") {
- action := "banall"
- if active {
- action = "unbanall"
- }
- return telegramCommandReply{Text: fmt.Sprintf("批量操作需要确认:/%s confirm", action)}
- }
- users, err := s.repo.User.List(ctx)
- if err != nil {
- return telegramCommandReply{Text: "读取用户失败:" + err.Error()}
- }
- var count int
- for _, user := range users {
- if !active && UserIsProtectedAccount(ctx, s.repo, &user) {
- continue
- }
- if active && user.Role == "admin" {
- continue
- }
- updates := map[string]any{"is_active": active}
- if active {
- updates["share_warnings"] = 0
- updates["last_share_warn_at"] = nil
- }
- if err := s.repo.User.UpdateFields(ctx, user.ID, updates); err == nil {
- _ = s.repo.UserDevice.SetKickedByUser(ctx, user.ID, !active)
- count++
- }
- }
- if active {
- return telegramCommandReply{Text: fmt.Sprintf("已解禁普通用户:%d 个。", count)}
- }
- return telegramCommandReply{Text: fmt.Sprintf("已禁用普通用户:%d 个。", count)}
-}
-
-func (s *TelegramBotService) cmdMgoCallAll(ctx context.Context, channel *model.NotifyChannel, args []string) telegramCommandReply {
- message := strings.TrimSpace(strings.Join(args, " "))
- if message == "" {
- return telegramCommandReply{Text: "用法:/callall 消息内容"}
- }
- if strings.TrimSpace(s.telegramChannelConfig(channel)["bot_token"]) == "" {
- return telegramCommandReply{Text: "当前 Telegram 渠道未配置 bot_token,无法群发。"}
- }
- var bindings []model.TelegramBinding
- if err := s.repo.DB.WithContext(ctx).Find(&bindings).Error; err != nil {
- return telegramCommandReply{Text: "读取绑定失败:" + err.Error()}
- }
- sent := 0
- for _, binding := range bindings {
- if binding.ChatID == 0 {
- continue
- }
- if err := s.reply(ctx, channel, int(binding.ChatID), telegramCommandReply{Text: message}); err == nil {
- sent++
- }
- }
- return telegramCommandReply{Text: fmt.Sprintf("群发完成:成功发送 %d 个绑定用户。", sent)}
-}
diff --git a/internal/service/telegram_mgo_compat.go b/internal/service/telegram_mgo_compat.go
deleted file mode 100644
index 46f2188..0000000
--- a/internal/service/telegram_mgo_compat.go
+++ /dev/null
@@ -1,104 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "strconv"
- "strings"
- "time"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *TelegramBotService) cmdMgoUnsupported(name, replacement string) telegramCommandReply {
- text := fmt.Sprintf("%s 已识别,但当前 Telegram Bot API 无法完整复刻该行为。", name)
- if replacement != "" {
- text += "\n请使用:" + replacement
- }
- return telegramCommandReply{Text: text}
-}
-
-func (s *TelegramBotService) findMgoBotUser(ctx context.Context, target string) *model.User {
- target = strings.TrimSpace(strings.TrimPrefix(target, "@"))
- if target == "" {
- return nil
- }
- if user, _ := s.repo.User.FindByUsername(ctx, target); user != nil {
- return user
- }
- if user, _ := s.repo.User.FindByID(ctx, target); user != nil {
- return user
- }
- if tgRaw, ok := strings.CutPrefix(strings.ToLower(target), "tg:"); ok {
- if tgID, err := strconv.ParseInt(tgRaw, 10, 64); err == nil {
- var binding model.TelegramBinding
- if err := s.repo.DB.WithContext(ctx).Where("telegram_user_id = ?", tgID).First(&binding).Error; err == nil {
- user, _ := s.repo.User.FindByID(ctx, binding.UserID)
- return user
- }
- }
- }
- return nil
-}
-
-func activeLabel(user *model.User) string {
- if user == nil {
- return "未知"
- }
- if !user.IsActive {
- return "已禁用"
- }
- if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
- return "已过期"
- }
- return "正常"
-}
-
-func formatOptionalTime(t *time.Time) string {
- if t == nil {
- return "-"
- }
- return t.Format("2006-01-02 15:04")
-}
-
-func blankDash(value string) string {
- value = strings.TrimSpace(value)
- if value == "" {
- return "-"
- }
- return value
-}
-
-func telegramInlineCodeList(items []string) string {
- if len(items) == 0 {
- return ""
- }
- return "" + strings.Join(items, "、") + ""
-}
-
-func parseOnOff(raw string) *bool {
- value := strings.ToLower(strings.TrimSpace(raw))
- switch value {
- case "on", "true", "1", "yes", "enable", "enabled", "开启", "开":
- v := true
- return &v
- case "off", "false", "0", "no", "disable", "disabled", "关闭", "关":
- v := false
- return &v
- default:
- return nil
- }
-}
-
-func humanDurationFromMillis(ms int64) string {
- if ms <= 0 {
- return "0 分钟"
- }
- totalMinutes := ms / 1000 / 60
- hours := totalMinutes / 60
- minutes := totalMinutes % 60
- if hours == 0 {
- return fmt.Sprintf("%d 分钟", minutes)
- }
- return fmt.Sprintf("%d 小时 %d 分钟", hours, minutes)
-}
diff --git a/internal/service/telegram_mgo_compat_test.go b/internal/service/telegram_mgo_compat_test.go
deleted file mode 100644
index bf3abee..0000000
--- a/internal/service/telegram_mgo_compat_test.go
+++ /dev/null
@@ -1,278 +0,0 @@
-package service
-
-import (
- "strings"
- "testing"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/config"
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func TestMgoBotUserManagementAndAuditCommands(t *testing.T) {
- ctx := t.Context()
- repos, bot := newBotTestService(t)
- if err := repos.User.Create(ctx, &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}); err != nil {
- t.Fatal(err)
- }
- channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9401"}`}
- msg := &TelegramMessage{From: TelegramUser{ID: 9401, Username: "admin"}, Chat: TelegramChat{ID: 9401, Type: "private"}}
-
- reply, err := bot.executeCommand(ctx, channel, msg, "/ucr viewer secret-pass 30")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "已创建用户") {
- t.Fatalf("expected user creation, got %q", reply.Text)
- }
- viewer, err := repos.User.FindByUsername(ctx, "viewer")
- if err != nil || viewer == nil {
- t.Fatalf("viewer should exist: %v", err)
- }
- if err := repos.UserDevice.Create(ctx, &model.UserDevice{
- UserID: viewer.ID,
- DeviceID: "dev-abc",
- DeviceName: "Windows PC",
- Client: "Infuse",
- LastIP: "1.2.3.4",
- FirstSeenAt: time.Now(),
- LastSeenAt: time.Now(),
- }); err != nil {
- t.Fatal(err)
- }
-
- for _, tc := range []struct {
- command string
- want string
- }{
- {"/uinfo viewer", "用户信息"},
- {"/userip viewer", "1.2.3.4"},
- {"/auditip 1.2.3", "viewer"},
- {"/auditdevice Windows", "viewer"},
- {"/auditclient Infuse", "viewer"},
- {"/udeviceid dev-abc", "viewer"},
- } {
- t.Run(tc.command, func(t *testing.T) {
- reply, err := bot.executeCommand(ctx, channel, msg, tc.command)
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, tc.want) {
- t.Fatalf("%s expected %q in %q", tc.command, tc.want, reply.Text)
- }
- })
- }
-}
-
-func TestMgoBotBatchAndPermissionCommands(t *testing.T) {
- ctx := t.Context()
- repos, bot := newBotTestService(t)
- channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9501"}`}
- msg := &TelegramMessage{From: TelegramUser{ID: 9501, Username: "admin"}, Chat: TelegramChat{ID: 9501, Type: "private"}}
- users := []*model.User{
- {Username: "root", PasswordHash: "x", Role: "admin", IsActive: true},
- {Username: "viewer1", PasswordHash: "x", Role: "user", IsActive: true},
- {Username: "viewer2", PasswordHash: "x", Role: "user", IsActive: true},
- {Username: "viewer3", PasswordHash: "x", Role: "user", IsActive: true},
- }
- for _, user := range users {
- if err := repos.User.Create(ctx, user); err != nil {
- t.Fatal(err)
- }
- if user.Role != "admin" && user.Username != "viewer3" {
- if err := repos.Permission.Create(ctx, DefaultPermissions(user.ID)); err != nil {
- t.Fatal(err)
- }
- }
- }
-
- reply, err := bot.executeCommand(ctx, channel, msg, "/renewall 7 confirm")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "批量续期完成") {
- t.Fatalf("expected renewall success, got %q", reply.Text)
- }
- renewed, _ := repos.User.FindByUsername(ctx, "viewer1")
- if renewed.ExpiredAt == nil {
- t.Fatal("renewall should set expiry for normal users")
- }
-
- if reply, err = bot.executeCommand(ctx, channel, msg, "/embylibs_blockall"); err != nil || !strings.Contains(reply.Text, "关闭媒体播放权限") {
- t.Fatalf("expected blockall success, reply=%q err=%v", reply.Text, err)
- }
- perm, _ := repos.Permission.FindByUserID(ctx, users[1].ID)
- if perm == nil || perm.CanPlayMedia {
- t.Fatal("embylibs_blockall should disable media playback for normal users")
- }
- perm, _ = repos.Permission.FindByUserID(ctx, users[3].ID)
- if perm == nil || perm.CanPlayMedia {
- t.Fatal("embylibs_blockall should create disabled media playback permissions when missing")
- }
- if reply, err = bot.executeCommand(ctx, channel, msg, "/embylibs_unblockall"); err != nil || !strings.Contains(reply.Text, "开启媒体播放权限") {
- t.Fatalf("expected unblockall success, reply=%q err=%v", reply.Text, err)
- }
- perm, _ = repos.Permission.FindByUserID(ctx, users[1].ID)
- if perm == nil || !perm.CanPlayMedia {
- t.Fatal("embylibs_unblockall should enable media playback for normal users")
- }
-
- if reply, err = bot.executeCommand(ctx, channel, msg, "/banall confirm"); err != nil || !strings.Contains(reply.Text, "已禁用普通用户") {
- t.Fatalf("expected banall success, reply=%q err=%v", reply.Text, err)
- }
- banned, _ := repos.User.FindByUsername(ctx, "viewer2")
- if banned.IsActive {
- t.Fatal("banall should disable normal users")
- }
- if reply, err = bot.executeCommand(ctx, channel, msg, "/unbanall confirm"); err != nil || !strings.Contains(reply.Text, "已解禁普通用户") {
- t.Fatalf("expected unbanall success, reply=%q err=%v", reply.Text, err)
- }
- unbanned, _ := repos.User.FindByUsername(ctx, "viewer2")
- if !unbanned.IsActive {
- t.Fatal("unbanall should re-enable normal users")
- }
-}
-
-func TestMgoBotSyncExpiryAndBotAdminCommands(t *testing.T) {
- ctx := t.Context()
- repos, bot := newBotTestService(t)
- channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9601"}`}
- if err := repos.NotifyChannel.Create(ctx, channel); err != nil {
- t.Fatal(err)
- }
- msg := &TelegramMessage{From: TelegramUser{ID: 9601, Username: "admin"}, Chat: TelegramChat{ID: 9601, Type: "private"}}
- past := time.Now().Add(-24 * time.Hour)
- if err := repos.User.Create(ctx, &model.User{Username: "expired", PasswordHash: "x", Role: "user", IsActive: true, ExpiredAt: &past}); err != nil {
- t.Fatal(err)
- }
-
- reply, err := bot.executeCommand(ctx, channel, msg, "/syncunbound")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "expired") {
- t.Fatalf("syncunbound should list unbound users, got %q", reply.Text)
- }
- reply, err = bot.executeCommand(ctx, channel, msg, "/check_ex")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "expired") {
- t.Fatalf("check_ex should list expired users, got %q", reply.Text)
- }
- reply, err = bot.executeCommand(ctx, channel, msg, "/proadmin 9602")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "已添加") {
- t.Fatalf("proadmin should update channel config, got %q", reply.Text)
- }
- updated, _ := repos.NotifyChannel.FindByID(ctx, channel.ID)
- cfg := bot.telegramChannelConfig(updated)
- if !strings.Contains(cfg["admin_user_ids"], "9602") {
- t.Fatalf("expected admin ids to include 9602, got %#v", cfg)
- }
-}
-
-func TestMgoBotProtectedUsersAndBackupCommands(t *testing.T) {
- ctx := t.Context()
- repos, bot := newBotTestService(t)
- cfg := &config.Config{}
- cfg.App.DataDir = t.TempDir()
- bot.SetBackupService(NewBackupService(cfg, zap.NewNop(), repos.DB))
-
- channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9701"}`}
- msg := &TelegramMessage{From: TelegramUser{ID: 9701, Username: "admin"}, Chat: TelegramChat{ID: 9701, Type: "private"}}
- users := []*model.User{
- {Username: "root", PasswordHash: "x", Role: "admin", IsActive: true},
- {Username: "safe", PasswordHash: "x", Role: "user", IsActive: true},
- {Username: "normal", PasswordHash: "x", Role: "user", IsActive: true},
- }
- for _, user := range users {
- if err := repos.User.Create(ctx, user); err != nil {
- t.Fatal(err)
- }
- }
-
- reply, err := bot.executeCommand(ctx, channel, msg, "/prouser safe")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "已加入保护名单") {
- t.Fatalf("expected protect success, got %q", reply.Text)
- }
- if reason := bot.protectReason(ctx, users[1].ID); !strings.Contains(reason, "保护名单") {
- t.Fatalf("protected user should have protect reason, got %q", reason)
- }
- reply, err = bot.executeCommand(ctx, channel, msg, "/banall confirm")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "已禁用普通用户") {
- t.Fatalf("expected banall success, got %q", reply.Text)
- }
- protected, _ := repos.User.FindByUsername(ctx, "safe")
- normal, _ := repos.User.FindByUsername(ctx, "normal")
- if !protected.IsActive {
- t.Fatal("protected user should not be disabled by banall")
- }
- if normal.IsActive {
- t.Fatal("normal user should be disabled by banall")
- }
- reply, err = bot.executeCommand(ctx, channel, msg, "/revuser safe")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "已移出保护名单") {
- t.Fatalf("expected unprotect success, got %q", reply.Text)
- }
-
- reply, err = bot.executeCommand(ctx, channel, msg, "/backup_db")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "数据库备份完成") {
- t.Fatalf("backup_db should create backup, got %q", reply.Text)
- }
- reply, err = bot.executeCommand(ctx, channel, msg, "/restore_from_db list")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "mediastation_") {
- t.Fatalf("restore list should show backup, got %q", reply.Text)
- }
-}
-
-func TestMgoBotAliasesAndSyncGroupGuards(t *testing.T) {
- ctx := t.Context()
- repos, bot := newBotTestService(t)
- if err := repos.User.Create(ctx, &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}); err != nil {
- t.Fatal(err)
- }
- channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9801"}`}
- msg := &TelegramMessage{From: TelegramUser{ID: 9801, Username: "admin"}, Chat: TelegramChat{ID: 9801, Type: "private"}}
-
- reply, err := bot.executeCommand(ctx, channel, msg, "/kk")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "用户管理") {
- t.Fatalf("/kk should map to user management, got %q", reply.Text)
- }
- reply, err = bot.executeCommand(ctx, channel, msg, "/syncgroupm")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "未配置可校验成员") {
- t.Fatalf("syncgroupm should explain missing group config, got %q", reply.Text)
- }
- reply, err = bot.executeCommand(ctx, channel, msg, "/kick_not_emby")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(reply.Text, "无法枚举全部群成员") {
- t.Fatalf("kick_not_emby should explain Telegram limitation, got %q", reply.Text)
- }
-}
diff --git a/internal/service/telegram_mgo_membership.go b/internal/service/telegram_mgo_membership.go
deleted file mode 100644
index 0804483..0000000
--- a/internal/service/telegram_mgo_membership.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *TelegramBotService) cmdMgoSyncGroup(ctx context.Context, channel *model.NotifyChannel, args []string) telegramCommandReply {
- chatIDs := s.telegramMembershipChatIDs(channel)
- if len(chatIDs) == 0 {
- return telegramCommandReply{Text: "未配置可校验成员的群组/频道 ID。请在 Telegram 通知渠道设置 group_chat_id 或 channel_chat_id。"}
- }
- if strings.TrimSpace(s.telegramChannelConfig(channel)["bot_token"]) == "" {
- return telegramCommandReply{Text: "当前 Telegram 渠道未配置 bot_token,无法校验群成员。"}
- }
- var bindings []model.TelegramBinding
- if err := s.repo.DB.WithContext(ctx).Find(&bindings).Error; err != nil {
- return telegramCommandReply{Text: "读取绑定失败:" + err.Error()}
- }
- type staleBinding struct {
- User model.User
- Binding model.TelegramBinding
- }
- var stale []staleBinding
- for _, binding := range bindings {
- if binding.TelegramUserID == 0 || binding.UserID == "" {
- continue
- }
- user, _ := s.repo.User.FindByID(ctx, binding.UserID)
- if user == nil || UserIsProtectedAccount(ctx, s.repo, user) {
- continue
- }
- // 仅当所有绑定群组/频道都「查实不是成员」时才判定为可清理;
- // getChatMember 出错(membershipUnknown)时保守跳过,避免误删。
- confirmedNo := true
- for _, chatID := range chatIDs {
- if s.telegramChatMembership(ctx, channel, chatID, int(binding.TelegramUserID)) != membershipNo {
- confirmedNo = false
- break
- }
- }
- if confirmedNo {
- stale = append(stale, staleBinding{User: *user, Binding: binding})
- }
- }
- if len(stale) == 0 {
- return telegramCommandReply{Text: "所有已绑定账号都仍在配置的群组/频道中。"}
- }
- if len(args) >= 2 && strings.EqualFold(args[0], "delete") && strings.EqualFold(args[1], "confirm") {
- deleted := 0
- for _, item := range stale {
- _ = s.repo.UserDevice.DeleteByUser(ctx, item.User.ID)
- if err := s.repo.User.Delete(ctx, item.User.ID); err == nil {
- deleted++
- }
- }
- return telegramCommandReply{Text: fmt.Sprintf("已删除不在群组/频道中的普通账号:%d 个。", deleted)}
- }
- names := make([]string, 0, minInt(len(stale), 20))
- for i, item := range stale {
- if i >= 20 {
- break
- }
- names = append(names, fmt.Sprintf("%s(tg:%d)", item.User.Username, item.Binding.TelegramUserID))
- }
- return telegramCommandReply{Text: fmt.Sprintf("不在配置群组/频道中的绑定账号:%d 个。\n%s\n\n删除需确认:/syncgroupm delete confirm", len(stale), telegramInlineCodeList(names))}
-}
-
-func (s *TelegramBotService) telegramMembershipChatIDs(channel *model.NotifyChannel) []string {
- cfg := s.telegramChannelConfig(channel)
- seen := map[string]struct{}{}
- var out []string
- for _, key := range []string{"group_chat_id", "channel_chat_id", "command_chat_id"} {
- value := strings.TrimSpace(cfg[key])
- if value == "" {
- continue
- }
- if _, ok := seen[value]; !ok {
- seen[value] = struct{}{}
- out = append(out, value)
- }
- }
- if len(out) == 0 {
- if value := strings.TrimSpace(cfg["chat_id"]); strings.HasPrefix(value, "-") {
- out = append(out, value)
- }
- }
- return out
-}
diff --git a/internal/service/telegram_mgo_users.go b/internal/service/telegram_mgo_users.go
deleted file mode 100644
index 552ed3b..0000000
--- a/internal/service/telegram_mgo_users.go
+++ /dev/null
@@ -1,184 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "strconv"
- "strings"
- "time"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *TelegramBotService) cmdMgoCreateUser(ctx context.Context, args []string) telegramCommandReply {
- if len(args) < 2 {
- return telegramCommandReply{Text: "用法:/ucr 用户名 密码 [天数],天数 0 表示永久。"}
- }
- if s.auth == nil {
- return telegramCommandReply{Text: "注册服务暂不可用。"}
- }
- user, _, err := s.auth.Register(ctx, args[0], args[1])
- if err != nil {
- return telegramCommandReply{Text: "创建失败:" + err.Error()}
- }
- days := 0
- if len(args) >= 3 {
- parsed, err := strconv.Atoi(args[2])
- if err != nil || parsed < 0 {
- return telegramCommandReply{Text: "账号已创建,但天数无效。请用 /renew 用户名 天数 调整。"}
- }
- days = parsed
- if err := s.applyRenewal(ctx, user.ID, days); err != nil {
- return telegramCommandReply{Text: "账号已创建,但续期失败:" + err.Error()}
- }
- }
- return telegramCommandReply{Text: fmt.Sprintf("已创建用户:%s\n到期:%s", user.Username, formatExpiry(s.userExpiry(ctx, user.ID)))}
-}
-
-func (s *TelegramBotService) cmdMgoUserInfo(ctx context.Context, args []string) telegramCommandReply {
- if len(args) == 0 {
- return telegramCommandReply{Text: "用法:/uinfo 用户名"}
- }
- user := s.findMgoBotUser(ctx, args[0])
- if user == nil {
- return telegramCommandReply{Text: "未找到用户。"}
- }
- s.applyRealtimeUserActivity(ctx, user)
- devices, _ := s.listUserDevices(ctx, user.ID)
- var historyCount int64
- _ = s.repo.DB.WithContext(ctx).Model(&model.PlaybackHistory{}).Where("user_id = ?", user.ID).Count(&historyCount).Error
- var binding model.TelegramBinding
- tg := "未绑定"
- if err := s.repo.DB.WithContext(ctx).Where("user_id = ?", user.ID).First(&binding).Error; err == nil {
- tg = fmt.Sprintf("tg:%d", binding.TelegramUserID)
- if binding.TelegramName != "" {
- tg += " " + binding.TelegramName
- }
- }
- return telegramCommandReply{Text: fmt.Sprintf(
- "用户信息\n\n用户名:%s\n角色:%s\n状态:%s\n到期:%s\nTelegram:%s\n设备:%d\n播放记录:%d\n最后登录:%s",
- user.Username, user.Role, activeLabel(user), formatExpiry(user.ExpiredAt), tg, len(devices), historyCount, formatOptionalTime(user.LastLoginAt),
- )}
-}
-
-func (s *TelegramBotService) applyRealtimeUserActivity(ctx context.Context, user *model.User) {
- if s == nil || user == nil || s.device == nil || s.device.sessions == nil {
- return
- }
- users := []model.User{*user}
- s.device.sessions.ApplyToUsers(ctx, users)
- *user = users[0]
-}
-
-func (s *TelegramBotService) cmdMgoDeleteUser(ctx context.Context, args []string) telegramCommandReply {
- if len(args) < 2 || !strings.EqualFold(args[len(args)-1], "confirm") {
- return telegramCommandReply{Text: "删除用户需要确认:/rmemby 用户名 confirm 或 /urm 用户名 confirm"}
- }
- user := s.findMgoBotUser(ctx, args[0])
- if user == nil {
- return telegramCommandReply{Text: "未找到用户。"}
- }
- if reason := s.protectReason(ctx, user.ID); reason != "" {
- return telegramCommandReply{Text: reason}
- }
- _ = s.repo.UserDevice.DeleteByUser(ctx, user.ID)
- if err := s.repo.User.Delete(ctx, user.ID); err != nil {
- return telegramCommandReply{Text: "删除失败:" + err.Error()}
- }
- return telegramCommandReply{Text: fmt.Sprintf("已删除用户 %s。", user.Username)}
-}
-
-func (s *TelegramBotService) cmdMgoOnlyRemoveRecord(ctx context.Context, args []string) telegramCommandReply {
- if len(args) == 0 {
- return telegramCommandReply{Text: "用法:/only_rm_record tg:123456 或 /only_rm_record 用户名,只删除 Telegram 绑定记录。"}
- }
- target := strings.TrimSpace(args[0])
- var removed int64
- if raw, ok := strings.CutPrefix(strings.ToLower(target), "tg:"); ok {
- tgID, err := strconv.ParseInt(raw, 10, 64)
- if err != nil || tgID == 0 {
- return telegramCommandReply{Text: "Telegram ID 无效。"}
- }
- removed, err = s.deleteTelegramBindings(ctx, "telegram_user_id = ?", tgID)
- if err != nil {
- return telegramCommandReply{Text: "删除绑定失败:" + err.Error()}
- }
- } else {
- user := s.findMgoBotUser(ctx, target)
- if user == nil {
- return telegramCommandReply{Text: "未找到用户。"}
- }
- n, err := s.deleteTelegramBindings(ctx, "user_id = ?", user.ID)
- if err != nil {
- return telegramCommandReply{Text: "删除绑定失败:" + err.Error()}
- }
- removed = n
- }
- return telegramCommandReply{Text: fmt.Sprintf("已删除 Telegram 绑定记录:%d 条。", removed)}
-}
-
-func (s *TelegramBotService) cmdMgoUserIP(ctx context.Context, args []string) telegramCommandReply {
- if len(args) == 0 {
- return telegramCommandReply{Text: "用法:/userip 用户名"}
- }
- user := s.findMgoBotUser(ctx, args[0])
- if user == nil {
- return telegramCommandReply{Text: "未找到用户。"}
- }
- devices, err := s.listUserDevices(ctx, user.ID)
- if err != nil {
- return telegramCommandReply{Text: "查询失败:" + err.Error()}
- }
- if len(devices) == 0 {
- return telegramCommandReply{Text: "该用户暂无设备/IP记录。"}
- }
- var out []string
- for i, d := range devices {
- if i >= 20 {
- break
- }
- out = append(out, fmt.Sprintf("%d. %s / %s / %s / %s", i+1, blankDash(d.LastIP), blankDash(d.DeviceName), blankDash(d.Client), d.LastSeenAt.Format("2006-01-02 15:04")))
- }
- return telegramCommandReply{Text: "" + user.Username + " 的设备/IP\n\n" + strings.Join(out, "\n") + ""}
-}
-
-func (s *TelegramBotService) cmdMgoAuditDevices(ctx context.Context, mode string, args []string) telegramCommandReply {
- if len(args) == 0 {
- return telegramCommandReply{Text: fmt.Sprintf("用法:/%s 关键词", mode)}
- }
- keyword := strings.TrimSpace(strings.Join(args, " "))
- var rows []struct {
- Username string
- DeviceID string
- DeviceName string
- Client string
- LastIP string
- LastSeenAt time.Time
- }
- q := s.repo.DB.WithContext(ctx).Table("user_devices").
- Select("users.username, user_devices.device_id, user_devices.device_name, user_devices.client, user_devices.last_ip, user_devices.last_seen_at").
- Joins("JOIN users ON users.id = user_devices.user_id").
- Order("user_devices.last_seen_at desc").
- Limit(20)
- switch mode {
- case "auditip":
- q = q.Where("user_devices.last_ip LIKE ?", "%"+keyword+"%")
- case "auditdevice":
- q = q.Where("user_devices.device_name LIKE ? OR user_devices.device_id LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
- case "auditclient":
- q = q.Where("user_devices.client LIKE ?", "%"+keyword+"%")
- case "udeviceid":
- q = q.Where("user_devices.device_id LIKE ?", "%"+keyword+"%")
- }
- if err := q.Scan(&rows).Error; err != nil {
- return telegramCommandReply{Text: "查询失败:" + err.Error()}
- }
- if len(rows) == 0 {
- return telegramCommandReply{Text: "没有匹配记录。"}
- }
- var out []string
- for i, r := range rows {
- out = append(out, fmt.Sprintf("%d. %s / %s / %s / %s / %s", i+1, r.Username, blankDash(r.LastIP), blankDash(r.DeviceName), blankDash(r.Client), r.LastSeenAt.Format("2006-01-02 15:04")))
- }
- return telegramCommandReply{Text: "审计结果\n\n" + strings.Join(out, "\n") + ""}
-}
diff --git a/internal/service/telegram_polling.go b/internal/service/telegram_polling.go
deleted file mode 100644
index 399f1b3..0000000
--- a/internal/service/telegram_polling.go
+++ /dev/null
@@ -1,208 +0,0 @@
-package service
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "strings"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-// TelegramPollingStartResult describes what happened when local long polling
-// was requested. The admin UI uses it to avoid a silent "started" toast when
-// no Telegram channel can actually poll.
-type TelegramPollingStartResult struct {
- Message string `json:"message"`
- Started int `json:"started"`
- AlreadyRunning int `json:"already_running"`
- Skipped int `json:"skipped"`
- Errors []string `json:"errors,omitempty"`
-}
-
-// StartPolling 为所有已启用的 Telegram 通知渠道启动长轮询。
-func (s *TelegramBotService) StartPolling(ctx context.Context) TelegramPollingStartResult {
- result := TelegramPollingStartResult{Message: "telegram polling started"}
- channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram")
- if err != nil {
- s.log.Error("failed to list telegram channels for polling", zap.Error(err))
- result.Message = "failed to list telegram channels"
- result.Errors = append(result.Errors, err.Error())
- return result
- }
- if len(channels) == 0 {
- result.Message = "no telegram channels configured"
- result.Errors = append(result.Errors, "没有配置 Telegram 通知渠道")
- return result
- }
-
- for _, ch := range channels {
- if !ch.Enabled {
- result.Skipped++
- result.Errors = append(result.Errors, ch.Name+": 通知渠道未启用")
- continue
- }
- configStr := ch.Config
- if s.crypto != nil && configStr != "" {
- configStr = s.crypto.Decrypt(configStr)
- }
- var rawCfg map[string]any
- if err := json.Unmarshal([]byte(configStr), &rawCfg); err != nil {
- result.Skipped++
- result.Errors = append(result.Errors, ch.Name+": Telegram 配置解析失败: "+err.Error())
- continue
- }
- cfg := telegramStringConfigFromAny(rawCfg)
- botToken := cfg["bot_token"]
- if botToken == "" {
- result.Skipped++
- result.Errors = append(result.Errors, ch.Name+": Telegram Bot Token 为空")
- continue
- }
- s.pollingMu.Lock()
- if _, running := s.pollingCancel[botToken]; running {
- s.pollingMu.Unlock()
- result.AlreadyRunning++
- continue
- }
- s.pollingMu.Unlock()
-
- if err := registerTelegramBotCommands(ctx, cfg); err != nil && s.log != nil {
- s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err)))
- }
- if err := deleteTelegramWebhook(ctx, cfg); err != nil {
- result.Skipped++
- result.Errors = append(result.Errors, ch.Name+": "+sanitizeTelegramError(err).Error())
- continue
- }
-
- s.pollingMu.Lock()
- if _, running := s.pollingCancel[botToken]; running {
- s.pollingMu.Unlock()
- result.AlreadyRunning++
- continue
- }
- pollCtx, cancel := context.WithCancel(context.Background())
- s.pollingCancel[botToken] = cancel
- s.pollingMu.Unlock()
-
- channel := ch
- go s.pollLoop(pollCtx, cfg, &channel)
- result.Started++
- s.log.Info("started telegram polling", zap.String("channel", ch.Name))
- }
- if result.Started == 0 && result.AlreadyRunning == 0 {
- result.Message = "no enabled telegram channels started"
- }
- return result
-}
-
-// StopPolling 停止所有 Telegram 长轮询。
-func (s *TelegramBotService) StopPolling() int {
- s.pollingMu.Lock()
- defer s.pollingMu.Unlock()
- stopped := 0
- for token, cancel := range s.pollingCancel {
- cancel()
- delete(s.pollingCancel, token)
- stopped++
- }
- s.log.Info("telegram polling stopped")
- return stopped
-}
-
-// pollLoop 对单个 Bot Token 执行长轮询。
-func (s *TelegramBotService) pollLoop(ctx context.Context, cfg map[string]string, channel *model.NotifyChannel) {
- var offset int64 = 0
- pollURL, err := telegramMethodURL(cfg, cfg["bot_token"], "getUpdates")
- if err != nil {
- s.log.Warn("telegram polling config invalid", zap.Error(err))
- return
- }
- clients := telegramHTTPClients(45*time.Second, cfg)
-
- for {
- select {
- case <-ctx.Done():
- return
- default:
- }
-
- reqBody, _ := json.Marshal(map[string]interface{}{
- "offset": offset,
- "timeout": 30,
- })
- respBody, err := telegramPollingRequest(ctx, clients, pollURL, string(reqBody))
- if err != nil {
- s.log.Debug("telegram polling failed", zap.Error(err))
- time.Sleep(5 * time.Second)
- continue
- }
-
- var result struct {
- OK bool `json:"ok"`
- Result []TelegramUpdate `json:"result"`
- }
- if err := json.Unmarshal(respBody, &result); err != nil || !result.OK {
- time.Sleep(3 * time.Second)
- continue
- }
-
- for _, upd := range result.Result {
- if upd.UpdateID >= int(offset) {
- offset = int64(upd.UpdateID) + 1
- }
- if !telegramUpdateActionable(upd) {
- continue
- }
- go func(u TelegramUpdate) {
- handlerCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
- defer cancel()
- _ = s.handleTelegramUpdate(handlerCtx, u, channel)
- }(upd)
- }
- }
-}
-
-// telegramUpdateActionable 判断一条 update 是否需要分发处理。
-// 长轮询默认会返回 message 与 callback_query 两类更新;命令消息需有文本,
-// 而内联按钮回调(callback_query)必须被分发,否则成人目录显隐开关会失效。
-func telegramUpdateActionable(upd TelegramUpdate) bool {
- if upd.CallbackQuery != nil {
- return true
- }
- return upd.Message != nil && upd.Message.Text != ""
-}
-
-func telegramPollingRequest(ctx context.Context, clients []*http.Client, pollURL, body string) ([]byte, error) {
- var lastErr error
- for _, client := range clients {
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, pollURL, strings.NewReader(body))
- if err != nil {
- return nil, err
- }
- req.Header.Set("Content-Type", "application/json")
- resp, err := client.Do(req)
- if err != nil {
- lastErr = sanitizeTelegramError(err)
- continue
- }
- respBody, _ := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
- if resp.StatusCode >= 400 {
- lastErr = fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(respBody)))
- continue
- }
- return respBody, nil
- }
- if lastErr != nil {
- return nil, lastErr
- }
- return nil, errors.New("telegram polling failed")
-}
diff --git a/internal/service/telegram_redeem.go b/internal/service/telegram_redeem.go
deleted file mode 100644
index dc9862f..0000000
--- a/internal/service/telegram_redeem.go
+++ /dev/null
@@ -1,185 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "strings"
- "time"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
- "gorm.io/gorm"
-)
-
-var (
- errRegistrationCodeAlreadyUsed = errors.New("registration code already used")
- errRegistrationCodeExpired = errors.New("registration code expired")
-)
-
-func (s *TelegramBotService) cmdRedeem(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, args []string) telegramCommandReply {
- if len(args) == 0 {
- return telegramCommandReply{Text: "请发送:/redeem 兑换码\n未绑定账号时自动尝试注册码;已绑定账号时自动尝试续期码。"}
- }
- code := strings.Join(args, " ")
- if s.boundUser(ctx, msg.From.ID) == nil {
- return s.redeemRegisterFlow(ctx, channel, msg, code)
- }
- return s.redeemRenewFlow(ctx, msg, code)
-}
-
-func (s *TelegramBotService) cmdRedeemRegister(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, args []string) telegramCommandReply {
- if len(args) == 0 {
- return telegramCommandReply{Text: "请发送:/redeem_register 注册兑换码"}
- }
- return s.redeemRegisterFlow(ctx, channel, msg, strings.Join(args, " "))
-}
-
-func (s *TelegramBotService) cmdRedeemRenew(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
- if len(args) == 0 {
- return telegramCommandReply{Text: "请发送:/redeem_renew 续期兑换码"}
- }
- return s.redeemRenewFlow(ctx, msg, strings.Join(args, " "))
-}
-
-func (s *TelegramBotService) redeemRegisterFlow(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, raw string) telegramCommandReply {
- if channel == nil {
- channel = s.findChannelForMessage(ctx, msg)
- }
- if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed {
- return telegramCommandReply{Text: telegramBindRejectText(dec, "兑换注册账号")}
- }
- rc, errMsg := s.lookupRedeemableCode(ctx, raw, model.RegistrationCodeRegister)
- if rc == nil {
- return telegramCommandReply{Text: errMsg}
- }
- if s.auth == nil {
- return telegramCommandReply{Text: "注册服务暂不可用。"}
- }
- if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil {
- if u, _ := s.repo.User.FindByID(ctx, binding.UserID); u != nil {
- return telegramCommandReply{Text: fmt.Sprintf("当前 Telegram 已绑定账号 %s,无需再用注册码。", u.Username)}
- }
- }
- user, password, claimedCode, err := s.createUserFromRegistrationCode(ctx, rc.Code)
- if err != nil {
- if errors.Is(err, errRegistrationCodeAlreadyUsed) {
- return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"}
- }
- if errors.Is(err, errRegistrationCodeExpired) {
- return telegramCommandReply{Text: "兑换码已过期。"}
- }
- if errors.Is(err, ErrUserLimitReached) {
- return telegramCommandReply{Text: "注册失败:用户数量已达授权上限。"}
- }
- return telegramCommandReply{Text: "注册失败:" + err.Error()}
- }
- if claimedCode == nil {
- return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"}
- }
- _ = s.upsertTelegramBinding(ctx, msg, user.ID)
- return telegramCommandReply{
- Text: fmt.Sprintf("兑换成功并已创建账号:\n用户名:%s\n密码:%s\n到期:%s\n\n请尽快用「改用户名/改密码」修改为你自己的凭据。",
- user.Username, password, formatExpiry(s.userExpiry(ctx, user.ID))),
- Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}},
- }
-}
-
-func (s *TelegramBotService) createUserFromRegistrationCode(ctx context.Context, rawCode string) (*model.User, string, *model.RegistrationCode, error) {
- code := normalizeRedemptionCode(rawCode)
- if code == "" {
- return nil, "", nil, errRegistrationCodeAlreadyUsed
- }
- password := randomCode(10)
- var created model.User
- var claimed model.RegistrationCode
- err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
- if err := tx.Where("code = ? AND kind = ? AND used_at IS NULL AND used_count < CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END", code, model.RegistrationCodeRegister).
- First(&claimed).Error; err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return errRegistrationCodeAlreadyUsed
- }
- return err
- }
- if claimed.IsExpired() {
- return errRegistrationCodeExpired
- }
- var count int64
- if err := tx.Model(&model.User{}).Count(&count).Error; err != nil {
- return err
- }
- if count >= LicensedMaxUsers(ctx, s.repo) {
- return ErrUserLimitReached
- }
- hash, err := hashPassword(password)
- if err != nil {
- return err
- }
- codePrefix := strings.ToLower(claimed.Code)
- if len(codePrefix) > 8 {
- codePrefix = codePrefix[:8]
- }
- created = model.User{
- Username: "u" + codePrefix,
- PasswordHash: hash,
- Role: "user",
- Tier: "free",
- HideAdult: true,
- ExpiredAt: renewExpiry(nil, claimed.DurationDays),
- }
- if err := tx.Create(&created).Error; err != nil {
- return err
- }
- if err := tx.Create(DefaultPermissions(created.ID)).Error; err != nil {
- return err
- }
- now := time.Now()
- res := tx.Model(&model.RegistrationCode{}).
- Where("id = ? AND used_at IS NULL AND used_count < CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END", claimed.ID).
- Updates(map[string]any{
- "used_by_user_id": created.ID,
- "used_count": gorm.Expr("used_count + 1"),
- "used_at": gorm.Expr("CASE WHEN used_count + 1 >= CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END THEN ? ELSE used_at END", now),
- })
- if res.Error != nil {
- return res.Error
- }
- if res.RowsAffected == 0 {
- return errRegistrationCodeAlreadyUsed
- }
- claimed.UsedByUserID = created.ID
- claimed.UsedCount++
- if claimed.UsedCount >= claimed.EffectiveMaxUses() {
- claimed.UsedAt = &now
- }
- return nil
- })
- if err != nil {
- return nil, "", nil, err
- }
- return &created, password, &claimed, nil
-}
-
-func (s *TelegramBotService) redeemRenewFlow(ctx context.Context, msg *TelegramMessage, raw string) telegramCommandReply {
- user := s.boundUser(ctx, msg.From.ID)
- if user == nil {
- return telegramCommandReply{Text: "请先绑定账号再续期。"}
- }
- rc, errMsg := s.lookupRedeemableCode(ctx, raw, model.RegistrationCodeRenew)
- if rc == nil {
- return telegramCommandReply{Text: errMsg}
- }
- if err := s.repo.RegCode.MarkUsed(ctx, rc.ID, user.ID); err != nil {
- return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"}
- }
- if err := s.applyRenewal(ctx, user.ID, rc.DurationDays); err != nil {
- return telegramCommandReply{Text: "续期失败:" + err.Error()}
- }
- return telegramCommandReply{Text: fmt.Sprintf("续期成功 ✅ 当前到期:%s", formatExpiry(s.userExpiry(ctx, user.ID)))}
-}
-
-func (s *TelegramBotService) userExpiry(ctx context.Context, userID string) *time.Time {
- if u, _ := s.repo.User.FindByID(ctx, userID); u != nil {
- return u.ExpiredAt
- }
- return nil
-}
diff --git a/internal/service/telegram_reply.go b/internal/service/telegram_reply.go
deleted file mode 100644
index f710cd2..0000000
--- a/internal/service/telegram_reply.go
+++ /dev/null
@@ -1,127 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "strconv"
- "strings"
- "time"
-
- "go.uber.org/zap"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-const defaultTelegramMessageDeleteDelay = 120 * time.Second
-
-type telegramSendMessageResponse struct {
- OK bool `json:"ok"`
- Result struct {
- MessageID int `json:"message_id"`
- } `json:"result"`
-}
-
-// reply 通过 Telegram Bot API 发送回复消息。
-func (s *TelegramBotService) reply(ctx context.Context, channel *model.NotifyChannel, chatID int, reply telegramCommandReply) error {
- cfg := s.telegramChannelConfig(channel)
- if strings.TrimSpace(cfg["bot_token"]) == "" {
- return fmt.Errorf("bot_token not configured")
- }
-
- payload := map[string]interface{}{
- "chat_id": strconv.Itoa(chatID),
- "text": reply.Text,
- "parse_mode": "HTML",
- }
- if len(reply.Buttons) > 0 {
- keyboard := make([][]map[string]string, 0, len(reply.Buttons))
- for _, row := range reply.Buttons {
- buttons := make([]map[string]string, 0, len(row))
- for _, button := range row {
- buttons = append(buttons, map[string]string{
- "text": button.Text,
- "callback_data": button.Data,
- })
- }
- keyboard = append(keyboard, buttons)
- }
- payload["reply_markup"] = map[string]interface{}{"inline_keyboard": keyboard}
- }
- var sent telegramSendMessageResponse
- if err := telegramPostJSONDecode(ctx, cfg, "sendMessage", payload, 15*time.Second, &sent); err != nil {
- return err
- }
- if sent.Result.MessageID > 0 {
- s.scheduleTelegramMessageDelete(cfg, chatID, sent.Result.MessageID)
- }
- return nil
-}
-
-func (s *TelegramBotService) replyForMessage(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, reply telegramCommandReply) error {
- if msg == nil {
- return nil
- }
- if strings.TrimSpace(reply.Text) == "" {
- return nil
- }
- return s.reply(ctx, channel, msg.Chat.ID, reply)
-}
-
-func (s *TelegramBotService) deleteTelegramSourceMessage(channel *model.NotifyChannel, chatID, messageID int) {
- if messageID <= 0 {
- return
- }
- s.scheduleTelegramMessageDelete(s.telegramChannelConfig(channel), chatID, messageID)
-}
-
-func (s *TelegramBotService) scheduleTelegramMessageDelete(cfg map[string]string, chatID, messageID int) {
- if chatID == 0 || messageID <= 0 || strings.TrimSpace(cfg["bot_token"]) == "" {
- return
- }
- delay := telegramMessageDeleteDelay(cfg)
- if delay < 0 {
- return
- }
- cfgCopy := make(map[string]string, len(cfg))
- for k, v := range cfg {
- cfgCopy[k] = v
- }
- go func() {
- if delay > 0 {
- timer := time.NewTimer(delay)
- defer timer.Stop()
- <-timer.C
- }
- deleteCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- err := telegramPostJSON(deleteCtx, cfgCopy, "deleteMessage", map[string]interface{}{
- "chat_id": strconv.Itoa(chatID),
- "message_id": messageID,
- }, 10*time.Second)
- if err != nil && s.log != nil {
- s.log.Debug("telegram deleteMessage failed",
- zap.Int("chat_id", chatID),
- zap.Int("message_id", messageID),
- zap.Error(sanitizeTelegramError(err)),
- )
- }
- }()
-}
-
-func telegramMessageDeleteDelay(cfg map[string]string) time.Duration {
- for _, key := range []string{"auto_delete_seconds", "message_delete_seconds", "delete_after_seconds"} {
- raw := strings.TrimSpace(cfg[key])
- if raw == "" {
- continue
- }
- seconds, err := strconv.Atoi(raw)
- if err != nil {
- continue
- }
- if seconds < 0 {
- return -1
- }
- return time.Duration(seconds) * time.Second
- }
- return defaultTelegramMessageDeleteDelay
-}
diff --git a/internal/service/telegram_stats.go b/internal/service/telegram_stats.go
deleted file mode 100644
index faf62b9..0000000
--- a/internal/service/telegram_stats.go
+++ /dev/null
@@ -1,224 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "strings"
-
- "gorm.io/gorm"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-// cmdStatus 处理 /status 命令。
-func (s *TelegramBotService) cmdStatus(ctx context.Context) (telegramCommandReply, error) {
- libraryIDs, err := s.activeTelegramStatsLibraryIDs(ctx)
- if err != nil {
- return telegramCommandReply{}, err
- }
- var mediaCount int64
- s.mediaStatsQuery(libraryIDs).Count(&mediaCount)
-
- var totalSize int64
- if err := s.mediaStatsQuery(libraryIDs).Select("COALESCE(SUM(size_bytes), 0)").Row().Scan(&totalSize); err != nil {
- return telegramCommandReply{}, err
- }
- totalSizeGB := float64(totalSize) / 1024 / 1024 / 1024
-
- return telegramCommandReply{Text: fmt.Sprintf(
- "系统运行状态\n\n"+
- "🎬 媒体总数: %d\n"+
- "💾 存储占用: %.1f GB",
- mediaCount, totalSizeGB,
- )}, nil
-}
-
-// cmdSearch 处理 /search 命令。
-func (s *TelegramBotService) cmdSearch(ctx context.Context, args []string) (telegramCommandReply, error) {
- if len(args) == 0 {
- return telegramCommandReply{Text: "请提供搜索关键词\n例: /search 哥斯拉"}, nil
- }
-
- keyword := strings.Join(args, " ")
- var results []model.Media
- err := s.repo.DB.Where("title LIKE ?", "%"+keyword+"%").
- Order("year DESC").Limit(8).
- Find(&results).Error
- if err != nil {
- return telegramCommandReply{}, err
- }
-
- if len(results) == 0 {
- return telegramCommandReply{Text: fmt.Sprintf("未找到与 %s 相关的媒体", keyword)}, nil
- }
-
- var sb strings.Builder
- sb.WriteString(fmt.Sprintf("搜索: %s\n\n", keyword))
- for i, m := range results {
- year := ""
- if m.Year > 0 {
- year = fmt.Sprintf(" (%d)", m.Year)
- }
- ep := ""
- if m.SeasonNum > 0 && m.EpisodeNum > 0 {
- ep = fmt.Sprintf(" S%02dE%02d", m.SeasonNum, m.EpisodeNum)
- }
- sb.WriteString(fmt.Sprintf("%d. %s%s%s — %s\n", i+1, m.Title, year, ep, formatSize(m.SizeBytes)))
- }
-
- return telegramCommandReply{Text: sb.String()}, nil
-}
-
-// cmdDownloads 处理 /downloads 命令。
-func (s *TelegramBotService) cmdDownloads(ctx context.Context) (telegramCommandReply, error) {
- type Row struct {
- Title string
- Status string
- }
- var rows []Row
- if err := s.repo.DB.Raw(
- "SELECT COALESCE(NULLIF(title,''),'下载任务') as title, COALESCE(status,'unknown') as status FROM download_tasks ORDER BY created_at DESC LIMIT 8",
- ).Scan(&rows).Error; err != nil {
- return telegramCommandReply{}, err
- }
-
- if len(rows) == 0 {
- return telegramCommandReply{Text: "当前没有下载任务。"}, nil
- }
-
- var sb strings.Builder
- sb.WriteString(fmt.Sprintf("下载任务 (%d)\n\n", len(rows)))
- for _, r := range rows {
- icon := "⏳"
- switch r.Status {
- case "completed":
- icon = "✅"
- case "downloading":
- icon = "📥"
- case "error":
- icon = "❌"
- }
- name := strings.TrimSpace(r.Title)
- if name == "" {
- name = "下载任务"
- }
- if len(name) > 60 {
- name = name[:57] + "..."
- }
- sb.WriteString(fmt.Sprintf("%s %s\n", icon, name))
- }
-
- return telegramCommandReply{Text: sb.String()}, nil
-}
-
-// cmdStats 处理 /stats 命令。
-func (s *TelegramBotService) cmdStats(ctx context.Context) (telegramCommandReply, error) {
- libs, err := s.activeTelegramStatsLibraries(ctx)
- if err != nil {
- return telegramCommandReply{}, err
- }
- libraryIDs := make([]string, 0, len(libs))
- for _, lib := range libs {
- libraryIDs = append(libraryIDs, lib.ID)
- }
- var totalMedia int64
- s.mediaStatsQuery(libraryIDs).Count(&totalMedia)
-
- var totalSize int64
- if err := s.mediaStatsQuery(libraryIDs).Select("COALESCE(SUM(size_bytes), 0)").Row().Scan(&totalSize); err != nil {
- return telegramCommandReply{}, err
- }
-
- type LibStat struct {
- Name string
- Type string
- Count int64
- }
- stats := make([]LibStat, 0, len(libs))
- for _, lib := range libs {
- var count int64
- if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("library_id = ?", lib.ID).Count(&count).Error; err != nil {
- return telegramCommandReply{}, err
- }
- stats = append(stats, LibStat{Name: lib.Name, Type: lib.Type, Count: count})
- }
-
- var sb strings.Builder
- sb.WriteString("媒体库统计\n\n")
- sb.WriteString(fmt.Sprintf("📚 总数: %d\n", totalMedia))
- sb.WriteString(fmt.Sprintf("💾 大小: %s\n", formatSize(totalSize)))
-
- if len(stats) > 0 {
- sb.WriteString("\n各库分布:\n")
- for _, l := range stats {
- icon := "🎬"
- switch l.Type {
- case "tv":
- icon = "📺"
- case "anime":
- icon = "🍥"
- case "music":
- icon = "🎵"
- }
- sb.WriteString(fmt.Sprintf("%s %s: %d\n", icon, l.Name, l.Count))
- }
- }
-
- return telegramCommandReply{Text: sb.String()}, nil
-}
-
-func (s *TelegramBotService) activeTelegramStatsLibraries(ctx context.Context) ([]model.Library, error) {
- if s == nil || s.repo == nil || s.repo.Library == nil {
- return nil, nil
- }
- libs, err := s.repo.Library.List(ctx)
- if err != nil {
- return nil, err
- }
- libs = FilterDisplayCloudLibraries(ctx, s.repo, libs)
- out := libs[:0]
- for _, lib := range libs {
- if lib.Enabled {
- out = append(out, lib)
- }
- }
- return out, nil
-}
-
-func (s *TelegramBotService) activeTelegramStatsLibraryIDs(ctx context.Context) ([]string, error) {
- libs, err := s.activeTelegramStatsLibraries(ctx)
- if err != nil {
- return nil, err
- }
- ids := make([]string, 0, len(libs))
- for _, lib := range libs {
- ids = append(ids, lib.ID)
- }
- return ids, nil
-}
-
-func (s *TelegramBotService) mediaStatsQuery(libraryIDs []string) *gorm.DB {
- q := s.repo.DB.Model(&model.Media{})
- if len(libraryIDs) == 0 {
- return q.Where("1 = 0")
- }
- return q.Where("library_id IN ?", libraryIDs)
-}
-
-// formatSize 格式化字节数为可读字符串。
-func formatSize(bytes int64) string {
- if bytes <= 0 {
- return "0 B"
- }
- units := []string{"B", "KB", "MB", "GB", "TB"}
- v := float64(bytes)
- i := 0
- for v >= 1024 && i < len(units)-1 {
- v /= 1024
- i++
- }
- if i == 0 {
- return fmt.Sprintf("%.0f %s", v, units[i])
- }
- return fmt.Sprintf("%.1f %s", v, units[i])
-}
diff --git a/internal/service/telegram_unbind.go b/internal/service/telegram_unbind.go
deleted file mode 100644
index d496eee..0000000
--- a/internal/service/telegram_unbind.go
+++ /dev/null
@@ -1,224 +0,0 @@
-package service
-
-import (
- "context"
- "fmt"
- "strconv"
- "strings"
- "time"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *TelegramBotService) cmdUnbind(ctx context.Context, args []string) telegramCommandReply {
- targets := parseTelegramUnbindTargets(args)
- if len(targets) == 0 {
- return telegramCommandReply{Text: "用法:/unbind 用户名1 用户名2\n也支持逗号分隔,或使用 tg:TelegramID 按 Telegram ID 解绑。此命令只解绑 Bot,不删除媒体账号。"}
- }
- var removed int64
- var done []string
- var skipped []string
- var missing []string
- for _, target := range targets {
- if tgIDRaw, ok := strings.CutPrefix(strings.ToLower(target), "tg:"); ok {
- tgID, err := strconv.ParseInt(tgIDRaw, 10, 64)
- if err != nil || tgID == 0 {
- missing = append(missing, target)
- continue
- }
- n, err := s.deleteTelegramBindings(ctx, "telegram_user_id = ?", tgID)
- if err != nil {
- return telegramCommandReply{Text: "解绑失败:" + err.Error()}
- }
- if n == 0 {
- missing = append(missing, target)
- continue
- }
- removed += n
- done = append(done, target)
- continue
- }
-
- user, _ := s.repo.User.FindByUsername(ctx, target)
- if user == nil {
- user, _ = s.repo.User.FindByID(ctx, target)
- }
- if user == nil {
- missing = append(missing, target)
- continue
- }
- if user.Role == "admin" {
- skipped = append(skipped, user.Username+"(管理员)")
- continue
- }
- n, err := s.deleteTelegramBindings(ctx, "user_id = ?", user.ID)
- if err != nil {
- return telegramCommandReply{Text: "解绑失败:" + err.Error()}
- }
- if n == 0 {
- missing = append(missing, user.Username+"(未绑定)")
- continue
- }
- removed += n
- done = append(done, user.Username)
- }
- return formatUnbindResult("批量解绑完成", removed, done, skipped, missing)
-}
-
-func (s *TelegramBotService) cmdUnbindDuplicates(ctx context.Context) telegramCommandReply {
- if s == nil || s.repo == nil || s.repo.DB == nil {
- return telegramCommandReply{Text: "仓库不可用。"}
- }
- var bindings []model.TelegramBinding
- if err := s.repo.DB.WithContext(ctx).Order("updated_at desc, created_at desc").Find(&bindings).Error; err != nil {
- return telegramCommandReply{Text: "读取绑定失败:" + err.Error()}
- }
- seenTelegram := make(map[int64]string)
- seenUser := make(map[string]string)
- var removeIDs []string
- var removedLabels []string
- for _, binding := range bindings {
- remove := false
- if binding.UserID == "" || binding.TelegramUserID == 0 {
- remove = true
- } else if user, _ := s.repo.User.FindByID(ctx, binding.UserID); user == nil {
- remove = true
- } else if _, ok := seenTelegram[binding.TelegramUserID]; ok {
- remove = true
- } else if _, ok := seenUser[binding.UserID]; ok {
- remove = true
- }
- if remove {
- removeIDs = append(removeIDs, binding.ID)
- removedLabels = append(removedLabels, fmt.Sprintf("tg:%d", binding.TelegramUserID))
- continue
- }
- seenTelegram[binding.TelegramUserID] = binding.ID
- seenUser[binding.UserID] = binding.ID
- }
- if len(removeIDs) == 0 {
- return telegramCommandReply{Text: "未发现重复或无效绑定。"}
- }
- n, err := s.deleteTelegramBindings(ctx, "id IN ?", removeIDs)
- if err != nil {
- return telegramCommandReply{Text: "清理失败:" + err.Error()}
- }
- return formatUnbindResult("重复/无效绑定清理完成", n, removedLabels, nil, nil)
-}
-
-func (s *TelegramBotService) cmdUnbindInactive(ctx context.Context, args []string) telegramCommandReply {
- if len(args) == 0 {
- return telegramCommandReply{Text: "用法:/unbind_inactive 天数\n例如 /unbind_inactive 30 会解绑 30 天未登录的普通用户 Bot 绑定,不删除账号。"}
- }
- days, err := strconv.Atoi(strings.TrimSpace(args[0]))
- if err != nil || days < 1 {
- return telegramCommandReply{Text: "天数必须是大于 0 的整数。"}
- }
- users, err := s.repo.User.List(ctx)
- if err != nil {
- return telegramCommandReply{Text: "读取用户失败:" + err.Error()}
- }
- cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
- recentWindow := time.Duration(days) * 24 * time.Hour
- var userIDs []string
- var done []string
- for _, user := range users {
- if user.Role == "admin" {
- continue
- }
- lastActive := user.CreatedAt
- if user.LastLoginAt != nil {
- lastActive = *user.LastLoginAt
- }
- if lastActive.IsZero() || lastActive.After(cutoff) {
- continue
- }
- if s.device != nil && s.device.UserRecentlyActive(ctx, user.ID, recentWindow) {
- continue
- }
- var count int64
- _ = s.repo.DB.WithContext(ctx).Model(&model.TelegramBinding{}).Where("user_id = ?", user.ID).Count(&count).Error
- if count == 0 {
- continue
- }
- userIDs = append(userIDs, user.ID)
- done = append(done, user.Username)
- }
- if len(userIDs) == 0 {
- return telegramCommandReply{Text: fmt.Sprintf("未发现 %d 天未登录且已绑定 Bot 的普通用户。", days)}
- }
- n, err := s.deleteTelegramBindings(ctx, "user_id IN ?", userIDs)
- if err != nil {
- return telegramCommandReply{Text: "解绑失败:" + err.Error()}
- }
- return formatUnbindResult(fmt.Sprintf("已解绑 %d 天未登录用户", days), n, done, nil, nil)
-}
-
-func parseTelegramUnbindTargets(args []string) []string {
- seen := make(map[string]struct{})
- var targets []string
- for _, arg := range args {
- for _, part := range strings.FieldsFunc(arg, func(r rune) bool {
- return r == ',' || r == ',' || r == ';' || r == ';' || r == '\n' || r == '\t'
- }) {
- part = strings.TrimSpace(part)
- if part == "" {
- continue
- }
- key := strings.ToLower(part)
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- targets = append(targets, part)
- }
- }
- return targets
-}
-
-func (s *TelegramBotService) deleteTelegramBindings(ctx context.Context, query string, args ...interface{}) (int64, error) {
- if s == nil || s.repo == nil || s.repo.DB == nil {
- return 0, nil
- }
- tx := s.repo.DB.WithContext(ctx).Unscoped().Where(query, args...).Delete(&model.TelegramBinding{})
- return tx.RowsAffected, tx.Error
-}
-
-func formatUnbindResult(title string, removed int64, done, skipped, missing []string) telegramCommandReply {
- var sb strings.Builder
- sb.WriteString("")
- sb.WriteString(title)
- sb.WriteString("\n\n")
- sb.WriteString(fmt.Sprintf("已解绑:%d 条绑定", removed))
- if len(done) > 0 {
- sb.WriteString("\n目标:")
- sb.WriteString(formatShortList(done, 12))
- }
- if len(skipped) > 0 {
- sb.WriteString("\n跳过:")
- sb.WriteString(formatShortList(skipped, 8))
- }
- if len(missing) > 0 {
- sb.WriteString("\n未找到/未绑定:")
- sb.WriteString(formatShortList(missing, 8))
- }
- return telegramCommandReply{Text: sb.String()}
-}
-
-func formatShortList(items []string, limit int) string {
- if len(items) == 0 {
- return ""
- }
- if limit < 1 {
- limit = 1
- }
- out := items
- if len(out) > limit {
- out = out[:limit]
- }
- text := "" + strings.Join(out, "、") + ""
- if len(items) > limit {
- text += fmt.Sprintf(" 等 %d 项", len(items))
- }
- return text
-}
diff --git a/internal/service/telegram_user_self.go b/internal/service/telegram_user_self.go
deleted file mode 100644
index 3588129..0000000
--- a/internal/service/telegram_user_self.go
+++ /dev/null
@@ -1,241 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "strconv"
- "strings"
-
- "github.com/ShukeBta/MediaStationGo/internal/model"
-)
-
-func (s *TelegramBotService) cmdKick(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
- user := s.boundUser(ctx, msg.From.ID)
- if user == nil {
- return telegramCommandReply{Text: "请先绑定账号:/start 用户名 密码"}
- }
- if len(args) == 0 {
- return telegramCommandReply{Text: "请指定要踢下线的设备:/kick all 或 /kick 设备编号。先用 /devices 查看编号。"}
- }
- target := strings.TrimSpace(args[0])
- if strings.EqualFold(target, "all") || target == "全部" {
- if s.device != nil {
- if err := s.device.KickAllDevices(ctx, user.ID); err != nil {
- return telegramCommandReply{Text: "踢下线失败:" + err.Error()}
- }
- } else if err := s.repo.UserDevice.SetKickedByUser(ctx, user.ID, true); err != nil {
- return telegramCommandReply{Text: "踢下线失败:" + err.Error()}
- }
- return telegramCommandReply{Text: "已踢下线此账号的全部设备。"}
- }
- devices, _ := s.listUserDevices(ctx, user.ID)
- if len(devices) == 0 {
- return telegramCommandReply{Text: "当前没有记录到登录设备。"}
- }
- var chosen *model.UserDevice
- if n, err := strconv.Atoi(target); err == nil && n >= 1 && n <= len(devices) {
- chosen = &devices[n-1]
- } else {
- for i := range devices {
- if devices[i].ID == target || devices[i].DeviceID == target {
- chosen = &devices[i]
- break
- }
- }
- }
- if chosen == nil {
- return telegramCommandReply{Text: "未找到该设备。请用 /devices 查看设备编号后重试。"}
- }
- if s.device != nil {
- if err := s.device.KickDevice(ctx, user.ID, chosen.DeviceID); err != nil {
- return telegramCommandReply{Text: "踢下线失败:" + err.Error()}
- }
- } else if fp := strings.TrimSpace(chosen.Fingerprint); fp != "" {
- if err := s.repo.UserDevice.SetKickedByFingerprint(ctx, user.ID, fp, true); err != nil {
- return telegramCommandReply{Text: "踢下线失败:" + err.Error()}
- }
- } else if err := s.repo.UserDevice.SetKicked(ctx, chosen.ID, true); err != nil {
- return telegramCommandReply{Text: "踢下线失败:" + err.Error()}
- }
- return telegramCommandReply{Text: fmt.Sprintf("已踢下线:%s。", deviceLabel(chosen.DeviceName, chosen.Client))}
-}
-
-func (s *TelegramBotService) cmdSetName(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
- if len(args) < 2 {
- return telegramCommandReply{Text: "请发送:/setname 当前密码 新用户名"}
- }
- return s.selfSetName(ctx, msg, strings.Join(args, " "))
-}
-
-func (s *TelegramBotService) cmdSetPass(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
- if len(args) < 2 {
- return telegramCommandReply{Text: "请发送:/setpass 当前密码 新密码"}
- }
- return s.selfSetPass(ctx, msg, strings.Join(args, " "))
-}
-
-func (s *TelegramBotService) replyAccount(ctx context.Context, msg *TelegramMessage) telegramCommandReply {
- user := s.boundUser(ctx, msg.From.ID)
- if user == nil {
- return telegramCommandReply{Text: "请先绑定账号:/start 用户名 密码"}
- }
- streak := 0
- if rec, _ := s.repo.SignIn.Get(ctx, user.ID); rec != nil {
- streak = rec.StreakDays
- }
- devices, _ := s.listUserDevices(ctx, user.ID)
- text := fmt.Sprintf("我的账号\n\n用户名:%s\n状态:%s\n到期:%s\n连续签到:%d 天\n登录设备:%d 台",
- user.Username,
- map[bool]string{true: "正常", false: "已禁用"}[user.IsActive],
- formatExpiry(user.ExpiredAt), streak, len(devices))
- return telegramCommandReply{Text: text, Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}}
-}
-
-func (s *TelegramBotService) replySignIn(ctx context.Context, msg *TelegramMessage) telegramCommandReply {
- user := s.boundUser(ctx, msg.From.ID)
- if user == nil {
- return telegramCommandReply{Text: "请先绑定账号后再签到。"}
- }
- res, err := s.signIn(ctx, user.ID)
- if err != nil {
- return telegramCommandReply{Text: "签到失败:" + err.Error()}
- }
- if res.AlreadySigned {
- return telegramCommandReply{Text: fmt.Sprintf("今天已经签到过啦~\n连续签到 %d 天,累计 %d 天。", res.Streak, res.Total)}
- }
- return telegramCommandReply{Text: fmt.Sprintf("签到成功 ✅\n连续签到 %d 天,累计 %d 天。", res.Streak, res.Total)}
-}
-
-func (s *TelegramBotService) replyDevices(ctx context.Context, msg *TelegramMessage) telegramCommandReply {
- user := s.boundUser(ctx, msg.From.ID)
- if user == nil {
- return telegramCommandReply{Text: "请先绑定账号。"}
- }
- devices, _ := s.listUserDevices(ctx, user.ID)
- if len(devices) == 0 {
- return telegramCommandReply{Text: "当前没有记录到登录设备。"}
- }
- var sb strings.Builder
- sb.WriteString("我的登录设备\n点击下方按钮可一键踢下线:\n")
- var rows [][]telegramInlineButton
- for i, d := range devices {
- status := ""
- if d.Kicked {
- status = "(已踢下线)"
- } else if d.Playing {
- status = "(播放中)"
- } else if d.Online {
- status = "(在线)"
- }
- sb.WriteString(fmt.Sprintf("\n%d. %s%s\n 最近活跃:%s", i+1, deviceLabel(d.DeviceName, d.Client), status, d.LastSeenAt.Format("01-02 15:04")))
- if !d.Kicked && !strings.HasPrefix(d.ID, "rt:") {
- rows = append(rows, []telegramInlineButton{{Text: "🚫 踢下线:" + deviceLabel(d.DeviceName, d.Client), Data: "kick:" + d.ID}})
- }
- }
- rows = append(rows, []telegramInlineButton{{Text: "⬅️ 返回菜单", Data: "menu_main"}})
- return telegramCommandReply{Text: sb.String(), Buttons: rows}
-}
-
-func (s *TelegramBotService) replyKick(ctx context.Context, msg *TelegramMessage, deviceRowID string) telegramCommandReply {
- user := s.boundUser(ctx, msg.From.ID)
- if user == nil {
- return telegramCommandReply{Text: "请先绑定账号。"}
- }
- var d model.UserDevice
- if err := s.repo.DB.WithContext(ctx).Where("id = ? AND user_id = ?", deviceRowID, user.ID).First(&d).Error; err != nil {
- return telegramCommandReply{Text: "未找到该设备。"}
- }
- if s.device != nil {
- if err := s.device.KickDevice(ctx, user.ID, d.DeviceID); err != nil {
- return telegramCommandReply{Text: "操作失败:" + err.Error()}
- }
- } else if fp := strings.TrimSpace(d.Fingerprint); fp != "" {
- if err := s.repo.UserDevice.SetKickedByFingerprint(ctx, user.ID, fp, true); err != nil {
- return telegramCommandReply{Text: "操作失败:" + err.Error()}
- }
- } else if err := s.repo.UserDevice.SetKicked(ctx, d.ID, true); err != nil {
- return telegramCommandReply{Text: "操作失败:" + err.Error()}
- }
- return s.replyDevices(ctx, msg)
-}
-
-func (s *TelegramBotService) listUserDevices(ctx context.Context, userID string) ([]model.UserDevice, error) {
- if s.device != nil {
- return s.device.ListDevices(ctx, userID)
- }
- return s.repo.UserDevice.ListByUser(ctx, userID)
-}
-
-func (s *TelegramBotService) selfSetName(ctx context.Context, msg *TelegramMessage, input string) telegramCommandReply {
- user := s.boundUser(ctx, msg.From.ID)
- if user == nil {
- return telegramCommandReply{Text: "请先绑定账号。"}
- }
- currentPassword, newName := splitCurrentPasswordAndValue(input)
- if currentPassword == "" || newName == "" {
- return telegramCommandReply{Text: "请发送:当前密码 新用户名。"}
- }
- newName = strings.TrimSpace(newName)
- if len(newName) < 2 || strings.ContainsAny(newName, " \t\n") {
- return telegramCommandReply{Text: "用户名至少 2 位且不能含空格,请重试。"}
- }
- if reply, ok := s.verifyTelegramSelfPassword(ctx, msg, user, currentPassword); !ok {
- return reply
- }
- if existing, _ := s.repo.User.FindByUsername(ctx, newName); existing != nil && existing.ID != user.ID {
- return telegramCommandReply{Text: "该用户名已被占用,请换一个。"}
- }
- if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"username": newName}); err != nil {
- return telegramCommandReply{Text: "修改失败:" + err.Error()}
- }
- return telegramCommandReply{Text: fmt.Sprintf("用户名已修改为 %s。请用新用户名登录。", newName)}
-}
-
-func (s *TelegramBotService) selfSetPass(ctx context.Context, msg *TelegramMessage, input string) telegramCommandReply {
- user := s.boundUser(ctx, msg.From.ID)
- if user == nil {
- return telegramCommandReply{Text: "请先绑定账号。"}
- }
- currentPassword, newPass := splitCurrentPasswordAndValue(input)
- if currentPassword == "" || newPass == "" {
- return telegramCommandReply{Text: "请发送:当前密码 新密码。"}
- }
- newPass = strings.TrimSpace(newPass)
- if s.auth == nil {
- return telegramCommandReply{Text: "服务暂不可用。"}
- }
- if err := s.auth.ChangePassword(ctx, user.ID, currentPassword, newPass); err != nil {
- if errors.Is(err, ErrInvalidCredentials) {
- _ = s.unbindTelegramUser(ctx, msg.From.ID)
- return telegramCommandReply{Text: "当前密码验证失败,绑定已自动解绑。请用新密码重新绑定账号。"}
- }
- return telegramCommandReply{Text: "修改失败:" + err.Error()}
- }
- if s.device != nil {
- _ = s.device.KickAllDevices(ctx, user.ID)
- }
- return telegramCommandReply{Text: "密码已修改,请用新密码重新登录第三方客户端。"}
-}
-
-func splitCurrentPasswordAndValue(input string) (string, string) {
- fields := strings.Fields(strings.TrimSpace(input))
- if len(fields) < 2 {
- return "", ""
- }
- return fields[0], strings.TrimSpace(strings.Join(fields[1:], " "))
-}
-
-func (s *TelegramBotService) verifyTelegramSelfPassword(ctx context.Context, msg *TelegramMessage, user *model.User, currentPassword string) (telegramCommandReply, bool) {
- if s.auth == nil {
- return telegramCommandReply{Text: "服务暂不可用。"}, false
- }
- if err := s.auth.VerifyPassword(ctx, user.ID, currentPassword); err != nil {
- if errors.Is(err, ErrInvalidCredentials) {
- _ = s.unbindTelegramUser(ctx, msg.From.ID)
- return telegramCommandReply{Text: "当前密码验证失败,绑定已自动解绑。请用新密码重新绑定账号。"}, false
- }
- return telegramCommandReply{Text: "验证失败:" + err.Error()}, false
- }
- return telegramCommandReply{}, true
-}
diff --git a/internal/service/telegram_webhook_admin.go b/internal/service/telegram_webhook_admin.go
deleted file mode 100644
index c3a70da..0000000
--- a/internal/service/telegram_webhook_admin.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package service
-
-import (
- "context"
- "time"
-
- "go.uber.org/zap"
-)
-
-// SetWebhook 注册 Telegram Bot Webhook URL。
-func (s *TelegramBotService) SetWebhook(ctx context.Context, botToken, webhookURL string) error {
- cfg := map[string]string{"bot_token": botToken}
- if err := registerTelegramBotCommands(ctx, cfg); err != nil && s.log != nil {
- s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err)))
- }
- payload := map[string]interface{}{
- "url": webhookURL,
- "allowed_updates": []string{"message", "callback_query"},
- }
- return telegramPostJSON(ctx, cfg, "setWebhook", payload, 15*time.Second)
-}
-
-// GetWebhookInfo 获取 Webhook 配置信息。
-func (s *TelegramBotService) GetWebhookInfo(ctx context.Context, botToken string) (map[string]interface{}, error) {
- cfg := map[string]string{"bot_token": botToken}
- var result map[string]interface{}
- if err := telegramGetJSONDecode(ctx, cfg, "getWebhookInfo", 10*time.Second, &result); err != nil {
- return nil, err
- }
- return result, nil
-}
diff --git a/internal/service/transmission_adp.go b/internal/service/transmission_adp.go
deleted file mode 100644
index b609d5b..0000000
--- a/internal/service/transmission_adp.go
+++ /dev/null
@@ -1,220 +0,0 @@
-// Package service — Transmission 下载适配器。
-//
-// TransmissionAdapter 实现了 DownloadAdapter 接口,通过 Transmission RPC API
-// 管理下载任务。
-package service
-
-import (
- "context"
- "encoding/base64"
- "fmt"
- "net/http"
- "strings"
- "sync"
- "time"
-)
-
-// TransmissionAdapter 是 Transmission 的 DownloadAdapter 实现。
-type TransmissionAdapter struct {
- mu sync.Mutex
- cfg DownloadClientConfig
- client *http.Client
- tag int
- sessionID string
-}
-
-// NewTransmissionAdapter 创建新的 Transmission 适配器。
-func NewTransmissionAdapter() *TransmissionAdapter {
- return &TransmissionAdapter{
- client: NewInternalHTTPClient(20 * time.Second),
- }
-}
-
-// AddTorrent 通过 URL 添加种子。
-func (a *TransmissionAdapter) AddTorrent(ctx context.Context, torrentURL, savePath string) (string, error) {
- a.mu.Lock()
- defer a.mu.Unlock()
- args := map[string]interface{}{"filename": torrentURL}
- return a.addTorrentLocked(ctx, args, savePath)
-}
-
-// AddTorrentFile submits application-fetched .torrent bytes through
-// Transmission's base64 metainfo field. This keeps private tracker cookies and
-// signed URLs inside MediaStationGo instead of asking Transmission to refetch.
-func (a *TransmissionAdapter) AddTorrentFile(ctx context.Context, data []byte, _ string, savePath string) (string, error) {
- a.mu.Lock()
- defer a.mu.Unlock()
- args := map[string]interface{}{"metainfo": base64.StdEncoding.EncodeToString(data)}
- return a.addTorrentLocked(ctx, args, savePath)
-}
-
-func (a *TransmissionAdapter) addTorrentLocked(ctx context.Context, args map[string]interface{}, savePath string) (string, error) {
- if savePath != "" {
- args["download-dir"] = savePath
- }
- resp, err := a.rpcLocked(ctx, "torrent-add", args)
- if err != nil {
- return "", err
- }
- if added, ok := resp.Arguments["torrent-added"].(map[string]interface{}); ok {
- if hashStr, ok := added["hashString"].(string); ok {
- return hashStr, nil
- }
- }
- if dup, ok := resp.Arguments["torrent-duplicate"].(map[string]interface{}); ok {
- if hashStr, ok := dup["hashString"].(string); ok {
- return hashStr, nil
- }
- }
- return "", nil
-}
-
-// AddMagnet 通过磁力链接添加种子。
-func (a *TransmissionAdapter) AddMagnet(ctx context.Context, magnet, savePath string) (string, error) {
- return a.AddTorrent(ctx, magnet, savePath)
-}
-
-// Pause 暂停种子。
-func (a *TransmissionAdapter) Pause(ctx context.Context, hash string) error {
- a.mu.Lock()
- defer a.mu.Unlock()
- _, err := a.rpcLocked(ctx, "torrent-stop", map[string]interface{}{
- "ids": []string{hash},
- })
- return err
-}
-
-// Resume 恢复种子。
-func (a *TransmissionAdapter) Resume(ctx context.Context, hash string) error {
- a.mu.Lock()
- defer a.mu.Unlock()
- _, err := a.rpcLocked(ctx, "torrent-start", map[string]interface{}{
- "ids": []string{hash},
- })
- return err
-}
-
-// Remove 删除种子。
-func (a *TransmissionAdapter) Remove(ctx context.Context, hash string, deleteFiles bool) error {
- a.mu.Lock()
- defer a.mu.Unlock()
- _, err := a.rpcLocked(ctx, "torrent-remove", map[string]interface{}{
- "ids": []string{hash},
- "delete-local-data": deleteFiles,
- })
- return err
-}
-
-// List 列出种子。
-func (a *TransmissionAdapter) List(ctx context.Context, filter string) ([]TorrentInfo, error) {
- a.mu.Lock()
- defer a.mu.Unlock()
- args := map[string]interface{}{
- "fields": []string{
- "hashString", "name", "totalSize", "percentDone",
- "rateDownload", "rateUpload", "status", "downloadDir",
- "peersSendingToUs", "peersGettingFromUs", "addedDate",
- "doneDate", "labels", "isStalled",
- },
- }
- resp, err := a.rpcLocked(ctx, "torrent-get", args)
- if err != nil {
- return nil, err
- }
-
- torrentsRaw, ok := resp.Arguments["torrents"].([]interface{})
- if !ok {
- return nil, nil
- }
-
- result := make([]TorrentInfo, 0, len(torrentsRaw))
- for _, tr := range torrentsRaw {
- t, ok := tr.(map[string]interface{})
- if !ok {
- continue
- }
-
- hash, _ := t["hashString"].(string)
- name, _ := t["name"].(string)
- size := toInt64(t["totalSize"])
- progress := toFloat64(t["percentDone"])
- dlSpeed := toInt64(t["rateDownload"])
- upSpeed := toInt64(t["rateUpload"])
- savePath, _ := t["downloadDir"].(string)
- numSeeds := int(toInt64(t["peersSendingToUs"]))
- numLeechs := int(toInt64(t["peersGettingFromUs"]))
- addedOn := int64(toFloat64(t["addedDate"]))
-
- // Transmission 状态码转字符串
- status := int(toFloat64(t["status"]))
- state := canonicalTorrentState(transmissionStateStr(status), progress)
-
- // 过滤
- if filter != "" && !strings.EqualFold(state, filter) {
- continue
- }
-
- result = append(result, TorrentInfo{
- Hash: hash,
- Name: name,
- Size: size,
- Progress: normalizedTorrentProgress(progress),
- DLSpeed: dlSpeed,
- UPSpeed: upSpeed,
- State: state,
- SavePath: savePath,
- NumSeeds: numSeeds,
- NumLeechs: numLeechs,
- AddedOn: time.Unix(addedOn, 0),
- Tags: toJSONLabels(t["labels"]),
- ContentPath: downloaderPayloadPath(savePath, name),
- CompletionOn: toInt64(t["doneDate"]),
- })
- }
- return result, nil
-}
-
-// GetInfo 获取单个种子信息。
-func (a *TransmissionAdapter) GetInfo(ctx context.Context, hash string) (*TorrentInfo, error) {
- a.mu.Lock()
- defer a.mu.Unlock()
- args := map[string]interface{}{
- "ids": []string{hash},
- "fields": []string{
- "hashString", "name", "totalSize", "percentDone",
- "rateDownload", "rateUpload", "status", "downloadDir",
- "peersSendingToUs", "peersGettingFromUs", "addedDate", "doneDate", "labels",
- },
- }
- resp, err := a.rpcLocked(ctx, "torrent-get", args)
- if err != nil {
- return nil, err
- }
- torrentsRaw, ok := resp.Arguments["torrents"].([]interface{})
- if !ok || len(torrentsRaw) == 0 {
- return nil, fmt.Errorf("torrent %s not found", hash)
- }
- t, ok := torrentsRaw[0].(map[string]interface{})
- if !ok {
- return nil, fmt.Errorf("torrent %s: invalid response", hash)
- }
-
- status := int(toFloat64(t["status"]))
- info := &TorrentInfo{
- Hash: hash,
- Name: strVal(t["name"]),
- Size: toInt64(t["totalSize"]),
- Progress: normalizedTorrentProgress(toFloat64(t["percentDone"])),
- DLSpeed: toInt64(t["rateDownload"]),
- UPSpeed: toInt64(t["rateUpload"]),
- State: canonicalTorrentState(transmissionStateStr(status), toFloat64(t["percentDone"])),
- SavePath: strVal(t["downloadDir"]),
- NumSeeds: int(toInt64(t["peersSendingToUs"])),
- NumLeechs: int(toInt64(t["peersGettingFromUs"])),
- AddedOn: time.Unix(int64(toFloat64(t["addedDate"])), 0),
- Tags: toJSONLabels(t["labels"]),
- ContentPath: downloaderPayloadPath(strVal(t["downloadDir"]), strVal(t["name"])),
- CompletionOn: toInt64(t["doneDate"]),
- }
- return info, nil
-}
diff --git a/internal/service/transmission_parse.go b/internal/service/transmission_parse.go
deleted file mode 100644
index d8a4c63..0000000
--- a/internal/service/transmission_parse.go
+++ /dev/null
@@ -1,100 +0,0 @@
-package service
-
-import (
- "encoding/json"
- "fmt"
- "strconv"
- "strings"
-)
-
-// transmissionStateStr 将 Transmission 状态码转为可读字符串。
-func transmissionStateStr(status int) string {
- switch status {
- case 0:
- return "stopped"
- case 1:
- return "check_pending"
- case 2:
- return "checking"
- case 3:
- return "download_pending"
- case 4:
- return "downloading"
- case 5:
- return "seed_pending"
- case 6:
- return "seeding"
- default:
- return "unknown"
- }
-}
-
-// toInt64 安全地将 interface{} 转为 int64。
-func toInt64(v interface{}) int64 {
- switch val := v.(type) {
- case float64:
- return int64(val)
- case int:
- return int64(val)
- case int64:
- return val
- case json.Number:
- n, _ := val.Int64()
- return n
- case string:
- n, _ := strconv.ParseInt(val, 10, 64)
- return n
- default:
- return 0
- }
-}
-
-// toFloat64 安全地将 interface{} 转为 float64。
-func toFloat64(v interface{}) float64 {
- switch val := v.(type) {
- case float64:
- return val
- case int:
- return float64(val)
- case int64:
- return float64(val)
- case json.Number:
- n, _ := val.Float64()
- return n
- case string:
- n, _ := strconv.ParseFloat(val, 64)
- return n
- default:
- return 0
- }
-}
-
-// strVal 安全地提取字符串。
-func strVal(v interface{}) string {
- if v == nil {
- return ""
- }
- s, ok := v.(string)
- if ok {
- return s
- }
- return fmt.Sprintf("%v", v)
-}
-
-// toJSONLabels 将 Transmission labels 转为逗号分隔字符串。
-func toJSONLabels(v interface{}) string {
- if v == nil {
- return ""
- }
- arr, ok := v.([]interface{})
- if !ok {
- return ""
- }
- labels := make([]string, 0, len(arr))
- for _, item := range arr {
- if s, ok := item.(string); ok {
- labels = append(labels, s)
- }
- }
- return strings.Join(labels, ",")
-}
diff --git a/internal/service/transmission_rpc.go b/internal/service/transmission_rpc.go
deleted file mode 100644
index f17cbb1..0000000
--- a/internal/service/transmission_rpc.go
+++ /dev/null
@@ -1,141 +0,0 @@
-package service
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
-)
-
-// transmissionRPCRequest 是 Transmission RPC 请求的通用结构。
-type transmissionRPCRequest struct {
- Method string `json:"method"`
- Arguments map[string]interface{} `json:"arguments"`
- Tag int `json:"tag,omitempty"`
-}
-
-// transmissionRPCResponse 是 Transmission RPC 响应的通用结构。
-type transmissionRPCResponse struct {
- Result string `json:"result"`
- Arguments map[string]interface{} `json:"arguments"`
- Tag int `json:"tag"`
-}
-
-// Initialize 配置并初始化 Transmission RPC 连接。
-func (a *TransmissionAdapter) Initialize(ctx context.Context, cfg DownloadClientConfig) error {
- a.mu.Lock()
- defer a.mu.Unlock()
- endpoint, err := normalizeDownloadClientEndpoint("transmission", cfg.Host)
- if err != nil {
- return err
- }
- cfg.Host = endpoint
- a.cfg = cfg
- a.sessionID = ""
- a.tag = 0
- return a.pingLocked(ctx)
-}
-
-// Ping 测试连接。
-func (a *TransmissionAdapter) Ping(ctx context.Context) error {
- a.mu.Lock()
- defer a.mu.Unlock()
- return a.pingLocked(ctx)
-}
-
-// pingLocked 内部 ping 实现(调用者必须持有锁)。
-func (a *TransmissionAdapter) pingLocked(ctx context.Context) error {
- rpcURL, err := downloadClientRPCURL("transmission", a.cfg.Host)
- if err != nil {
- return err
- }
- req, err := newDownloadClientHTTPRequest(ctx, http.MethodGet, rpcURL, nil)
- if err != nil {
- return err
- }
- if a.cfg.Username != "" {
- req.SetBasicAuth(a.cfg.Username, a.cfg.Password)
- }
- resp, err := a.client.Do(req)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- _, _ = io.Copy(io.Discard, resp.Body)
- if resp.StatusCode == 409 {
- // 正常:需要 CSRF token
- a.sessionID = resp.Header.Get("X-Transmission-Session-Id")
- return nil
- }
- if resp.StatusCode >= 400 {
- return fmt.Errorf("transmission rpc: %d", resp.StatusCode)
- }
- return nil
-}
-
-// rpcLocked 发送 RPC 请求(调用者必须持有锁)。
-func (a *TransmissionAdapter) rpcLocked(ctx context.Context, method string, args map[string]interface{}) (*transmissionRPCResponse, error) {
- rpcURL, err := downloadClientRPCURL("transmission", a.cfg.Host)
- if err != nil {
- return nil, err
- }
-
- a.tag++
- body, err := json.Marshal(transmissionRPCRequest{
- Method: method,
- Arguments: args,
- Tag: a.tag,
- })
- if err != nil {
- return nil, err
- }
-
- for attempt := 0; attempt < 2; attempt++ {
- res, retry, err := func() (*transmissionRPCResponse, bool, error) {
- req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, rpcURL, bytes.NewReader(body))
- if err != nil {
- return nil, false, err
- }
- req.Header.Set("Content-Type", "application/json")
- if a.sessionID != "" {
- req.Header.Set("X-Transmission-Session-Id", a.sessionID)
- }
- if a.cfg.Username != "" {
- req.SetBasicAuth(a.cfg.Username, a.cfg.Password)
- }
-
- resp, err := a.client.Do(req)
- if err != nil {
- return nil, false, err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode == 409 {
- a.sessionID = resp.Header.Get("X-Transmission-Session-Id")
- return nil, true, nil
- }
- if resp.StatusCode >= 400 {
- raw, _ := io.ReadAll(resp.Body)
- return nil, false, fmt.Errorf("transmission rpc error: %d: %s", resp.StatusCode, string(raw))
- }
-
- var result transmissionRPCResponse
- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
- return nil, false, err
- }
- if result.Result != "success" {
- return nil, false, fmt.Errorf("transmission rpc result: %s", result.Result)
- }
- return &result, false, nil
- }()
- if err != nil {
- return nil, err
- }
- if !retry {
- return res, nil
- }
- }
- return nil, fmt.Errorf("transmission: failed after CSRF retry")
-}
diff --git a/internal/service/watcher.go b/internal/service/watcher.go
index 7a37bfc..47cc4cd 100644
--- a/internal/service/watcher.go
+++ b/internal/service/watcher.go
@@ -108,9 +108,6 @@ func (w *WatcherService) Refresh(ctx context.Context) error {
if !root.Enabled {
continue
}
- if _, _, ok := parseCloudLibraryPath(root.Path); ok {
- continue
- }
watchRoot, info, err := resolveAccessibleMappedPath(root.Path)
if err != nil || !info.IsDir() {
w.log.Warn("watch path inaccessible",
diff --git a/web/index.html b/web/index.html
index ce08c73..948f2b0 100644
--- a/web/index.html
+++ b/web/index.html
@@ -4,16 +4,17 @@
-
-
-
+
+
+
+
- MediaStationGo
+ MMTL
diff --git a/web/package.json b/web/package.json
index a9e8bcf..68cae85 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,5 +1,5 @@
{
- "name": "mediastationgo-web",
+ "name": "mmtl-web",
"private": true,
"version": "0.1.0",
"type": "module",
diff --git a/web/public/brand/logo-192.png b/web/public/brand/logo-192.png
new file mode 100644
index 0000000000000000000000000000000000000000..674b95049a32e5b976fca96897424b0297136434
GIT binary patch
literal 61940
zcmWifcRX9~7sqcRNC*;pOCmNk>WiAOM{BFq)?T%%t*V*CEVXyl9;KzI(PdMr)F@R&
z?bX)aiumRCdgaNz_mBI!dCqh0c|PZTPM(+;>C(|~(f|NJr?01FMm~1@e?Y0o-)1x3
z)Z_!`zL~BD@Op^*H~9kMu5PFf0QJeV=Z=)*YnZ>D&3yo1eDMDPnsws~0)X8o`daE1
zL3SG#l>Xc{%@>JZ;7)GBEU@byHd!tis-V&vR>C1C!1<+(P_5UE5n*+BY0dP2QJYdcV4sFkGR0
zB|`30#Z7NFxOZ6WVym|=vC!C?>$2iO!9mX6v1OZlgy5&YUuyY>vh9p_m2%qK7eh}k
zJ0ckNFJn))Mb#>TR49FJW!)
zoM+JWygj$IZSUG@d+hS5mk!{2hWos{OoYUkntQJoXGC#sE&39f
zS*UyZfyTs*RJ=T~@yJ{`e0tj>*f{*or!)3qtG3s>(kp)l)`ST%`D(AKGEEF3_{O|M
z3SXV`FmL;Xex07P3LiX~a%-<$Jj_VWy(nlP$aYJ9`LWfmtA&KDn^N}Il-NvoFLO(3V0aPBfj`;5;jJkEx9$$9VoioJ!S++%yixk=5zlhV9P
zv|A`^wDe$z%*sCY>rt|d(xcV=8)t5-xu+itb}|YLPlMx!@3yJzx8x3I$}Sc99VM^y
zu@q{a%4gaCIGMVNDcB3ke0Dk;zyDRKm19jzt@P*iKYpG9o8>G_(Qd07-g)BMv&(|s
z_P(dXo>N(TRt5525EGKqRTAzS(~5nE*7L<=C!O3y3QqD*^#8=Crx-ZRXI1Za9{Hu#
z=e$o;wb1m-@av$qpV#)h#D+3$%{iU^Mdk*F?49THy?iI|%XPv)qV
zUb;QDb}m0`McRH^TYRGIR%n>E=jR!C`*f~VaO~h{Px%>}w@aNlXL72Z5n;U)`+RpH
zV@-5+ZX;(g+2!b2yjt4t(}tFzWbW9#u(BWdr;nsUZlBt!10IweU%2@2sejzW_~?`SZb$#z
z@_#>_>l6Pl7|QoMXSA=O0v9hYe;DwMw*T6ekDqtAx0|0IjaS8*34`NohzKdA_kJed
z?a;}~wYAG1;e(g4t7a!Uq=-vvYNashO=h?V|se}%$tg3pZDuBDmrWZ&)%zDlXfmlHq5!ZI2SjZ
zn=#DQx7>5$vvyv~d0D@9KDm2&=WSS0|Mc*wTH1N&r@uSfH`+gHI7KjyjisMQ{ClJ(
z$N;6_q77_-Vq7M6pPjl_bY=pBH=g*aC5QVO6Wop#uR5lv*1gH(E6k%>z7~Yi?cfi4_XsmCp{YdPU8JAJ*RDa7NRh+s~W7g$yfB^LX+F$64A=T
zw_vg+q%eLe60D@EZr}GpVSLw|+w)R{K7A@Yo}WjpZ{etL$$xs-KkASpsX1NMoyY5v
zGO0N%skEkW=`(5AT37!}PpvPtRpb9Bg-h$R!~WiL?h2#-t{F95g4aHTKRKhnq)IM0
zRj~^?84kK!WIO4WZ0}~6J8*V-4w#bkblkUvpGusq$(4$km;M}-RNJ0wr@D{=#;(Sw
zudrqF6P|ams}+6gp^+N=Cg}R5>^vI#Ayduj^{D{NybKe>i<=
z_U!Sg)$U3P#%|3-myPRG8~JrTJ8-N1>d`s%^jG!6+>iCDA(C>d8-2YqmfnMRR|3s_
zx#aZExZYIxbg$0Pd-wnCnIQ6diz(xMqN(d!>)56f7#!XEk=Sf*ZG)fp%CdNLZGY;Q
zWftn#{=8e3WfsE9!SV>zuz1BK<;%8~+v%OpuUpvOe`mn7uR1bMsA4Sk+2cJ6ai(9>
zgsHtq=tzX}DdWJ(sdEV^1)wh>>xOsK31
z&%b%+RMAs(4rll&uTms};=8L~QS-UPcxJnp#lI__*JriEeusDnUjl|Je8u$}{IVJ0
zalPM?M=XwDwqI1Wum67GA#w`&cs=4v$!1oXas9{T7nA1y?r%t3z(WjFyJwx!kC-pG
zLuk~x3y6iz#-!o8yo}?kjmsoviED)=0>|bX{g>B{()PlWeqB@3zCI!3F?7!U^*3Ls
z^dY}&S!mq9{B38eKWCHa$Dn_K7tF>pyL>0-k+t^@(@wcJym=}gkr2*z?
zn&B@z|6RYd^}uzA$*sNXJ=cz70=yzysz}_
z)!eksoWeiMb=kXP{l0-ytT}@}SW_DFg9YQ{quP}gfcPLfEv#uK2Y(gdkdhRObwkUh
zCXFs#o7r1>6@bjjh^@Q%9_qd#l93!&rlBQBzU?LmW5t3}mgaL5+eZ|n#O`ELLhWs$
z;(DC4UW+=81_n9RD>T1>AYlS=agsV(p|BFmEKIQLwGt+=I`MkO0ZU8ApI>OSgi=K7
z#oK5G7+)gOO(JtnID2|v63Mli-k~a%SXtDSV&py8*SG|~-uQPq)p1@Lw{O!jE*_&)
z!zIPgzIP6cqUM(u7GS5Z-2UD^BxCcxKrUcg~Jxpm*ElzGi
z7c+1%(KpET(E8j^ASUkW)}gl$ay>k~y(guU+!5S)OUaJ+XYLiBFikQ>Fy3M^a`tK&
zX^y%&*|tYtR{eqPuKqFq{Y_2b$pftDqn5asLHT;6HO-Cwf6R&fe8p~ml|1^xsUIDl
zg9}?P1!AcCm{Ef}smp5S@haU1ZmBp$-&HH~H8C$%V35vRrsZJO?JR&j4KM-ob4B##
zjT&Eepm;+_56d#QbWf%<#HTHiZi*bvEJ@DYj;Z_aG1NdW*7vcDth7?GT{8t~pjnfr
zfr69KN7pY>(KF8@+b7m7S7D2Dd;ta?zs%(eH#GFgN&%7w{MzkD;@>!fqdKZ=>typd&N>H3~YUjav_f_i;
zQf7(=jvOOgGR5*PPvm4NK*hOGXg?#(R~-irdZNZYg85l;(Fhn4z09vr26PrjL+K?n
z^gN_BGz_AM(Hfdx9H2EgYmf3W&nPZWq+_LaSut;_<41%mB3@3`$;O%~i{n|Hcq39e
zMzAMJ&e_I;$w}iGlIa_9rTAmChAo2W##so0>jRq@{HzXRAv`n^e^M7{`Mj9!UQ8gC
zT`>hQ5uLf9q9^r$U$v157IURq_P`-?0kDicc3OI~q-Xb7K3+~GGUh@qLIq_*Z8jL8pT>T73xIJj;Me_Ze{1S)85KrrlpYE59djtYyOzxbs8+1?KXTIF`$u#}RXNls
zKU7+*^P9hSML?lMv@*w(zf9Q6GTC*E3<8lGjgJ$DMA1^lxYI_jprpjPKr!AB&NoPx
z&)P-k?CGL9Wk@Uwss|^k6;})`j$w?7$?QT{#+Yb|z>Cx1E8d}IK^`n!bT{Lq#!+cc
z`E+`-2l*!AJY>Rhq#|wv(K>b6BAh*X1I#>VHE}-Gjtow8E`j&E#{!=_sx9`P#K>7{
zuyfd1vRQtSd4NVYrcd|OQCPDCB3_lriEN-4H5pLSjnrNkdPnzj9^u(@__M3Com)?n
zP1{KSZ48C+__dzM^Ohl2b4_Kr&?y&gGEK0UOUgJKP|jVCQ#yCX-s1H@V6Z#6>c{0$
z1W7*NrDE}|)S)V)^CM9stIED3x;aRD+}D}=Tp@ulFsn~`rEW47r^O(N4GwjdO+lHC
zK2E(w2}xbd3A*E1E{yAAF~u=u!VQ;=?rW#4D;yp$Lbxe~!DTp}0{Fudqss0;4WA<6
z6tJPSXk(1*dR1RG2Cu4Yjzj2RmdyPm#kfGl*Wh?Oib0)f^o%<8b89$^Yp&A735;{Uam%yqAk!EfEv{z@G$z+uNpDhg#A$td-XM^m${)$D2nbML
ztRbdxoM1OM@i524vDeZqKAKYvuw4akgN|Og$++29L
ziIOEj4IDA;H78O!RO)OO&m$HEqN)eAM!s-YPug}qzIM3Rw!pQ(h90cN0GPLRoi=Ip
zHy?--ll%yLB2Kgx$db&rD{EEm%E^V-`!MVOx#F8?wY?)FqGlMeN>P~b8%oim-INHI
zP=m+9zjm{G0DqbcxS6K?58h?TsT5C
z6w8|P(0Uo)(2S9YiG=?aV(SDq5EJad98oh($&OZk@`R`TL6olwJFh37+kproiO3lF
z?Ho$LAmYoaeA8ZC3lh(l2=tb8E7wS`+Pm>}9CWV7#?MqFrSHgiuY%e~>KJ$-*&3qrzZyE#j{@x?lX|>Sy&Btr$l>%P1(j
z!X<-16D^>j{ZF$(-il2VSCz~GiHWo}q*01$TF|XiU4%|V^Yk9&+!vnPQ~vX)?MXF@
z@zR68vFsasEC}{i4|=?*8_$tAZ|2ZP9(?ZmR+}8@)*KrT=>&MmT6@4DmCV3Vr;;~q
z$qzO(7R}S<$Tjb5O>N&Dd=l-07Y!{PJ!q>ogi|HJhXv=bW@S>J?kQ&TniJ7ncfgui
zev^V(_LFW|wd4Py$KGQ>M7}1+Tg5Wo(d~|TCX;fNdNDpbw7;lsuFIX6b7^b_Mh6OG
zi%ep%nmlP>L%o-HR1cO%j6#tLW!~6{6^$bMr#LhF^8|d6GUN@Gok)XV#-gAVa+U0z
z>H^gx^JXG67SMfat4_r2#JYLTDC!>=j5Qv?YmpFFq)3HN(x&>37PTg#D%FMG5zuk4
ziVP2VC(R?2T#$Sg^|p@p4sVYPGz7`^rmzW9#&Qqs6UT5@SNZsaJa-kfat#cR0Tfx4
zQVD|MBjLETVmAF4)g&;a^TST5HCs4@tBZkO?6-1YdCL=G=5T4v^3B_)ukGU@f^)fF
zc^lkafehqAptTz{?zGEIyXG;wblcCTY%LE~6y;nex|O`ImQ1jqP=P|B-4oqE>gZKt
z4#kO`^3O4Z466E3`8PjZ$7p<9PV3jX9m7?Jq13sy@igD|LtQxJy0V!+AXSUG7PvId
z^2k0uD4}BIItf-YTW77bzV&m%CDd25c~($OQK$zJ`32f#bP~IPS9GC1k4!J7HzEis
zuslnV^&pNF!SvTGxt`PC@uzyA&M`viuEB-{MyTBzz(gqV_Ie~13Z?`Ia2$vjN#jS+
zfv^zO#?!D9#T=n8wLDB%;Oa6EvioAb(?H8AaVRZQtOF8~$OuieW*zs4!ehDuIFU3Z
zmiUfQRE`#^Q3il$+^&NY-=D77qNFou79!#JI0zUV5BBP!zpfbd5Ta$xm$tN}^=rt=
zH0K0t8(`O2?&q%IFxxQjJM}JNEpUJAv;ioB@_czx!y%(l9LIief8SEA*=3VxHOZ<~
z{WbE68`t^ddDn@cj|24cq>DeFrz_wBeV56bo_J$%EeEx*j%)omzLfqukw&^-$%P_R
zL#{*q0aGaUG{(2hFdPjA%e?2~3a8~;T1s1g=QmQW1$
z9#>adFs3mgC@Qf0DY5_&Ab7dBj7}#6lvpIb9;u~R#_)x3MT+`g$88WELN=dC5grwX
zf{N=vbqN8*MFCNeOn%uqqB@lJr4!HveuZSUj@;?s)Pj5u*fze4&;_xok91(5C19>P
zaXl?CT3Y;zW@%?gopO+*Q{<78b_yPk0d*t9QE{}t5lH+P-{@<2rWb*tL@Eog{$z?r
zt#^BNJ6?JEgB?ojw)Rqpsu_J@+Ij(wPqWjyJ~Z&|Uw4O3Dvqji*`~bEu#a@i*&+Ts
zhwW1@n`Mje#f=*`Ftsn~db4|rQj8Uzo-RzblI!udwPKZbvD^5jA-;=xg(5hm+
zcNE<)t``(UmZb&1POy~BYR#32;lWA2XoY;zWLw_TsTTi@`n=K7%G&nDxp(QRgTwx_
zr20`!*M@=Cj|xLN1yw1B;*Lj$J2ZkdOIfI$G!#lIl&IHr0s+LVKW8Uh?>&ai^z6}D
zY*2g07vVE8UUeQ
zQ6Z~swICDHkP-z!CuFM_KxWl_pCIDsGW3;Hu8I3T7HRtRSZEwFgNwZQX+epaWuHddY|yVFE(>-19_goYzGI9R{rM>d=fJ2+e+G}7!@DDpGEFZFszy*Q9B-N3k61tWIr7&Xk4Q=Gz=I)F
z%owR6C;{{dN+YaZS77lJbw`?bSPq@Y=C`mXAi^j$C`6IH7(^6%6rR%(^2A{z{SSfz
zCxw83A!s6ws`%4nZ=H)U79q?DX+mGAaTDk>h$lr?3Y5zXq{}TL{%QnFwVxt
zngP5{wn*7myk#CN|4ITHIDEafDF6|F--l!U0(aa@A)@-1jm-D>UG^+TrdESXXDZJINYaJq{
zmWz?XcKb}r0xI!-?M`O~Dy7U-kI!t%eLF%|k867EQzlKLACi=-s{Hv@dq!$^gGZVp-FU
zOAW!Q!&d*V^(-X%UWiHy1r`N{66to((g?2kn(XPsol(E#i3HnUIzhkXI`f)1RIxSU
zFtCjx(OJpj1CPn?K#lUBun0q$^M_S%B7mXuln~+JWbI4=<;yVz-;q1`z`cI>K5Y+C
z#Lt2I*a-!a@%KSS`%#78+0i}DkksZfJdh|l2*U-mo?K&WD}aYZgwQLtH~G@_S0G6!yKup*N=Fd2ccJ
zF@n^;)y8IrM=HFY1|)w`1sUvp`$p0Ux+~AIQiDbUx8`3Vw#{Wnd}HIwOmRhqgmz0`
z^P-6|87PEcnMa?`4QsR+@uGx+KHNqkp5yrZs
zIwy)p_eZ(e#^X4sR@cpK`=WlH6`TgS7S9-Iu-v3A@z^mAEi_&>O5nh
zQ1o!o%^mDM^_NXgw6hjM@c8-N@cxs6vJSp6TeM7T_
zM3@y@zEC$rR8m514YQD7o8Uw|P8YS)jN$a4L6k{kl#n-Xm!d9fD}ss7ds8YO%7Gl3
zyCOV{J_xicatqJ{e0YtMhw^lG6Bysek8Kp5Q=4jX}ZX
zT8f7B*Ix}0j3m^H`Ze)_b5LAeGg)KbfoU|ALsOXzDBm>0i#e-Y5oLNs0yi-?_+3UU
z&^OEzr3_gpkew(pnR6JKK%0F`C?()=TNm081R@7#jjUt12ZG@7_!9L{po$I#^3j8P
zX9tHzgQHK=!5>rDN0JWSouzQ!;}x=
zWgl>8e5zQ#cyINiHcGd{;2pK#o@#EsK<->*()HCxFDxaOuWC0=M(6ngv%k(Pb{tZ$B$2
z?4|{_VFc_T1Y20A+)3rz&L4%1JZ&VUvOuNt^G8tN{85K-QzT*M*qAjHmrviMsEIFP
z9mT!)$|_aaP;@x#M%IJZpd-r|lk{ZNHAW8_WVMJ}bV}nVc;tHs1QMlg{@R2R{)Z;&
zrM6g6(Px}#@v~2+>bE2@pck4Yf(S&T9E8`L$y5z}&A4_7s#QU57G!hL
zUQMrxo}plze{GD9wNuo=?Z|QD^BPAfHNCydlHe#M3gb=dUwLll!SX`>jl#-kLPnpk
z4`!z6iB9*&yB_vLeDi-0f=hTjoDt&d5`K9T41UO{TsCp1m6P|)`H#U7+n|%$NFgs3
zI}?*@iFs;G`l~gKhKQ7zbF1z<0zE*b3v{AOQ@|EP
zaW_(`^-?FOO0MI26LR*Y;m(l|RIa-(;%X=i@|`v4Rpl=l`}Ufha?2*Vw(vL2w17o_
zn%R6U2Kue&h^>hLplCU>$ix5~fQ6c@iD*eNbAFQYxUL{7#q605`M-1)fV)cRNg;rR
zXQCnkCC~6Q&f1DvK+0)3#->cWQSuuGL_oaI5hoV0h?Qr=ITZO&;m3;Pyc3n5b!r$xbTt*Q@Y<~#_8zqNYZ*&Y1O5j-x1*2k1*M;MR2L6u6PReA5>%C
zRY4)~aDD$5KA9f+yyW;buZycDC{dsZMYhxo--D4Xl{bAH9-rzX>Y!kmOShmiA7XML
z9#2l8r>!AmV4}3J_LfOh4Md1XnLN?Mp-eo>j)q;N0WRPa9Cua;UgVcF#ey!#gq0>9
zM#%yGce-y+8Y-2PrTF_YG4|D!@R(6|Ju#KnhEmix;$mPw0*YV<42cjTB(m$|%J}rY
zT>cBG_ICZ(*h2#lxZ6pl{gtP{Z40r5mj?4bXi&8GNrYtodAPnH)WR$J90DzP`Opmr
z`UiuyoXgciA--YydE^LB-W^X);u}9thltJGp6DEK7NeNgMVufZtE0G0WAZa)Nj4)0
z2