{item.title}
++ {item.overview || `订阅关键词:${keyword}`} +
+ +
`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer upstream.Close()
+
+ db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}, &model.APIConfig{}); err != nil {
+ t.Fatal(err)
+ }
+ repos := repository.New(db)
+ apiConfig := NewAPIConfigService(zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
+ baseURL := upstream.URL
+ if _, err := apiConfig.Update(t.Context(), "adult", APIConfigPatch{BaseURL: &baseURL}); err != nil {
+ t.Fatal(err)
+ }
+ log := zap.NewNop()
+ scraper := NewScraperService(&config.Config{}, log, repos, nil, nil, nil, nil, NewHub(log), NewAdultProvider(log, apiConfig))
+
+ lib := model.Library{Name: "成人", Path: "/media/adult", Type: "movie", Enabled: true}
+ if err := repos.DB.Create(&lib).Error; err != nil {
+ t.Fatal(err)
+ }
+ media := model.Media{LibraryID: lib.ID, Title: "SSIS-001", OriginalName: "SSIS-001", Path: "/media/adult/SSIS-001.mkv"}
+ if err := repos.DB.Create(&media).Error; err != nil {
+ t.Fatal(err)
+ }
+
+ results, err := scraper.ManualSearch(t.Context(), &media, "SSIS-001", "adult", "adult")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(results) != 1 || results[0].Source != "adult" || results[0].MediaType != "adult" || !results[0].NSFW || results[0].OriginalName != "SSIS-001" {
+ t.Fatalf("manual adult candidates = %#v", results)
+ }
+}
+
+func TestApplyManualMatchSavesSelectedCloudMatchWhenDetailsSlow(t *testing.T) {
+ oldTimeout := tmdbDetailsTimeout
+ tmdbDetailsTimeout = 20 * time.Millisecond
+ defer func() { tmdbDetailsTimeout = oldTimeout }()
+
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/movie/77" {
+ http.NotFound(w, r)
+ return
+ }
+ select {
+ case <-r.Context().Done():
+ return
+ case <-time.After(time.Second):
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": 77,
+ "title": "Slow Details",
+ })
+ }
+ }))
+ defer upstream.Close()
+
+ db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
+ t.Fatal(err)
+ }
+ repos := repository.New(db)
+ cfg := &config.Config{}
+ cfg.Secrets.TMDbAPIKey = "test-key"
+ cfg.Secrets.TMDbAPIProxy = upstream.URL
+ log := zap.NewNop()
+ scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
+
+ lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
+ if err := repos.DB.Create(&lib).Error; err != nil {
+ t.Fatal(err)
+ }
+ media := model.Media{
+ LibraryID: lib.ID,
+ Title: "bad cloud title",
+ Path: "cloud://openlist/Movies/Bad.Title.2026.mkv",
+ ScrapeStatus: "pending",
+ }
+ if err := repos.DB.Create(&media).Error; err != nil {
+ t.Fatal(err)
+ }
+
+ start := time.Now()
+ if _, err := scraper.ApplyManualMatch(t.Context(), media.ID, ManualScrapeRequest{
+ Source: "manual",
+ MediaType: "movie",
+ Title: "Correct Cloud Movie",
+ TMDbID: 77,
+ Year: 2026,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
+ t.Fatalf("manual apply waited for optional details: %s", elapsed)
+ }
+
+ var got model.Media
+ if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
+ t.Fatal(err)
+ }
+ if got.Title != "Correct Cloud Movie" || got.ScrapeStatus != "matched" || got.TMDbID != 77 {
+ t.Fatalf("manual cloud match was not saved: title=%q status=%q tmdb=%d", got.Title, got.ScrapeStatus, got.TMDbID)
+ }
+}
diff --git a/internal/service/media.go b/internal/service/media.go
index 4b30291..b70d17b 100644
--- a/internal/service/media.go
+++ b/internal/service/media.go
@@ -7,8 +7,6 @@ import (
"encoding/hex"
"errors"
"fmt"
- "os"
- "path/filepath"
"sort"
"strings"
"time"
@@ -96,229 +94,6 @@ func (s *MediaService) CreateLibrary(ctx context.Context, name, path, kind strin
return lib, nil
}
-func inferLibraryKind(name, path, requested string) string {
- requested = normalizeOrganizeMediaType(requested)
- text := strings.ToLower(name + " " + filepath.ToSlash(path))
- switch {
- case containsAnyText(text, "成人", "番号", "jav", "9kg", "adult", "nsfw"):
- return "adult"
- case containsAnyText(text, "综艺", "真人秀", "variety"):
- return "variety"
- case containsAnyText(text, "国漫", "日漫", "日番", "动漫", "动画", "anime", "bangumi") && !containsAnyText(text, "动画电影"):
- return "anime"
- case containsAnyText(text, "电视剧", "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "剧集", "tv", "series"):
- return "tv"
- case containsAnyText(text, "电影", "movie", "film"):
- return "movie"
- }
- if requested != "" {
- return requested
- }
- return "movie"
-}
-
-func resolveAccessibleLibraryPath(path string) (string, error) {
- input := strings.TrimSpace(path)
- if input == "" {
- return "", errors.New("path required")
- }
- for _, candidate := range mappedPathCandidates(input) {
- if isAccessibleDir(candidate) {
- return filepath.Clean(candidate), nil
- }
- }
- abs, err := filepath.Abs(input)
- if err != nil {
- return "", fmt.Errorf("invalid path: %w", err)
- }
- return "", fmt.Errorf("path is not an accessible directory: %s", abs)
-}
-
-func resolveAccessibleMappedPath(path string) (string, os.FileInfo, error) {
- input := strings.TrimSpace(path)
- if input == "" {
- return "", nil, errors.New("path required")
- }
- candidates := mappedPathCandidates(input)
- for _, candidate := range candidates {
- if info, err := os.Stat(candidate); err == nil {
- return filepath.Clean(candidate), info, nil
- }
- }
- abs, err := filepath.Abs(input)
- if err != nil {
- return "", nil, fmt.Errorf("invalid path: %w", err)
- }
- return "", nil, fmt.Errorf("path is not accessible: %s", abs)
-}
-
-func resolveMappedDestinationPath(path string) string {
- path = strings.TrimSpace(path)
- if path == "" {
- return ""
- }
- clean := filepath.Clean(path)
- if _, err := os.Stat(clean); err == nil {
- return clean
- }
- for _, candidate := range mappedPathCandidates(clean) {
- if candidate == clean {
- continue
- }
- return filepath.Clean(candidate)
- }
- return clean
-}
-
-func mappedPathCandidates(input string) []string {
- var candidates []string
- add := func(candidate string) {
- candidate = filepath.Clean(filepath.FromSlash(strings.TrimSpace(candidate)))
- if candidate == "" || candidate == "." {
- return
- }
- for _, existing := range candidates {
- if sameLibraryPath(existing, candidate) {
- return
- }
- }
- candidates = append(candidates, candidate)
- }
- clean := filepath.Clean(input)
- add(clean)
- for _, candidate := range dockerVolumePathCandidates(input) {
- add(candidate)
- }
- for _, candidate := range dockerVolumePathCandidates(clean) {
- add(candidate)
- }
- if slashClean := cleanPathForVolumeMapping(input); slashClean != "" {
- add(slashClean)
- }
- if abs, err := filepath.Abs(input); err == nil {
- add(abs)
- for _, candidate := range dockerVolumePathCandidates(abs) {
- add(candidate)
- }
- }
- return candidates
-}
-
-func isAccessibleDir(path string) bool {
- info, err := os.Stat(path)
- return err == nil && info.IsDir()
-}
-
-func dockerVolumePathCandidates(path string) []string {
- normalized := cleanPathForVolumeMapping(path)
- var candidates []string
- addCandidate := func(candidate string) {
- candidate = filepath.Clean(filepath.FromSlash(candidate))
- for _, existing := range candidates {
- if sameLibraryPath(existing, candidate) {
- return
- }
- }
- candidates = append(candidates, candidate)
- }
-
- for _, mapping := range []struct {
- env string
- container string
- }{
- {env: "MEDIASTATION_MEDIA_DIR", container: envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")},
- {env: "MEDIASTATION_DOWNLOAD_DIR", container: envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads")},
- } {
- host := cleanPathForVolumeMapping(os.Getenv(mapping.env))
- if host == "." || host == "" || strings.HasPrefix(host, ".") {
- continue
- }
- if normalized == host {
- addCandidate(mapping.container)
- continue
- }
- if strings.HasPrefix(normalized, host+"/") {
- addCandidate(mapping.container + strings.TrimPrefix(normalized, host))
- }
- container := cleanPathForVolumeMapping(mapping.container)
- if container == "." || container == "" || strings.HasPrefix(container, ".") {
- continue
- }
- if normalized == container {
- addCandidate(host)
- continue
- }
- if strings.HasPrefix(normalized, container+"/") {
- addCandidate(host + strings.TrimPrefix(normalized, container))
- }
- }
-
- for _, marker := range []struct {
- part string
- container string
- }{
- {part: "/media", container: envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")},
- {part: "/downloads", container: envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads")},
- } {
- part := strings.TrimRight(marker.part, "/")
- container := strings.TrimRight(filepath.ToSlash(marker.container), "/")
- markerPath := pathAfterWindowsDrivePrefix(normalized)
- if markerPath == part {
- addCandidate(container)
- continue
- }
- if strings.HasPrefix(markerPath, part+"/") {
- addCandidate(container + strings.TrimPrefix(markerPath, part))
- }
- }
-
- return candidates
-}
-
-func cleanPathForVolumeMapping(path string) string {
- path = strings.TrimSpace(path)
- if path == "" {
- return ""
- }
- path = strings.ReplaceAll(path, "\\", "/")
- path = trimEmbeddedWindowsDrive(path)
- return filepath.ToSlash(filepath.Clean(filepath.FromSlash(path)))
-}
-
-func pathAfterWindowsDrivePrefix(path string) string {
- if len(path) >= 3 && path[1] == ':' && path[2] == '/' && isASCIIAlpha(path[0]) {
- return path[2:]
- }
- return path
-}
-
-func trimEmbeddedWindowsDrive(path string) string {
- for i := 0; i+2 < len(path); i++ {
- if !isASCIIAlpha(path[i]) || path[i+1] != ':' || path[i+2] != '/' {
- continue
- }
- if i == 0 || path[i-1] == '/' {
- return path[i:]
- }
- }
- return path
-}
-
-func isASCIIAlpha(ch byte) bool {
- return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
-}
-
-func sameLibraryPath(a, b string) bool {
- return filepath.Clean(a) == filepath.Clean(b)
-}
-
-func envOrDefault(key, fallback string) string {
- if value := strings.TrimSpace(os.Getenv(key)); value != "" {
- return value
- }
- return fallback
-}
-
// ListLibraries returns every library configured on the server.
func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) {
return s.repo.Library.List(ctx)
@@ -440,138 +215,6 @@ func (s *MediaService) invalidateMediaCache(ctx context.Context) {
}
}
-func (s *MediaService) attachLibraryMetadata(ctx context.Context, items []model.Media) {
- if s == nil || s.repo == nil || s.repo.Library == nil || len(items) == 0 {
- return
- }
- libs, err := s.repo.Library.List(ctx)
- if err != nil {
- return
- }
- byID := make(map[string]model.Library, len(libs))
- for _, lib := range libs {
- byID[lib.ID] = lib
- }
- resolver := newMediaDisplayLibraryResolver(ctx, s.repo, libs)
- for i := range items {
- if lib, ok := byID[items[i].LibraryID]; ok {
- items[i].LibraryName = lib.Name
- items[i].LibraryPath = lib.Path
- }
- if lib, ok := resolver.DisplayLibraryForMedia(items[i]); ok {
- items[i].DisplayLibraryID = lib.ID
- items[i].DisplayLibraryName = lib.Name
- items[i].DisplayLibraryPath = lib.Path
- }
- }
-}
-
-type mediaDisplayLibraryResolver struct {
- byID map[string]model.Library
- displayByID map[string]model.Library
- displayByMergeKey map[string]model.Library
- displayLibraries []model.Library
-}
-
-func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Container, libs []model.Library) mediaDisplayLibraryResolver {
- displayLibraries := FilterDisplayCloudLibraries(ctx, repo, append([]model.Library(nil), libs...))
- resolver := mediaDisplayLibraryResolver{
- byID: make(map[string]model.Library, len(libs)),
- displayByID: make(map[string]model.Library, len(displayLibraries)),
- displayByMergeKey: make(map[string]model.Library, len(displayLibraries)),
- displayLibraries: displayLibraries,
- }
- for _, lib := range libs {
- resolver.byID[lib.ID] = lib
- }
- for _, lib := range displayLibraries {
- resolver.displayByID[lib.ID] = lib
- if key, ok := CloudLibraryMergeKey(lib); ok {
- if _, exists := resolver.displayByMergeKey[key]; !exists {
- resolver.displayByMergeKey[key] = lib
- }
- }
- }
- return resolver
-}
-
-func (r mediaDisplayLibraryResolver) DisplayLibraryForMedia(media model.Media) (model.Library, bool) {
- if lib, ok := r.bestPathDisplayLibrary(media); ok {
- return lib, true
- }
- if lib, ok := r.displayByID[media.LibraryID]; ok {
- return lib, true
- }
- own, hasOwn := r.byID[media.LibraryID]
- if hasOwn {
- if key, ok := CloudLibraryMergeKey(own); ok {
- if lib, exists := r.displayByMergeKey[key]; exists {
- return lib, true
- }
- }
- return own, true
- }
- return model.Library{}, false
-}
-
-func (r mediaDisplayLibraryResolver) bestPathDisplayLibrary(media model.Media) (model.Library, bool) {
- if strings.HasPrefix(strings.ToLower(strings.TrimSpace(media.Path)), "cloud://") {
- mediaInfo, ok := ParseCloudLibraryMount(media.Path)
- if !ok {
- return model.Library{}, false
- }
- var best model.Library
- bestDepth := 0
- for _, lib := range r.displayLibraries {
- info, ok := ParseCloudLibraryMount(lib.Path)
- if !ok || info.Provider != mediaInfo.Provider || !lib.Enabled {
- continue
- }
- dir := strings.Trim(firstNonEmpty(info.DisplayDir, info.ScanDir), "/")
- if dir == "" {
- continue
- }
- mediaDir := strings.Trim(firstNonEmpty(mediaInfo.DisplayDir, mediaInfo.ScanDir), "/")
- if mediaDir != dir && !cloudMountAncestor(dir, mediaDir) {
- continue
- }
- depth := len(strings.Split(dir, "/"))
- if depth > bestDepth {
- best = lib
- bestDepth = depth
- }
- }
- if bestDepth > 0 {
- return best, true
- }
- return model.Library{}, false
- }
-
- mediaPath := cleanPathForVolumeMapping(media.Path)
- var best model.Library
- bestLen := 0
- for _, lib := range r.displayLibraries {
- if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled {
- continue
- }
- libPath := cleanPathForVolumeMapping(lib.Path)
- if libPath == "" || libPath == "." {
- continue
- }
- if mediaPath != libPath && !strings.HasPrefix(mediaPath, strings.TrimRight(libPath, "/")+"/") {
- continue
- }
- if len(libPath) > bestLen {
- best = lib
- bestLen = len(libPath)
- }
- }
- if bestLen > 0 {
- return best, true
- }
- return model.Library{}, false
-}
-
func groupMediaVersions(items []model.Media) []MediaItem {
if len(items) == 0 {
return nil
diff --git a/internal/service/media_classifier.go b/internal/service/media_classifier.go
index a710cd9..01d0cfe 100644
--- a/internal/service/media_classifier.go
+++ b/internal/service/media_classifier.go
@@ -15,6 +15,9 @@ var (
classifierEpisodeRE = regexp.MustCompile(`(?i)\bS\d{1,2}E\d{1,3}\b|第\s*\d+\s*[集期]|(?:^|[\s._-])E\d{1,3}(?:[\s._-]|$)`)
classifierSeasonRE = regexp.MustCompile(`(?i)\bS\d{1,2}\b|第\s*\d+\s*季`)
classifierJAVCodeRE = regexp.MustCompile(`(?:^|[\s._\-/\[\]()])[A-Z]{2,6}[-_]?\d{3,5}(?:[\s._\-/\[\]()]|$)`)
+ classifierMovieRE = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:movies?|films?)(?:[^a-z0-9]|$)`)
+ classifierTVRE = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:tv|series|shows?|dramas?)(?:[^a-z0-9]|$)`)
+ classifierAnimeRE = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:anime|bangumi)(?:[^a-z0-9]|$)`)
)
const DownloadSmartClassifySettingKey = "downloads.smart_classify"
@@ -33,12 +36,16 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
genres := normalizeTokens(input.Genres...)
countries := normalizeTokens(input.Countries...)
languages := normalizeTokens(input.Languages...)
- rawText := input.Title + " " + input.Category + " " + strings.Join(input.Genres, " ")
+ rawTitleText := input.Title + " " + strings.Join(input.Genres, " ")
+ categoryText := strings.ToLower(input.Category)
+ rawText := rawTitleText + " " + input.Category
text := strings.ToLower(rawText)
hasMetadata := len(genres) > 0 || len(countries) > 0 || len(languages) > 0
+ sourceHint := sourceCategoryHint(input.Category, mediaType, categories)
isChineseByMetadata := hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN", "BO", "ZA") || hasAny(countries, "CN", "TW", "HK", "MO")
- isChineseByText := containsHan(rawText) || containsAnyText(text, "华语", "国产", "国剧", "国漫")
+ isChineseByText := containsHan(rawTitleText) || containsAnyText(strings.ToLower(rawTitleText), "华语", "国产", "国剧", "国漫")
+ isChineseByCategory := containsAnyText(categoryText, "华语", "国产", "国剧", "大陆剧", "国产电视剧", "国产电影", "国漫", "国产动漫", "国产动画")
isChinese := isChineseByMetadata || (!hasMetadata && isChineseByText)
// 动漫的中文译名几乎都是纯汉字(如日本动画「葬送的芙莉莲」),用 containsHan
// 判中文会把日本动画误判成国漫。动漫只在有元数据或显式中文标记时才算国漫,
@@ -46,12 +53,14 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
isChineseAnime := isChineseByMetadata || (!hasMetadata && containsAnyText(text, "华语", "国产", "国漫", "國漫", "国创", "国产动漫", "国产动画"))
isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || containsJapaneseKana(rawText) || strings.Contains(text, "日番")
isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || containsKoreanHangul(rawText)
- isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG")
+ isEastAsianByCategory := containsAnyText(categoryText, "日韩剧", "日剧", "韩剧", "日韩电影")
+ isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG") || (!hasMetadata && isEastAsianByCategory)
isWesternByMetadata := hasAny(countries,
"US", "GB", "UK", "FR", "DE", "CA", "AU", "NZ", "IE", "NL", "SE", "NO", "DK",
"FI", "ES", "IT", "PT", "AT", "CH", "BE", "RU",
)
- isWestern := isWesternByMetadata
+ isWesternByCategory := containsAnyText(categoryText, "欧美剧", "欧美电视剧", "美剧", "英剧", "欧美电影", "外语电影")
+ isWestern := isWesternByMetadata || (!hasMetadata && isWesternByCategory)
hasAnimeText := containsAnyText(text, "动画", "动漫", "番剧", "年番", "国漫", "日番", "bangumi", "anime", "b-global", "ani-one", "crunchyroll")
hasVarietyText := containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show")
hasDocumentaryText := containsAnyText(text, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo")
@@ -80,14 +89,23 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText {
return categoryName(categories, "animation_movie", "动画电影")
}
+ if !hasMetadata && sourceHint != "" {
+ return sourceHint
+ }
if isChinese {
return categoryName(categories, "chinese_movie", "华语电影")
}
return categoryName(categories, "foreign_movie", "外语电影")
case "anime":
+ if !hasMetadata && sourceHint != "" {
+ return sourceHint
+ }
if isChineseAnime {
return categoryName(categories, "cn_anime", "国漫")
}
+ if isWesternByMetadata || (!hasMetadata && containsAnyText(categoryText, "欧美动漫", "欧美动画", "西方动画")) {
+ return categoryName(categories, "euus_anime", "欧美动漫")
+ }
return categoryName(categories, "jp_anime", "日番")
case "variety":
return categoryName(categories, "variety", "综艺")
@@ -108,9 +126,15 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
if isChineseAnime {
return categoryName(categories, "cn_anime", "国漫")
}
+ if isWesternByMetadata || (!hasMetadata && containsAnyText(categoryText, "欧美动漫", "欧美动画", "西方动画")) {
+ return categoryName(categories, "euus_anime", "欧美动漫")
+ }
return categoryName(categories, "jp_anime", "日番")
}
- if isChinese {
+ if !hasMetadata && sourceHint != "" {
+ return sourceHint
+ }
+ if isChinese || (!hasMetadata && isChineseByCategory) {
return categoryName(categories, "domestic_tv", "国产剧")
}
if isEastAsian {
@@ -145,11 +169,11 @@ func normalizeMediaType(mediaType, title, category string) string {
return "adult"
case containsAnyText(raw, "综艺", "真人秀"):
return "variety"
- case containsAnyText(raw, "国漫", "日漫", "日番", "动漫", "动画", "anime", "bangumi") && !containsAnyText(raw, "动画电影"):
+ case (containsAnyText(raw, "国漫", "日漫", "日番", "动漫", "动画") || classifierAnimeRE.MatchString(raw)) && !containsAnyText(raw, "动画电影"):
return "anime"
- case containsAnyText(raw, "电视剧", "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "剧集", "tv", "series"):
+ case containsAnyText(raw, "电视剧", "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "剧集") || classifierTVRE.MatchString(raw):
return "tv"
- case containsAnyText(raw, "电影", "movie", "film"):
+ case containsAnyText(raw, "电影") || classifierMovieRE.MatchString(raw):
return "movie"
}
text := strings.ToLower(title + " " + category)
@@ -158,13 +182,13 @@ func normalizeMediaType(mediaType, title, category string) string {
return "adult"
case containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show"):
return "variety"
- case strings.Contains(text, "movie") || strings.Contains(text, "电影"):
+ case strings.Contains(text, "电影") || classifierMovieRE.MatchString(text):
return "movie"
- case strings.Contains(text, "anime") || strings.Contains(text, "bangumi") || strings.Contains(text, "动漫") || strings.Contains(text, "动画"):
+ case classifierAnimeRE.MatchString(text) || strings.Contains(text, "动漫") || strings.Contains(text, "动画"):
return "anime"
case strings.Contains(text, "variety") || strings.Contains(text, "综艺") || strings.Contains(text, "真人秀"):
return "variety"
- case classifierEpisodeRE.MatchString(text) || classifierSeasonRE.MatchString(text) || strings.Contains(text, "tv") || strings.Contains(text, "剧集") || strings.Contains(text, "电视剧"):
+ case classifierEpisodeRE.MatchString(text) || classifierSeasonRE.MatchString(text) || classifierTVRE.MatchString(text) || strings.Contains(text, "剧集") || strings.Contains(text, "电视剧"):
return "tv"
default:
return "movie"
@@ -261,6 +285,80 @@ func categoryName(categories map[string]string, key, fallback string) string {
return fallback
}
+type sourceCategoryHintDef struct {
+ Key string
+ Fallback string
+ MediaType string
+}
+
+var sourceCategoryHints = []sourceCategoryHintDef{
+ {Key: "animation_movie", Fallback: "动画电影", MediaType: "movie"},
+ {Key: "chinese_movie", Fallback: "华语电影", MediaType: "movie"},
+ {Key: "jk_movie", Fallback: "日韩电影", MediaType: "movie"},
+ {Key: "euus_movie", Fallback: "欧美电影", MediaType: "movie"},
+ {Key: "foreign_movie", Fallback: "外语电影", MediaType: "movie"},
+ {Key: "domestic_tv", Fallback: "国产剧", MediaType: "tv"},
+ {Key: "euus_tv", Fallback: "欧美剧", MediaType: "tv"},
+ {Key: "jk_tv", Fallback: "日韩剧", MediaType: "tv"},
+ {Key: "cn_anime", Fallback: "国漫", MediaType: "anime"},
+ {Key: "jp_anime", Fallback: "日番", MediaType: "anime"},
+ {Key: "euus_anime", Fallback: "欧美动漫", MediaType: "anime"},
+ {Key: "variety", Fallback: "综艺", MediaType: "variety"},
+ {Key: "documentary", Fallback: "纪录片", MediaType: "tv"},
+ {Key: "children", Fallback: "儿童", MediaType: "tv"},
+ {Key: "adult", Fallback: "成人", MediaType: "adult"},
+ {Key: "adult_9kg", Fallback: "9KG", MediaType: "adult"},
+ {Key: "adult_jav", Fallback: "番号", MediaType: "adult"},
+}
+
+func sourceCategoryHint(category, mediaType string, categories map[string]string) string {
+ tokens := sourceCategoryTokens(category)
+ if len(tokens) == 0 {
+ return ""
+ }
+ for _, hint := range sourceCategoryHints {
+ if !sourceCategoryCompatible(mediaType, hint.MediaType) {
+ continue
+ }
+ for _, name := range []string{hint.Fallback, categoryName(categories, hint.Key, hint.Fallback)} {
+ if _, ok := tokens[strings.ToLower(strings.TrimSpace(name))]; ok {
+ return categoryName(categories, hint.Key, hint.Fallback)
+ }
+ }
+ }
+ return ""
+}
+
+func sourceCategoryTokens(category string) map[string]struct{} {
+ category = strings.TrimSpace(category)
+ if category == "" {
+ return nil
+ }
+ normalized := strings.NewReplacer("\\", " ", "/", " ", "|", " ", ",", " ", ";", " ").Replace(category)
+ out := map[string]struct{}{
+ strings.ToLower(category): {},
+ }
+ for _, field := range strings.Fields(normalized) {
+ out[strings.ToLower(strings.TrimSpace(field))] = struct{}{}
+ }
+ return out
+}
+
+func sourceCategoryCompatible(mediaType, categoryMediaType string) bool {
+ mediaType = strings.ToLower(strings.TrimSpace(mediaType))
+ categoryMediaType = strings.ToLower(strings.TrimSpace(categoryMediaType))
+ if mediaType == "" || categoryMediaType == "" || mediaType == categoryMediaType {
+ return true
+ }
+ if mediaType == "tv" && (categoryMediaType == "anime" || categoryMediaType == "variety") {
+ return true
+ }
+ if categoryMediaType == "adult" {
+ return true
+ }
+ return false
+}
+
func (o *OrganizerService) categoryMap() map[string]string {
if o == nil || o.cfg == nil || o.cfg.Organizer.Categories == nil {
return nil
@@ -341,7 +439,7 @@ func (s *SubscriptionService) lookupSubscriptionMetadata(ctx context.Context, me
if candidate == "" {
continue
}
- match := s.scraper.lookup(ctx, lib, candidate, year)
+ match := s.scraper.lookup(ctx, lib, nil, candidate, year)
if match == nil || strings.TrimSpace(match.Title) == "" {
continue
}
diff --git a/internal/service/media_classifier_test.go b/internal/service/media_classifier_test.go
index 41d7114..c6f54fa 100644
--- a/internal/service/media_classifier_test.go
+++ b/internal/service/media_classifier_test.go
@@ -3,9 +3,7 @@ package service
import (
"testing"
- "github.com/glebarez/sqlite"
"go.uber.org/zap"
- "gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
@@ -119,6 +117,33 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
},
want: "未分类",
},
+ {
+ name: "latin tv keeps explicit western source category",
+ input: mediaClassifyInput{
+ MediaType: "tv",
+ Title: "Blades.of.the.Guardians.S02E01.1080p",
+ Category: "downloads 欧美剧 Blades.of.the.Guardians",
+ },
+ want: "欧美剧",
+ },
+ {
+ name: "generic tv folder is not treated as chinese category",
+ input: mediaClassifyInput{
+ MediaType: "tv",
+ Title: "The Last of Us S01E01 1080p",
+ Category: "downloads 电视剧",
+ },
+ want: "未分类",
+ },
+ {
+ name: "gala title overrides wrong western source category",
+ input: mediaClassifyInput{
+ MediaType: "tv",
+ Title: "HNTV Spring Festival Gala 2026 2160p WEB-DL",
+ Category: "欧美剧",
+ },
+ want: "综艺",
+ },
{
name: "platform token alone does not classify romanized drama",
input: mediaClassifyInput{
@@ -171,6 +196,36 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
},
want: "日番",
},
+ {
+ name: "western anime metadata uses western anime category",
+ input: mediaClassifyInput{
+ MediaType: "anime",
+ Title: "Family Guy",
+ Countries: []string{"US"},
+ Genres: []string{"16"},
+ Category: "日番",
+ },
+ want: "欧美动漫",
+ },
+ {
+ name: "tv animation with western metadata uses western anime category",
+ input: mediaClassifyInput{
+ MediaType: "tv",
+ Title: "The Simpsons",
+ Countries: []string{"US"},
+ Genres: []string{"Animation"},
+ },
+ want: "欧美动漫",
+ },
+ {
+ name: "western anime source category is preserved without metadata",
+ input: mediaClassifyInput{
+ MediaType: "anime",
+ Title: "The Simpsons S01E01 1080p",
+ Category: "downloads 欧美动漫",
+ },
+ want: "欧美动漫",
+ },
{
name: "anime with CN country metadata is cn",
input: mediaClassifyInput{
@@ -181,6 +236,15 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
},
want: "国漫",
},
+ {
+ name: "western movie source category remains western movie",
+ input: mediaClassifyInput{
+ MediaType: "movie",
+ Title: "Dune 2021 2160p",
+ Category: "downloads 欧美电影",
+ },
+ want: "欧美电影",
+ },
{
name: "jav code is adult",
input: mediaClassifyInput{
@@ -217,14 +281,27 @@ func TestNormalizeMediaTypeAcceptsChineseLibraryTypes(t *testing.T) {
}
}
+func TestNormalizeMediaTypeDoesNotTreatReleaseTokensAsTV(t *testing.T) {
+ tests := []string{
+ "They Will Kill You 2026 1080p HDTV x264",
+ "Some Movie 2026 2160p AppleTV WEB-DL",
+ "Some Movie 2026 2160p ATVP WEB-DL",
+ }
+ for _, input := range tests {
+ t.Run(input, func(t *testing.T) {
+ if got := normalizeMediaType("", input, ""); got != "movie" {
+ t.Fatalf("normalizeMediaType(%q) = %q, want movie", input, got)
+ }
+ })
+ }
+
+ if got := normalizeMediaType("", "The Last of Us", `F:\media\tv\The Last of Us`); got != "tv" {
+ t.Fatalf("standalone tv path token = %q, want tv", got)
+ }
+}
+
func TestSubscriptionResolveClassifiedSavePath(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.Setting{}); err != nil {
- t.Fatal(err)
- }
+ db := newServiceTestDB(t, &model.Setting{})
repos := repository.New(db)
if err := repos.Setting.Set(t.Context(), "organizer.smart_classify", "true"); err != nil {
t.Fatal(err)
diff --git a/internal/service/media_display_library.go b/internal/service/media_display_library.go
new file mode 100644
index 0000000..7523f12
--- /dev/null
+++ b/internal/service/media_display_library.go
@@ -0,0 +1,141 @@
+package service
+
+import (
+ "context"
+ "strings"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/repository"
+)
+
+func (s *MediaService) attachLibraryMetadata(ctx context.Context, items []model.Media) {
+ if s == nil || s.repo == nil || s.repo.Library == nil || len(items) == 0 {
+ return
+ }
+ libs, err := s.repo.Library.List(ctx)
+ if err != nil {
+ return
+ }
+ byID := make(map[string]model.Library, len(libs))
+ for _, lib := range libs {
+ byID[lib.ID] = lib
+ }
+ resolver := newMediaDisplayLibraryResolver(ctx, s.repo, libs)
+ for i := range items {
+ if lib, ok := byID[items[i].LibraryID]; ok {
+ items[i].LibraryName = lib.Name
+ items[i].LibraryPath = lib.Path
+ }
+ if lib, ok := resolver.DisplayLibraryForMedia(items[i]); ok {
+ items[i].DisplayLibraryID = lib.ID
+ items[i].DisplayLibraryName = lib.Name
+ items[i].DisplayLibraryPath = lib.Path
+ }
+ }
+}
+
+type mediaDisplayLibraryResolver struct {
+ byID map[string]model.Library
+ displayByID map[string]model.Library
+ displayByMergeKey map[string]model.Library
+ displayLibraries []model.Library
+}
+
+func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Container, libs []model.Library) mediaDisplayLibraryResolver {
+ displayLibraries := FilterDisplayCloudLibraries(ctx, repo, append([]model.Library(nil), libs...))
+ resolver := mediaDisplayLibraryResolver{
+ byID: make(map[string]model.Library, len(libs)),
+ displayByID: make(map[string]model.Library, len(displayLibraries)),
+ displayByMergeKey: make(map[string]model.Library, len(displayLibraries)),
+ displayLibraries: displayLibraries,
+ }
+ for _, lib := range libs {
+ resolver.byID[lib.ID] = lib
+ }
+ for _, lib := range displayLibraries {
+ resolver.displayByID[lib.ID] = lib
+ if key, ok := CloudLibraryMergeKey(lib); ok {
+ if _, exists := resolver.displayByMergeKey[key]; !exists {
+ resolver.displayByMergeKey[key] = lib
+ }
+ }
+ }
+ return resolver
+}
+
+func (r mediaDisplayLibraryResolver) DisplayLibraryForMedia(media model.Media) (model.Library, bool) {
+ if lib, ok := r.bestPathDisplayLibrary(media); ok {
+ return lib, true
+ }
+ if lib, ok := r.displayByID[media.LibraryID]; ok {
+ return lib, true
+ }
+ own, hasOwn := r.byID[media.LibraryID]
+ if hasOwn {
+ if key, ok := CloudLibraryMergeKey(own); ok {
+ if lib, exists := r.displayByMergeKey[key]; exists {
+ return lib, true
+ }
+ }
+ return own, true
+ }
+ return model.Library{}, false
+}
+
+func (r mediaDisplayLibraryResolver) bestPathDisplayLibrary(media model.Media) (model.Library, bool) {
+ if strings.HasPrefix(strings.ToLower(strings.TrimSpace(media.Path)), "cloud://") {
+ mediaInfo, ok := ParseCloudLibraryMount(media.Path)
+ if !ok {
+ return model.Library{}, false
+ }
+ var best model.Library
+ bestDepth := 0
+ for _, lib := range r.displayLibraries {
+ info, ok := ParseCloudLibraryMount(lib.Path)
+ if !ok || info.Provider != mediaInfo.Provider || !lib.Enabled {
+ continue
+ }
+ dir := strings.Trim(firstNonEmpty(info.DisplayDir, info.ScanDir), "/")
+ if dir == "" {
+ continue
+ }
+ mediaDir := strings.Trim(firstNonEmpty(mediaInfo.DisplayDir, mediaInfo.ScanDir), "/")
+ if mediaDir != dir && !cloudMountAncestor(dir, mediaDir) {
+ continue
+ }
+ depth := len(strings.Split(dir, "/"))
+ if depth > bestDepth {
+ best = lib
+ bestDepth = depth
+ }
+ }
+ if bestDepth > 0 {
+ return best, true
+ }
+ return model.Library{}, false
+ }
+
+ mediaPath := cleanPathForVolumeMapping(media.Path)
+ var best model.Library
+ bestLen := 0
+ for _, lib := range r.displayLibraries {
+ if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled {
+ continue
+ }
+ libPath := cleanPathForVolumeMapping(lib.Path)
+ if libPath == "" || libPath == "." {
+ continue
+ }
+ if mediaPath != libPath && !strings.HasPrefix(mediaPath, strings.TrimRight(libPath, "/")+"/") {
+ continue
+ }
+ if len(libPath) > bestLen {
+ best = lib
+ bestLen = len(libPath)
+ }
+ }
+ if bestLen > 0 {
+ return best, true
+ }
+ return model.Library{}, false
+}
diff --git a/internal/service/media_paths.go b/internal/service/media_paths.go
new file mode 100644
index 0000000..950b943
--- /dev/null
+++ b/internal/service/media_paths.go
@@ -0,0 +1,232 @@
+package service
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func inferLibraryKind(name, path, requested string) string {
+ requested = normalizeOrganizeMediaType(requested)
+ text := strings.ToLower(name + " " + filepath.ToSlash(path))
+ switch {
+ case containsAnyText(text, "成人", "番号", "jav", "9kg", "adult", "nsfw"):
+ return "adult"
+ case containsAnyText(text, "综艺", "真人秀", "variety"):
+ return "variety"
+ case containsAnyText(text, "国漫", "日漫", "日番", "动漫", "动画", "anime", "bangumi") && !containsAnyText(text, "动画电影"):
+ return "anime"
+ case containsAnyText(text, "电视剧", "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "剧集", "tv", "series"):
+ return "tv"
+ case containsAnyText(text, "电影", "movie", "film"):
+ return "movie"
+ }
+ if requested != "" {
+ return requested
+ }
+ return "movie"
+}
+
+func resolveAccessibleLibraryPath(path string) (string, error) {
+ input := strings.TrimSpace(path)
+ if input == "" {
+ return "", errors.New("path required")
+ }
+ for _, candidate := range mappedPathCandidates(input) {
+ if isAccessibleDir(candidate) {
+ return filepath.Clean(candidate), nil
+ }
+ }
+ abs, err := filepath.Abs(input)
+ if err != nil {
+ return "", fmt.Errorf("invalid path: %w", err)
+ }
+ return "", fmt.Errorf("path is not an accessible directory: %s", abs)
+}
+
+func resolveAccessibleMappedPath(path string) (string, os.FileInfo, error) {
+ input := strings.TrimSpace(path)
+ if input == "" {
+ return "", nil, errors.New("path required")
+ }
+ candidates := mappedPathCandidates(input)
+ for _, candidate := range candidates {
+ if info, err := os.Stat(candidate); err == nil {
+ return filepath.Clean(candidate), info, nil
+ }
+ }
+ abs, err := filepath.Abs(input)
+ if err != nil {
+ return "", nil, fmt.Errorf("invalid path: %w", err)
+ }
+ return "", nil, fmt.Errorf("path is not accessible: %s", abs)
+}
+
+func resolveMappedDestinationPath(path string) string {
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return ""
+ }
+ clean := filepath.Clean(path)
+ if _, err := os.Stat(clean); err == nil {
+ return clean
+ }
+ for _, candidate := range mappedPathCandidates(clean) {
+ if candidate == clean {
+ continue
+ }
+ return filepath.Clean(candidate)
+ }
+ return clean
+}
+
+func mappedPathCandidates(input string) []string {
+ var candidates []string
+ add := func(candidate string) {
+ candidate = filepath.Clean(filepath.FromSlash(strings.TrimSpace(candidate)))
+ if candidate == "" || candidate == "." {
+ return
+ }
+ for _, existing := range candidates {
+ if sameLibraryPath(existing, candidate) {
+ return
+ }
+ }
+ candidates = append(candidates, candidate)
+ }
+ clean := filepath.Clean(input)
+ add(clean)
+ for _, candidate := range dockerVolumePathCandidates(input) {
+ add(candidate)
+ }
+ for _, candidate := range dockerVolumePathCandidates(clean) {
+ add(candidate)
+ }
+ if slashClean := cleanPathForVolumeMapping(input); slashClean != "" {
+ add(slashClean)
+ }
+ if abs, err := filepath.Abs(input); err == nil {
+ add(abs)
+ for _, candidate := range dockerVolumePathCandidates(abs) {
+ add(candidate)
+ }
+ }
+ return candidates
+}
+
+func isAccessibleDir(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && info.IsDir()
+}
+
+func dockerVolumePathCandidates(path string) []string {
+ normalized := cleanPathForVolumeMapping(path)
+ var candidates []string
+ addCandidate := func(candidate string) {
+ candidate = filepath.Clean(filepath.FromSlash(candidate))
+ for _, existing := range candidates {
+ if sameLibraryPath(existing, candidate) {
+ return
+ }
+ }
+ candidates = append(candidates, candidate)
+ }
+
+ for _, mapping := range []struct {
+ env string
+ container string
+ }{
+ {env: "MEDIASTATION_MEDIA_DIR", container: envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")},
+ {env: "MEDIASTATION_DOWNLOAD_DIR", container: envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads")},
+ } {
+ host := cleanPathForVolumeMapping(os.Getenv(mapping.env))
+ if host == "." || host == "" || strings.HasPrefix(host, ".") {
+ continue
+ }
+ if normalized == host {
+ addCandidate(mapping.container)
+ continue
+ }
+ if strings.HasPrefix(normalized, host+"/") {
+ addCandidate(mapping.container + strings.TrimPrefix(normalized, host))
+ }
+ container := cleanPathForVolumeMapping(mapping.container)
+ if container == "." || container == "" || strings.HasPrefix(container, ".") {
+ continue
+ }
+ if normalized == container {
+ addCandidate(host)
+ continue
+ }
+ if strings.HasPrefix(normalized, container+"/") {
+ addCandidate(host + strings.TrimPrefix(normalized, container))
+ }
+ }
+
+ for _, marker := range []struct {
+ part string
+ container string
+ }{
+ {part: "/media", container: envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")},
+ {part: "/downloads", container: envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads")},
+ } {
+ part := strings.TrimRight(marker.part, "/")
+ container := strings.TrimRight(filepath.ToSlash(marker.container), "/")
+ markerPath := pathAfterWindowsDrivePrefix(normalized)
+ if markerPath == part {
+ addCandidate(container)
+ continue
+ }
+ if strings.HasPrefix(markerPath, part+"/") {
+ addCandidate(container + strings.TrimPrefix(markerPath, part))
+ }
+ }
+
+ return candidates
+}
+
+func cleanPathForVolumeMapping(path string) string {
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return ""
+ }
+ path = strings.ReplaceAll(path, "\\", "/")
+ path = trimEmbeddedWindowsDrive(path)
+ return filepath.ToSlash(filepath.Clean(filepath.FromSlash(path)))
+}
+
+func pathAfterWindowsDrivePrefix(path string) string {
+ if len(path) >= 3 && path[1] == ':' && path[2] == '/' && isASCIIAlpha(path[0]) {
+ return path[2:]
+ }
+ return path
+}
+
+func trimEmbeddedWindowsDrive(path string) string {
+ for i := 0; i+2 < len(path); i++ {
+ if !isASCIIAlpha(path[i]) || path[i+1] != ':' || path[i+2] != '/' {
+ continue
+ }
+ if i == 0 || path[i-1] == '/' {
+ return path[i:]
+ }
+ }
+ return path
+}
+
+func isASCIIAlpha(ch byte) bool {
+ return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
+}
+
+func sameLibraryPath(a, b string) bool {
+ return filepath.Clean(a) == filepath.Clean(b)
+}
+
+func envOrDefault(key, fallback string) string {
+ if value := strings.TrimSpace(os.Getenv(key)); value != "" {
+ return value
+ }
+ return fallback
+}
diff --git a/internal/service/media_series.go b/internal/service/media_series.go
new file mode 100644
index 0000000..be3bb65
--- /dev/null
+++ b/internal/service/media_series.go
@@ -0,0 +1,328 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+)
+
+type SeriesCard struct {
+ Key string `json:"key"`
+ Rep model.Media `json:"rep"`
+ LinkMedia model.Media `json:"linkMedia"`
+ Count int `json:"count"`
+}
+
+func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID string, visibility MediaVisibility) ([]SeriesCard, int64, error) {
+ rows, _, err := s.listAllMediaVisible(ctx, libraryID, visibility)
+ if err != nil {
+ return nil, 0, err
+ }
+ cards := groupMediaSeriesCards(rows)
+ return cards, int64(len(cards)), nil
+}
+
+func (s *MediaService) ListLibrarySeriesEpisodes(ctx context.Context, libraryID, key string, visibility MediaVisibility) ([]model.Media, error) {
+ rows, _, err := s.listAllMediaVisible(ctx, libraryID, visibility)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]model.Media, 0)
+ for _, row := range rows {
+ if mediaSeriesKey(row) == key {
+ out = append(out, row)
+ }
+ }
+ sort.SliceStable(out, func(i, j int) bool {
+ if out[i].SeasonNum != out[j].SeasonNum {
+ return out[i].SeasonNum < out[j].SeasonNum
+ }
+ if out[i].EpisodeNum != out[j].EpisodeNum {
+ return out[i].EpisodeNum < out[j].EpisodeNum
+ }
+ return out[i].CreatedAt.Before(out[j].CreatedAt)
+ })
+ return out, nil
+}
+
+func (s *MediaService) listAllMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, int64, error) {
+ const pageSize = 2000
+ var all []model.Media
+ var total int64
+ for page := 1; ; page++ {
+ rows, n, err := s.ListMediaVisible(ctx, libraryID, page, pageSize, visibility)
+ if err != nil {
+ return nil, 0, err
+ }
+ if page == 1 {
+ total = n
+ all = make([]model.Media, 0, minInt64(n, pageSize))
+ }
+ all = append(all, rows...)
+ if int64(len(all)) >= n || len(rows) < pageSize {
+ break
+ }
+ }
+ return all, total, nil
+}
+
+func groupMediaSeriesCards(items []model.Media) []SeriesCard {
+ if len(items) == 0 {
+ return nil
+ }
+ cards := make([]SeriesCard, 0)
+ byKey := make(map[string]int, len(items))
+ for _, item := range items {
+ key := mediaSeriesKey(item)
+ if key == "" {
+ continue
+ }
+ if idx, ok := byKey[key]; ok {
+ card := &cards[idx]
+ card.Count++
+ if betterSeriesLinkMedia(item, card.LinkMedia) {
+ card.LinkMedia = item
+ }
+ currentArtwork := seriesArtworkScore(item)
+ representativeArtwork := seriesArtworkScore(card.Rep)
+ if currentArtwork > representativeArtwork {
+ card.Rep = item
+ } else if currentArtwork == representativeArtwork {
+ cur := item.SeasonNum*10000 + item.EpisodeNum
+ rep := card.Rep.SeasonNum*10000 + card.Rep.EpisodeNum
+ if cur > 0 && (rep == 0 || cur < rep) {
+ card.Rep = item
+ }
+ }
+ continue
+ }
+ byKey[key] = len(cards)
+ cards = append(cards, SeriesCard{Key: key, Rep: item, LinkMedia: item, Count: 1})
+ }
+ return cards
+}
+
+var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|国产剧|欧美剧|日韩剧|日剧|韩剧|综艺|纪录片|动漫|番剧|国漫|日番|欧美动漫|欧美动画|儿童|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`)
+
+func mediaSeriesKey(media model.Media) string {
+ return compactSeriesKey(mediaSeriesRawKey(media))
+}
+
+func mediaSeriesRawKey(media model.Media) string {
+ fromPath := seriesTitleFromMediaPath(media.Path)
+ if media.SeasonNum > 0 || media.EpisodeNum > 0 || episodicPathRE.MatchString(media.Path+" "+media.DisplayLibraryPath+" "+media.LibraryPath) {
+ if fromPath != "" {
+ return seriesFingerprint("library-path", mediaTargetLibraryID(media), fromPath)
+ }
+ if media.TMDbID > 0 {
+ return fmt.Sprintf("tmdb:%d", media.TMDbID)
+ }
+ if media.BangumiID > 0 {
+ return fmt.Sprintf("bgm:%d", media.BangumiID)
+ }
+ if strings.TrimSpace(media.DoubanID) != "" {
+ return "douban:" + strings.TrimSpace(media.DoubanID)
+ }
+ if strings.TrimSpace(media.TheTVDBID) != "" {
+ return "thetvdb:" + strings.TrimSpace(media.TheTVDBID)
+ }
+ if strings.TrimSpace(media.SeriesID) != "" {
+ return "series:" + strings.TrimSpace(media.SeriesID)
+ }
+ return seriesFingerprint("library-title", mediaTargetLibraryID(media), normalizeSeriesTitle(seriesDisplayTitle(media)))
+ }
+ if strings.TrimSpace(media.SeriesID) != "" {
+ return "series:" + strings.TrimSpace(media.SeriesID)
+ }
+ if media.TMDbID > 0 {
+ return fmt.Sprintf("tmdb:%d", media.TMDbID)
+ }
+ if media.BangumiID > 0 {
+ return fmt.Sprintf("bgm:%d", media.BangumiID)
+ }
+ if fromPath != "" {
+ return seriesFingerprint("library-path", media.LibraryID, fromPath)
+ }
+ return seriesFingerprint("library-title", media.LibraryID, normalizeSeriesTitle(media.Title))
+}
+
+func seriesFingerprint(parts ...string) string {
+ return strings.Join(parts, "\x1f")
+}
+
+func compactSeriesKey(raw string) string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return ""
+ }
+ var hash uint32 = 2166136261
+ for _, b := range []byte(raw) {
+ hash ^= uint32(b)
+ hash *= 16777619
+ }
+ return fmt.Sprintf("series:%08x", hash)
+}
+
+var (
+ seriesYearRE = regexp.MustCompile(`\s*\((?:19|20)\d{2}\)\s*`)
+ seriesIDRE = regexp.MustCompile(`(?i)\s*\[(?:tmdb|tmdbid)[=-]\d+\]\s*`)
+ seriesBraceRE = regexp.MustCompile(`(?i)\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*`)
+ seriesSpacerRE = regexp.MustCompile(`[\s._-]+`)
+ seriesSeasonDirRE = regexp.MustCompile(`(?i)^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)$`)
+ seriesSpecialCodeRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:s0+\s*e?\s*\d+|season\s*0+(?:\s*episode)?\s*\d*|special(?:\s*episode)?s?\s*\d*|sp\s*\d*|ovas?\s*\d*|oads?\s*\d*|extras?\s*\d*|bonus(?:es)?\s*\d*|omake\s*\d*)\s*[\]))】]?$`)
+ seriesSpecialCJKRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$`)
+)
+
+func normalizeSeriesTitle(value string) string {
+ value = strings.ToLower(strings.TrimSpace(value))
+ value = seriesYearRE.ReplaceAllString(value, " ")
+ value = seriesIDRE.ReplaceAllString(value, " ")
+ value = seriesBraceRE.ReplaceAllString(value, " ")
+ value = seriesSpacerRE.ReplaceAllString(value, " ")
+ return strings.TrimSpace(value)
+}
+
+func normalizeSeriesPathTitle(value string) string {
+ title, _ := CleanQuery(value)
+ if title == "" {
+ title = normalizeSeriesTitle(value)
+ } else {
+ title = normalizeSeriesTitle(title)
+ }
+ stripped := stripSeriesSpecialSuffix(title)
+ if stripped != "" {
+ return stripped
+ }
+ return title
+}
+
+func stripSeriesSpecialSuffix(title string) string {
+ for _, re := range []*regexp.Regexp{seriesSpecialCodeRE, seriesSpecialCJKRE} {
+ stripped := strings.TrimSpace(re.ReplaceAllString(title, ""))
+ if stripped != "" && stripped != title {
+ return stripped
+ }
+ }
+ return title
+}
+
+func seriesTitleFromMediaPath(path string) string {
+ if strings.TrimSpace(path) == "" {
+ return ""
+ }
+ parts := strings.FieldsFunc(path, func(r rune) bool { return r == '/' || r == '\\' })
+ if len(parts) < 2 {
+ return ""
+ }
+ dirIndex := len(parts) - 2
+ for dirIndex >= 0 && seriesSeasonDirRE.MatchString(filepath.Base(parts[dirIndex])) {
+ dirIndex--
+ }
+ if dirIndex < 0 {
+ return ""
+ }
+ title := normalizeSeriesPathTitle(parts[dirIndex])
+ if unsafeAutomaticEpisodeQuery(title) {
+ return ""
+ }
+ return title
+}
+
+func seriesDisplayTitle(media model.Media) string {
+ if fromPath := seriesTitleFromMediaPath(media.Path); fromPath != "" {
+ return fromPath
+ }
+ if media.Title != "" {
+ return media.Title
+ }
+ if media.OriginalName != "" {
+ return media.OriginalName
+ }
+ return "未命名节目"
+}
+
+func mediaTargetLibraryID(media model.Media) string {
+ if strings.TrimSpace(media.DisplayLibraryID) != "" {
+ return media.DisplayLibraryID
+ }
+ return media.LibraryID
+}
+
+func betterSeriesLinkMedia(candidate, current model.Media) bool {
+ candidateScore := librarySpecificityScore(candidate)
+ currentScore := librarySpecificityScore(current)
+ if candidateScore != currentScore {
+ return candidateScore > currentScore
+ }
+ return seriesArtworkScore(candidate) > seriesArtworkScore(current)
+}
+
+func librarySpecificityScore(media model.Media) int {
+ rawPath := strings.TrimSpace(firstNonEmpty(media.DisplayLibraryPath, media.LibraryPath))
+ if rawPath == "" {
+ return 0
+ }
+ normalized := strings.TrimRight(strings.ReplaceAll(rawPath, "\\", "/"), "/")
+ lower := strings.ToLower(normalized)
+ if strings.HasPrefix(lower, "cloud://") {
+ rest := normalized[len("cloud://"):]
+ slash := strings.Index(rest, "/")
+ if slash < 0 || slash == len(rest)-1 {
+ return 0
+ }
+ return 100 + len(nonEmptySlashParts(rest[slash+1:]))
+ }
+ return 200 + len(nonEmptySlashParts(normalized))
+}
+
+func nonEmptySlashParts(value string) []string {
+ parts := strings.Split(value, "/")
+ out := parts[:0]
+ for _, part := range parts {
+ if strings.TrimSpace(part) != "" {
+ out = append(out, part)
+ }
+ }
+ return out
+}
+
+var (
+ posterArtworkRE = regexp.MustCompile(`(poster|folder|cover|movie|show|pl)(?:[._-]|\.[a-z0-9]+$|$)`)
+ badArtworkRE = regexp.MustCompile(`(actor|actress|cast|avatar|sample|screenshot|screen|still|scene|fanart|backdrop|background|landscape|banner|logo|disc)`)
+)
+
+func seriesArtworkScore(media model.Media) int {
+ poster := strings.ToLower(media.PosterURL)
+ backdrop := strings.ToLower(media.BackdropURL)
+ if poster == "" {
+ if backdrop != "" {
+ return 5
+ }
+ return 0
+ }
+ if posterArtworkRE.MatchString(poster) {
+ return 40
+ }
+ if badArtworkRE.MatchString(poster) {
+ return 10
+ }
+ if strings.Contains(poster, "thumb") {
+ return 20
+ }
+ return 30
+}
+
+func minInt64(a int64, b int) int {
+ if a <= 0 {
+ return 0
+ }
+ if a > int64(b) {
+ return b
+ }
+ return int(a)
+}
diff --git a/internal/service/media_series_test.go b/internal/service/media_series_test.go
new file mode 100644
index 0000000..308f4f2
--- /dev/null
+++ b/internal/service/media_series_test.go
@@ -0,0 +1,206 @@
+package service
+
+import (
+ "testing"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+)
+
+func TestMediaSeriesKeyCollapsesNestedSpecialFolders(t *testing.T) {
+ main := model.Media{
+ LibraryID: "lib-tv",
+ Path: `cloud://openlist/动漫/国漫/示例剧/Season 01/示例剧.S01E01.mkv`,
+ SeasonNum: 1,
+ EpisodeNum: 1,
+ }
+ special := model.Media{
+ LibraryID: "lib-tv",
+ Path: `cloud://openlist/动漫/国漫/示例剧/Extras/Season 01/示例剧.SP01.mkv`,
+ }
+
+ if got, want := mediaSeriesKey(special), mediaSeriesKey(main); got != want {
+ t.Fatalf("special key=%q, want main key=%q", got, want)
+ }
+
+ cards := groupMediaSeriesCards([]model.Media{main, special})
+ if len(cards) != 1 || cards[0].Count != 2 {
+ t.Fatalf("cards=%#v, want one merged series card with two items", cards)
+ }
+}
+
+func TestMediaSeriesKeyCollapsesSpecialTitleSuffix(t *testing.T) {
+ main := model.Media{
+ LibraryID: "lib-tv",
+ Path: `cloud://openlist/电视剧/欧美剧/Example Show/Season 01/Example.Show.S01E01.mkv`,
+ SeasonNum: 1,
+ EpisodeNum: 1,
+ }
+ special := model.Media{
+ LibraryID: "lib-tv",
+ Path: `cloud://openlist/电视剧/欧美剧/Example Show Specials/Example.Show.Special.01.mkv`,
+ SeasonNum: 0,
+ EpisodeNum: 1,
+ }
+ chineseSpecial := model.Media{
+ LibraryID: "lib-tv",
+ Path: `cloud://openlist/动漫/国漫/示例剧 特别篇/示例剧.SP01.mkv`,
+ SeasonNum: 0,
+ EpisodeNum: 1,
+ }
+ chineseMain := model.Media{
+ LibraryID: "lib-tv",
+ Path: `cloud://openlist/动漫/国漫/示例剧/Season 01/示例剧.S01E01.mkv`,
+ SeasonNum: 1,
+ EpisodeNum: 1,
+ }
+
+ if got, want := mediaSeriesKey(special), mediaSeriesKey(main); got != want {
+ t.Fatalf("english special key=%q, want main key=%q", got, want)
+ }
+ if got, want := mediaSeriesKey(chineseSpecial), mediaSeriesKey(chineseMain); got != want {
+ t.Fatalf("chinese special key=%q, want main key=%q", got, want)
+ }
+}
+
+func TestMediaSeriesKeyCollapsesSeasonZeroAndSpecialAliases(t *testing.T) {
+ main := model.Media{
+ LibraryID: "lib-anime",
+ Path: `cloud://openlist/动漫/日番/宝可梦 (1997) {tmdb-60572}/Season 1/宝可梦.S01E01.mkv`,
+ SeasonNum: 1,
+ EpisodeNum: 1,
+ }
+ seasonZero := model.Media{
+ LibraryID: "lib-anime",
+ Path: `cloud://openlist/动漫/日番/宝可梦 (1997) {tmdb-60572}/Season 0/宝可梦.S00E34.mkv`,
+ SeasonNum: 0,
+ EpisodeNum: 34,
+ }
+ specialEpisode := model.Media{
+ LibraryID: "lib-anime",
+ Path: `cloud://openlist/动漫/日番/宝可梦 Special Episode/宝可梦.SP01.mkv`,
+ SeasonNum: 0,
+ EpisodeNum: 1,
+ }
+ extraEpisode := model.Media{
+ LibraryID: "lib-anime",
+ Path: `cloud://openlist/动漫/日番/宝可梦 番外篇/宝可梦.SP02.mkv`,
+ SeasonNum: 0,
+ EpisodeNum: 2,
+ }
+
+ want := mediaSeriesKey(main)
+ for name, item := range map[string]model.Media{
+ "season zero": seasonZero,
+ "special episode": specialEpisode,
+ "番外篇": extraEpisode,
+ } {
+ if got := mediaSeriesKey(item); got != want {
+ t.Fatalf("%s key=%q, want main key=%q", name, got, want)
+ }
+ }
+}
+
+func TestMediaSeriesKeyCollapsesNumberedSpecialSuffixes(t *testing.T) {
+ main := model.Media{
+ LibraryID: "lib-tv",
+ Path: `F:\media\电视剧\欧美剧\Example Show\Season 01\Example Show - S01E01.mkv`,
+ SeasonNum: 1,
+ EpisodeNum: 1,
+ }
+ chineseMain := model.Media{
+ LibraryID: "lib-tv",
+ Path: `F:\media\电视剧\欧美剧\示例剧\Season 01\示例剧.S01E01.mkv`,
+ SeasonNum: 1,
+ EpisodeNum: 1,
+ }
+ cases := map[string]struct {
+ item model.Media
+ want model.Media
+ }{
+ "sp number": {
+ item: model.Media{
+ LibraryID: "lib-tv",
+ Path: `F:\media\电视剧\欧美剧\Example Show SP01\Example Show.SP01.mkv`,
+ SeasonNum: 0,
+ EpisodeNum: 1,
+ },
+ want: main,
+ },
+ "ova number": {
+ item: model.Media{
+ LibraryID: "lib-tv",
+ Path: `F:\media\电视剧\欧美剧\Example Show OVA 1\Example Show.OVA.1.mkv`,
+ SeasonNum: 0,
+ EpisodeNum: 1,
+ },
+ want: main,
+ },
+ "season zero episode": {
+ item: model.Media{
+ LibraryID: "lib-tv",
+ Path: `F:\media\电视剧\欧美剧\Example Show S00E01\Example Show.S00E01.mkv`,
+ SeasonNum: 0,
+ EpisodeNum: 1,
+ },
+ want: main,
+ },
+ "wrapped special": {
+ item: model.Media{
+ LibraryID: "lib-tv",
+ Path: `F:\media\电视剧\欧美剧\Example Show [Special]\Example Show.Special.mkv`,
+ SeasonNum: 0,
+ EpisodeNum: 1,
+ },
+ want: main,
+ },
+ "chinese numbered special": {
+ item: model.Media{
+ LibraryID: "lib-tv",
+ Path: `F:\media\电视剧\欧美剧\示例剧 特别篇 第1集\示例剧.SP01.mkv`,
+ SeasonNum: 0,
+ EpisodeNum: 1,
+ },
+ want: chineseMain,
+ },
+ }
+ for name, tt := range cases {
+ want := mediaSeriesKey(tt.want)
+ if got := mediaSeriesKey(tt.item); got != want {
+ t.Fatalf("%s key=%q, want main key=%q", name, got, want)
+ }
+ }
+}
+
+func TestMediaSeriesKeyCleansReleaseNoiseFolders(t *testing.T) {
+ clean := model.Media{
+ LibraryID: "lib-variety",
+ Path: `F:\media\电视剧\综艺\Hntv Spring Festival Gala S01e (2026)\Season 1\Hntv Spring Festival Gala S01e - S01E202.ts`,
+ SeasonNum: 1,
+ EpisodeNum: 202,
+ }
+ dirty := model.Media{
+ LibraryID: "lib-variety",
+ Path: `F:\media\电视剧\综艺\Hntv Spring Festival Gala Fps Hlg Qhstudio S01e (2026)\Season 1\Hntv Spring Festival Gala Fps Hlg Qhstudio S01e - S01E202.ts`,
+ SeasonNum: 1,
+ EpisodeNum: 202,
+ }
+ if got, want := mediaSeriesKey(dirty), mediaSeriesKey(clean); got != want {
+ t.Fatalf("dirty folder key=%q, want clean folder key=%q", got, want)
+ }
+
+ noisyRelease := model.Media{
+ LibraryID: "lib-tv",
+ Path: `F:\media\电视剧\欧美剧\Motherhood Of Taihang Aac2 Mweb\Season 1\Motherhood Of Taihang Aac2 Mweb - S01E01-Aac2.Mweb.mkv`,
+ SeasonNum: 1,
+ EpisodeNum: 1,
+ }
+ cleanRelease := model.Media{
+ LibraryID: "lib-tv",
+ Path: `F:\media\电视剧\欧美剧\Motherhood Of Taihang\Season 1\Motherhood Of Taihang - S01E01.mkv`,
+ SeasonNum: 1,
+ EpisodeNum: 1,
+ }
+ if got, want := mediaSeriesKey(noisyRelease), mediaSeriesKey(cleanRelease); got != want {
+ t.Fatalf("release-noise folder key=%q, want clean key=%q", got, want)
+ }
+}
diff --git a/internal/service/media_test.go b/internal/service/media_test.go
index 2ad0e3e..14482cb 100644
--- a/internal/service/media_test.go
+++ b/internal/service/media_test.go
@@ -7,7 +7,6 @@ import (
"testing"
"time"
- "github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
@@ -179,13 +178,7 @@ func TestResolveAccessibleMappedPathMapsWindowsDownloadVariants(t *testing.T) {
}
func TestDeleteCloudLibraryPurgesMountWithoutRecycleBin(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
- t.Fatal(err)
- }
+ db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Name: "OpenList · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
@@ -266,13 +259,7 @@ func TestGroupMediaVersionsMergesEpisodeByExternalIDAcrossLibraries(t *testing.T
}
func TestUpdateMediaMetadataMarksManualMatch(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
- t.Fatal(err)
- }
+ db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Name: "自采集", Path: "/media/custom", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
@@ -309,13 +296,7 @@ func TestUpdateMediaMetadataMarksManualMatch(t *testing.T) {
}
func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.Media{}); err != nil {
- t.Fatal(err)
- }
+ db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
path := "cloud://openlist/国漫/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv"
if err := repos.DB.Create(&model.Media{
@@ -350,13 +331,7 @@ func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) {
}
func TestMediaUpsertCorrectsCloudExternalIDConflicts(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.Media{}); err != nil {
- t.Fatal(err)
- }
+ db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
path := "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv"
if err := repos.DB.Create(&model.Media{
@@ -392,13 +367,7 @@ func TestMediaUpsertCorrectsCloudExternalIDConflicts(t *testing.T) {
}
func TestRepairCloudPathMetadataBackfillsExistingPlaceholders(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.Media{}); err != nil {
- t.Fatal(err)
- }
+ db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
path := "cloud://openlist/动画电影/雄狮少年2 (2024) {tmdb-1154478}/雄狮少年2 (2024) - 2160p.WEB-DL.H.265.DDP 5.1-ADWeb.mp4"
if err := repos.DB.Create(&model.Media{
@@ -427,13 +396,7 @@ func TestRepairCloudPathMetadataBackfillsExistingPlaceholders(t *testing.T) {
}
func TestRepairCloudPathMetadataCorrectsConflictingMatchedID(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.Media{}); err != nil {
- t.Fatal(err)
- }
+ db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
path := "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv"
if err := repos.DB.Create(&model.Media{
@@ -465,13 +428,7 @@ func TestRepairCloudPathMetadataCorrectsConflictingMatchedID(t *testing.T) {
}
func TestSoftDeleteCloudMediaPurgesRecordWithoutRecycleBin(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.Media{}); err != nil {
- t.Fatal(err)
- }
+ db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
media := model.Media{
Base: model.Base{ID: "cloud-media"},
@@ -504,13 +461,7 @@ func TestSoftDeleteCloudMediaPurgesRecordWithoutRecycleBin(t *testing.T) {
}
func TestListRecycleBinPrunesOldRowsOverLimit(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.Media{}); err != nil {
- t.Fatal(err)
- }
+ db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
now := time.Now()
for i := 0; i < maxRecycleBinRecords+5; i++ {
@@ -553,13 +504,7 @@ func TestListRecycleBinPrunesOldRowsOverLimit(t *testing.T) {
}
func TestSoftDeleteInvalidatesMediaAndStatsCache(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.Media{}); err != nil {
- t.Fatal(err)
- }
+ db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
media := model.Media{
Base: model.Base{ID: "local-media"},
diff --git a/internal/service/media_visibility_test.go b/internal/service/media_visibility_test.go
index cadc319..7200f5f 100644
--- a/internal/service/media_visibility_test.go
+++ b/internal/service/media_visibility_test.go
@@ -8,19 +8,11 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
- "github.com/glebarez/sqlite"
"go.uber.org/zap"
- "gorm.io/gorm"
)
func TestMediaVisibilityFiltersNSFWAndLibraries(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil {
- t.Fatal(err)
- }
+ db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{})
repos := repository.New(db)
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
@@ -90,14 +82,55 @@ func TestMediaVisibilityFiltersNSFWAndLibraries(t *testing.T) {
}
}
-func TestConfiguredAdultLibrariesDoNotHideSafeLibraryWithNSFWItems(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+func TestMediaVisibilityHidesDeprecatedNativeCloudLibraries(t *testing.T) {
+ db := newServiceTestDB(t, &model.Library{}, &model.Media{})
+ repos := repository.New(db)
+ svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
+
+ legacy := model.Library{
+ Name: "旧云盘",
+ Path: BuildCloudLibraryPath(LegacyQuarkProvider, "archive", "archive"),
+ Type: "movie",
+ Enabled: true,
+ }
+ openList := model.Library{
+ Name: "OpenList",
+ Path: BuildCloudLibraryPath("openlist", "movies", "movies"),
+ Type: "movie",
+ Enabled: true,
+ }
+ if err := repos.Library.Create(t.Context(), &legacy); err != nil {
+ t.Fatal(err)
+ }
+ if err := repos.Library.Create(t.Context(), &openList); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Create(&[]model.Media{
+ {LibraryID: legacy.ID, Title: "历史媒体", Path: "cloud://" + LegacyQuarkProvider + "/archive/old.mkv"},
+ {LibraryID: openList.ID, Title: "可见媒体", Path: "cloud://openlist/movies/new.mkv"},
+ }).Error; err != nil {
+ t.Fatal(err)
+ }
+
+ items, err := svc.SearchMediaVisible(t.Context(), "媒体", 20, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
- if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil {
+ if got := sortedMediaTitles(items); !slices.Equal(got, []string{"可见媒体"}) {
+ t.Fatalf("deprecated native cloud media should be hidden from search, got %#v", got)
+ }
+
+ listed, total, err := svc.ListMediaVisible(t.Context(), legacy.ID, 1, 20, MediaVisibility{IncludeNSFW: true})
+ if err != nil {
t.Fatal(err)
}
+ if total != 0 || len(listed) != 0 {
+ t.Fatalf("deprecated native cloud media should be hidden from direct list total=%d rows=%#v", total, sortedMediaTitles(listed))
+ }
+}
+
+func TestConfiguredAdultLibrariesDoNotHideSafeLibraryWithNSFWItems(t *testing.T) {
+ db := newServiceTestDB(t, &model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{})
repos := repository.New(db)
safe := model.Library{Name: "电影", Path: "/media/movie", Type: "movie", Enabled: true}
@@ -142,13 +175,7 @@ func TestConfiguredAdultLibrariesDoNotHideSafeLibraryWithNSFWItems(t *testing.T)
}
func TestSearchMediaVisibleHonorsLargePosterWallLimit(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
- t.Fatal(err)
- }
+ db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Name: "海报墙", Path: "/media/all", Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
@@ -177,13 +204,7 @@ func TestSearchMediaVisibleHonorsLargePosterWallLimit(t *testing.T) {
}
func TestSearchMediaVisibleCanReturnHugeLibraryResultsWhenRequested(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
- t.Fatal(err)
- }
+ db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Name: "海量剧集", Path: "/media/huge", Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
diff --git a/internal/service/metadata_match_trust.go b/internal/service/metadata_match_trust.go
new file mode 100644
index 0000000..4d3b4ac
--- /dev/null
+++ b/internal/service/metadata_match_trust.go
@@ -0,0 +1,98 @@
+package service
+
+import (
+ "regexp"
+ "strings"
+)
+
+var (
+ metadataTrustTokenRE = regexp.MustCompile(`[\p{L}\p{N}]+`)
+ metadataTrustDanglingEpisodeRE = regexp.MustCompile(`(?i)^s\d{1,2}e$`)
+)
+
+func automaticMetadataTitleTrusted(query string, match *Match) bool {
+ queryKey := metadataTrustKey(query)
+ if queryKey == "" || match == nil {
+ return false
+ }
+ for _, title := range []string{match.Title, match.OriginalName} {
+ titleKey := metadataTrustKey(title)
+ if titleKey == "" {
+ continue
+ }
+ if queryKey == titleKey || metadataTrustTokenOverlap(queryKey, titleKey) {
+ return true
+ }
+ }
+ return false
+}
+
+func metadataTrustKey(value string) string {
+ value = strings.ToLower(strings.TrimSpace(value))
+ if value == "" {
+ return ""
+ }
+ value = bracketedTag.ReplaceAllString(value, " ")
+ value = yearPattern.ReplaceAllString(value, " ")
+ for _, re := range []*regexp.Regexp{patSEnE, patDanglingSE, patNxE, patEP, patCN, patSeasonOnly, patCNSeason} {
+ value = re.ReplaceAllString(value, " ")
+ }
+ tokens := metadataTrustTokenRE.FindAllString(value, -1)
+ out := make([]string, 0, len(tokens))
+ for _, token := range tokens {
+ token = strings.TrimSpace(token)
+ if token == "" || metadataTrustNoiseToken(token) {
+ continue
+ }
+ out = append(out, token)
+ }
+ return strings.Join(out, " ")
+}
+
+func metadataTrustNoiseToken(token string) bool {
+ if token == "x" || token == "×" {
+ return true
+ }
+ if _, ok := noiseTokenSet[token]; ok {
+ return true
+ }
+ if metadataTrustDanglingEpisodeRE.MatchString(token) {
+ return true
+ }
+ return false
+}
+
+func metadataTrustTokenOverlap(queryKey, titleKey string) bool {
+ queryTokens := metadataTrustSignificantTokens(queryKey)
+ titleTokens := metadataTrustSignificantTokens(titleKey)
+ if len(queryTokens) <= 1 || len(titleTokens) <= 1 {
+ return false
+ }
+ titleSet := make(map[string]struct{}, len(titleTokens))
+ for _, token := range titleTokens {
+ titleSet[token] = struct{}{}
+ }
+ overlap := 0
+ for _, token := range queryTokens {
+ if _, ok := titleSet[token]; ok {
+ overlap++
+ }
+ }
+ queryCoverage := float64(overlap) / float64(len(queryTokens))
+ titleCoverage := float64(overlap) / float64(len(titleTokens))
+ return queryCoverage >= 0.80 && titleCoverage >= 0.50
+}
+
+func metadataTrustSignificantTokens(key string) []string {
+ fields := strings.Fields(key)
+ out := make([]string, 0, len(fields))
+ for _, field := range fields {
+ switch field {
+ case "the", "a", "an", "of", "and":
+ continue
+ default:
+ out = append(out, field)
+ }
+ }
+ return out
+}
diff --git a/internal/service/nfo.go b/internal/service/nfo.go
index da91541..fed6e80 100644
--- a/internal/service/nfo.go
+++ b/internal/service/nfo.go
@@ -7,8 +7,8 @@
// movie.mkv -> movie.nfo (
`))
- default:
- http.NotFound(w, r)
- }
- }))
- defer upstream.Close()
-
- db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}, &model.APIConfig{}); err != nil {
- t.Fatal(err)
- }
- repos := repository.New(db)
- apiConfig := NewAPIConfigService(zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
- baseURL := upstream.URL
- if _, err := apiConfig.Update(t.Context(), "adult", APIConfigPatch{BaseURL: &baseURL}); err != nil {
- t.Fatal(err)
- }
- log := zap.NewNop()
- scraper := NewScraperService(&config.Config{}, log, repos, nil, nil, nil, nil, NewHub(log), NewAdultProvider(log, apiConfig))
-
- lib := model.Library{Name: "成人", Path: "/media/adult", Type: "movie", Enabled: true}
- if err := repos.DB.Create(&lib).Error; err != nil {
- t.Fatal(err)
- }
- media := model.Media{LibraryID: lib.ID, Title: "SSIS-001", OriginalName: "SSIS-001", Path: "/media/adult/SSIS-001.mkv"}
- if err := repos.DB.Create(&media).Error; err != nil {
- t.Fatal(err)
- }
-
- results, err := scraper.ManualSearch(t.Context(), &media, "SSIS-001", "adult", "adult")
- if err != nil {
- t.Fatal(err)
- }
- if len(results) != 1 || results[0].Source != "adult" || results[0].MediaType != "adult" || !results[0].NSFW || results[0].OriginalName != "SSIS-001" {
- t.Fatalf("manual adult candidates = %#v", results)
- }
-}
-
-func TestApplyManualMatchSavesSelectedCloudMatchWhenDetailsSlow(t *testing.T) {
- oldTimeout := tmdbDetailsTimeout
- tmdbDetailsTimeout = 20 * time.Millisecond
- defer func() { tmdbDetailsTimeout = oldTimeout }()
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/movie/77" {
- http.NotFound(w, r)
- return
- }
- select {
- case <-r.Context().Done():
- return
- case <-time.After(time.Second):
- _ = json.NewEncoder(w).Encode(map[string]any{
- "id": 77,
- "title": "Slow Details",
- })
- }
- }))
- defer upstream.Close()
-
- db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
- t.Fatal(err)
- }
- repos := repository.New(db)
- cfg := &config.Config{}
- cfg.Secrets.TMDbAPIKey = "test-key"
- cfg.Secrets.TMDbAPIProxy = upstream.URL
- log := zap.NewNop()
- scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
-
- lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
- if err := repos.DB.Create(&lib).Error; err != nil {
- t.Fatal(err)
- }
- media := model.Media{
- LibraryID: lib.ID,
- Title: "bad cloud title",
- Path: "cloud://openlist/Movies/Bad.Title.2026.mkv",
- ScrapeStatus: "pending",
- }
- if err := repos.DB.Create(&media).Error; err != nil {
- t.Fatal(err)
- }
-
- start := time.Now()
- if _, err := scraper.ApplyManualMatch(t.Context(), media.ID, ManualScrapeRequest{
- Source: "manual",
- MediaType: "movie",
- Title: "Correct Cloud Movie",
- TMDbID: 77,
- Year: 2026,
- }); err != nil {
- t.Fatal(err)
- }
- if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
- t.Fatalf("manual apply waited for optional details: %s", elapsed)
- }
-
- var got model.Media
- if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
- t.Fatal(err)
- }
- if got.Title != "Correct Cloud Movie" || got.ScrapeStatus != "matched" || got.TMDbID != 77 {
- t.Fatalf("manual cloud match was not saved: title=%q status=%q tmdb=%d", got.Title, got.ScrapeStatus, got.TMDbID)
- }
-}
-
func TestEnrichOneUsesExistingTMDbIDForCloudMedia(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
@@ -373,56 +41,6 @@ func TestEnrichOneUsesExistingTMDbIDForCloudMedia(t *testing.T) {
}
}
-func TestScrapeQueryCandidatesPreferSeriesFolderAndCJKTitle(t *testing.T) {
- lib := &model.Library{
- Path: `F:\downloads\国产剧`,
- Type: "movie",
- }
- media := &model.Media{
- Title: "亏成首富从游戏开始 the ri est in game",
- Path: `F:\downloads\国产剧\亏成首富从游戏开始 The Richest in Game\Season 01\亏成首富从游戏开始 The Richest in Game - S01E11 - 4K.mp4`,
- SeasonNum: 1,
- EpisodeNum: 11,
- }
-
- got := scrapeQueryCandidates(media, lib)
- if len(got) == 0 {
- t.Fatal("scrapeQueryCandidates returned no candidates")
- }
- if got[0] != "亏成首富从游戏开始" {
- t.Fatalf("first query candidate = %q, want Chinese series title", got[0])
- }
- for _, candidate := range got {
- if strings.Contains(candidate, "ri est") {
- t.Fatalf("query candidate kept substring-stripped title: %#v", got)
- }
- }
-}
-
-func TestScrapeQueryCandidatesSkipCategoryFolderAsSeriesTitle(t *testing.T) {
- lib := &model.Library{
- Path: `/downloads`,
- Type: "tv",
- }
- media := &model.Media{
- Title: "Ashes To Crown",
- Path: `/downloads/国产剧/Ashes.to.Crown.S01E06.1080p.WEB-DL.mkv`,
- SeasonNum: 1,
- EpisodeNum: 6,
- }
-
- got := scrapeQueryCandidates(media, lib)
- if len(got) == 0 {
- t.Fatal("scrapeQueryCandidates returned no candidates")
- }
- if got[0] == "国产剧" {
- t.Fatalf("first query candidate = %q, category folders must not be used as title candidates: %#v", got[0], got)
- }
- if !strings.EqualFold(got[0], "Ashes To Crown") {
- t.Fatalf("first query candidate = %q, want release title; all candidates=%#v", got[0], got)
- }
-}
-
func TestEnrichOneWritesTMDbIDColumn(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
@@ -460,6 +78,71 @@ func TestEnrichOneWritesTMDbIDColumn(t *testing.T) {
}
}
+func TestEnrichOneTreatsEpisodicMediaInMovieLibraryAsTV(t *testing.T) {
+ scraper, repos, closeServer := newTestScraper(t)
+ defer closeServer()
+
+ lib := model.Library{Name: "混合库", Path: t.TempDir(), Type: "movie", Enabled: true}
+ if err := repos.DB.Create(&lib).Error; err != nil {
+ t.Fatal(err)
+ }
+ media := model.Media{
+ LibraryID: lib.ID,
+ Title: "间谍过家家 S02E01",
+ Path: filepath.Join(lib.Path, "间谍过家家", "Season 02", "间谍过家家 - S02E01.mkv"),
+ SeasonNum: 2,
+ EpisodeNum: 1,
+ ScrapeStatus: "pending",
+ }
+ if err := repos.DB.Create(&media).Error; err != nil {
+ t.Fatal(err)
+ }
+
+ if err := scraper.EnrichOne(t.Context(), &media); err != nil {
+ t.Fatal(err)
+ }
+
+ var got model.Media
+ if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
+ t.Fatal(err)
+ }
+ if got.ScrapeStatus != "matched" || got.TMDbID != 12345 {
+ t.Fatalf("episodic media in movie library should use tv scrape: status=%q tmdb=%d", got.ScrapeStatus, got.TMDbID)
+ }
+}
+
+func TestDetermineMediaTypeForMediaHonorsExplicitMatchType(t *testing.T) {
+ scraper := &ScraperService{}
+ lib := &model.Library{Name: "欧美剧", Type: "tv"}
+ media := &model.Media{
+ Title: "错误识别的电影",
+ Path: filepath.Join("library", "欧美剧", "错误识别的电影 (2024)", "错误识别的电影.S01E202.mkv"),
+ SeasonNum: 1,
+ EpisodeNum: 202,
+ }
+
+ tests := []struct {
+ name string
+ match *Match
+ want string
+ }{
+ {name: "movie match overrides stale episode hints", match: &Match{MediaType: "movie"}, want: "movie"},
+ {name: "tv match stays tv", match: &Match{MediaType: "tv"}, want: "tv"},
+ {name: "anime match uses tmdb tv endpoint", match: &Match{MediaType: "anime"}, want: "tv"},
+ {name: "variety match uses tmdb tv endpoint", match: &Match{MediaType: "variety"}, want: "tv"},
+ {name: "adult match uses tmdb movie endpoint", match: &Match{MediaType: "adult"}, want: "movie"},
+ {name: "unknown match falls back to episodic hints", match: &Match{}, want: "tv"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := scraper.determineMediaTypeForMedia(lib, media, tt.match); got != tt.want {
+ t.Fatalf("determineMediaTypeForMedia() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
func TestEnrichOneWritesTMDbEpisodeMetadata(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
@@ -469,12 +152,16 @@ func TestEnrichOneWritesTMDbEpisodeMetadata(t *testing.T) {
t.Fatal(err)
}
mediaPath := filepath.Join(lib.Path, "间谍过家家 - S02E01.mkv")
+ existingPoster := "https://image.tmdb.org/t/p/w500/existing-poster.jpg"
+ existingBackdrop := "https://image.tmdb.org/t/p/w1280/existing-backdrop.jpg"
media := model.Media{
LibraryID: lib.ID,
Title: "间谍过家家",
Path: mediaPath,
SeasonNum: 2,
EpisodeNum: 1,
+ PosterURL: existingPoster,
+ BackdropURL: existingBackdrop,
ScrapeStatus: "pending",
}
if err := repos.DB.Create(&media).Error; err != nil {
@@ -499,6 +186,9 @@ func TestEnrichOneWritesTMDbEpisodeMetadata(t *testing.T) {
if got.Rating < 9.09 || got.Rating > 9.11 {
t.Fatalf("episode rating = %v, want 9.1", got.Rating)
}
+ if got.EpisodeTitle != "任务代号: 猫" {
+ t.Fatalf("episode_title should store per-episode name, got %q", got.EpisodeTitle)
+ }
// original_name 必须保持「整剧原名」,绝不能被单集名(任务代号: 猫)覆盖,
// 否则同剧每集 original_name 不同会导致合集被拆成多集无法合并。
if got.OriginalName != "SPY×FAMILY" {
@@ -506,137 +196,7 @@ func TestEnrichOneWritesTMDbEpisodeMetadata(t *testing.T) {
}
}
-func TestEnrichOneRejectsWrongYearMatchFromSeriesFolder(t *testing.T) {
- 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":
- _ = json.NewEncoder(w).Encode(map[string]any{
- "results": []map[string]any{{
- "id": 999,
- "name": "Parade of Stars Auto Show",
- "first_air_date": "1952-01-01",
- }},
- })
- default:
- http.NotFound(w, r)
- }
- }))
- defer upstream.Close()
-
- db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
- t.Fatal(err)
- }
- repos := repository.New(db)
- cfg := &config.Config{}
- cfg.Secrets.TMDbAPIKey = "test-key"
- cfg.Secrets.TMDbAPIProxy = upstream.URL
- log := zap.NewNop()
- scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
-
- root := t.TempDir()
- mediaPath := filepath.Join(root, "Auto Show (2026)", "Season 1", "Auto Show - S01E03 - 第 3 集.mkv")
- lib := model.Library{Name: "剧集", Path: root, Type: "tv", Enabled: true}
- if err := repos.DB.Create(&lib).Error; err != nil {
- t.Fatal(err)
- }
- media := model.Media{
- LibraryID: lib.ID,
- Title: "auto show",
- Path: mediaPath,
- SeasonNum: 1,
- EpisodeNum: 3,
- ScrapeStatus: "pending",
- }
- if err := repos.DB.Create(&media).Error; err != nil {
- t.Fatal(err)
- }
-
- if err := scraper.EnrichOne(t.Context(), &media); err != nil {
- t.Fatal(err)
- }
-
- var got model.Media
- if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
- t.Fatal(err)
- }
- if got.ScrapeStatus != "no_match" || got.Title != "auto show" || got.Year != 0 || got.TMDbID != 0 {
- t.Fatalf("wrong-year scrape should be rejected, got status=%q title=%q year=%d tmdb=%d", got.ScrapeStatus, got.Title, got.Year, got.TMDbID)
- }
-}
-
-func TestEnrichOnePrefersLocalMetadataWithoutProvider(t *testing.T) {
- db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
- t.Fatal(err)
- }
- repos := repository.New(db)
- log := zap.NewNop()
- scraper := NewScraperService(&config.Config{}, log, repos, nil, nil, nil, nil, NewHub(log))
-
- root := t.TempDir()
- showDir := filepath.Join(root, "间谍过家家")
- seasonDir := filepath.Join(showDir, "Season 02")
- if err := os.MkdirAll(seasonDir, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(`%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
new file mode 100644
index 0000000..2653af2
--- /dev/null
+++ b/internal/service/telegram_admin_users.go
@@ -0,0 +1,186 @@
+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, ":") // /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
index ef4226f..f30306f 100644
--- a/internal/service/telegram_api.go
+++ b/internal/service/telegram_api.go
@@ -55,6 +55,7 @@ func telegramHTTPClient(timeout time.Duration, cfg map[string]string) *http.Clie
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 {
@@ -70,6 +71,9 @@ func telegramHTTPClients(timeout time.Duration, cfg map[string]string) []*http.C
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
}
@@ -87,6 +91,9 @@ func telegramProxyCandidates(cfg map[string]string) []string {
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",
@@ -102,6 +109,10 @@ func telegramProxyCandidates(cfg map[string]string) []string {
return out
}
+func telegramUsesCustomAPIBase(cfg map[string]string) bool {
+ return telegramAPIBaseURL(cfg) != defaultTelegramAPIBaseURL
+}
+
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 {
diff --git a/internal/service/telegram_api_test.go b/internal/service/telegram_api_test.go
index 70aa189..6e10856 100644
--- a/internal/service/telegram_api_test.go
+++ b/internal/service/telegram_api_test.go
@@ -249,6 +249,17 @@ func TestTelegramProxyCandidatesDefaultLocalFallbacks(t *testing.T) {
}
}
+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",
diff --git a/internal/service/telegram_binding.go b/internal/service/telegram_binding.go
new file mode 100644
index 0000000..1130b6a
--- /dev/null
+++ b/internal/service/telegram_binding.go
@@ -0,0 +1,470 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "go.uber.org/zap"
+ "gorm.io/gorm"
+
+ "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) 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)))
+ }
+}
+
+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) 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
+}
+
+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
+}
+
+// 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
+}
+
+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
+ }
+}
+
+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
index 41e349f..3faf93b 100644
--- a/internal/service/telegram_bot.go
+++ b/internal/service/telegram_bot.go
@@ -9,8 +9,6 @@ import (
"encoding/json"
"errors"
"fmt"
- "io"
- "net/http"
"strconv"
"strings"
"sync"
@@ -18,7 +16,6 @@ import (
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
- "gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
@@ -86,17 +83,6 @@ type TelegramBotService struct {
pending map[int64]pendingInput // telegram_user_id -> awaited text input
}
-// 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"`
-}
-
// pendingInput tracks a button-initiated action that awaits the user's next
// text message (e.g. tapping「注册」then sending "用户名 密码").
type pendingInput struct {
@@ -602,954 +588,6 @@ func (s *TelegramBotService) cmdHideAdult(ctx context.Context, msg *TelegramMess
}
}
-// 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)
-}
-
-// ── Polling ──
-
-// 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")
-}
-
-// ── Message Sending ──
-
-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
-}
-
-// 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) 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)))
- }
-}
-
-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) 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
-}
-
-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
-}
-
-// 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
-}
-
-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
- }
-}
-
-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
-}
-
// ── Webhook Management ──
// SetWebhook 注册 Telegram Bot Webhook URL。
@@ -1574,21 +612,3 @@ func (s *TelegramBotService) GetWebhookInfo(ctx context.Context, botToken string
}
return result, nil
}
-
-// 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_cleanup_rules.go b/internal/service/telegram_cleanup_rules.go
new file mode 100644
index 0000000..6103b83
--- /dev/null
+++ b/internal/service/telegram_cleanup_rules.go
@@ -0,0 +1,220 @@
+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
new file mode 100644
index 0000000..47f5fdf
--- /dev/null
+++ b/internal/service/telegram_cleanup_rules_test.go
@@ -0,0 +1,34 @@
+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_commands.go b/internal/service/telegram_commands.go
index 21fe1f3..411d880 100644
--- a/internal/service/telegram_commands.go
+++ b/internal/service/telegram_commands.go
@@ -22,6 +22,17 @@ type telegramCommandDefinition struct {
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 {
@@ -39,6 +50,11 @@ func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, cha
{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) }},
@@ -53,7 +69,11 @@ func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, cha
}},
{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 }},
@@ -75,11 +95,21 @@ func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, cha
{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
}},
@@ -92,6 +122,11 @@ func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, cha
{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 }},
@@ -111,6 +146,11 @@ func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, cha
{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 }},
diff --git a/internal/service/telegram_device_policy.go b/internal/service/telegram_device_policy.go
new file mode 100644
index 0000000..79eeb57
--- /dev/null
+++ b/internal/service/telegram_device_policy.go
@@ -0,0 +1,252 @@
+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_menu.go b/internal/service/telegram_menu.go
index 0a254f8..064d543 100644
--- a/internal/service/telegram_menu.go
+++ b/internal/service/telegram_menu.go
@@ -2,25 +2,17 @@ package service
import (
"context"
- "encoding/json"
- "errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/ShukeBta/MediaStationGo/internal/model"
- "gorm.io/gorm"
)
// pendingTTL bounds how long a button-initiated text prompt stays valid.
const pendingTTL = 5 * time.Minute
-var (
- errRegistrationCodeAlreadyUsed = errors.New("registration code already used")
- errRegistrationCodeExpired = errors.New("registration code expired")
-)
-
func (s *TelegramBotService) setPending(userID int64, kind string) {
s.pendingMu.Lock()
s.pending[userID] = pendingInput{Kind: kind, CreatedAt: time.Now()}
@@ -50,121 +42,45 @@ func (s *TelegramBotService) boundUser(ctx context.Context, telegramUserID int)
return u
}
-// 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)
- isGroup := telegramIsGroupChat(msg.Chat.Type)
- user := s.boundUser(ctx, msg.From.ID)
-
- var rows [][]telegramInlineButton
- var header string
-
- if isGroup {
- if user == nil {
- header = "MediaStationGo 群组自助菜单\n\n你还没有绑定媒体中心账号。绑定、注册、兑换等包含敏感信息的操作请私聊 Bot。"
- } else {
- adult := map[bool]string{true: "已隐藏", false: "已显示"}[user.HideAdult]
- header = fmt.Sprintf("MediaStationGo 群组自助菜单\n\n账号:%s\n到期:%s\n成人目录:%s",
- user.Username, formatExpiry(user.ExpiredAt), adult)
- rows = append(rows,
- []telegramInlineButton{
- {Text: "👤 我的账号", Data: "act_account"},
- {Text: "📅 签到", Data: "act_signin"},
- },
- []telegramInlineButton{
- {Text: "📱 我的设备", Data: "act_devices"},
- {Text: map[bool]string{true: "🔞 显示成人目录", false: "🔞 隐藏成人目录"}[user.HideAdult], Data: "adult_toggle"},
- },
- )
- }
- if isAdmin {
- header += "\n\n管理员入口"
- rows = append(rows,
- []telegramInlineButton{{Text: "—— 管理员 ——", Data: "noop"}},
- []telegramInlineButton{
- {Text: "📊 容量/状态", Data: "adm_capacity"},
- {Text: "👥 用户管理", Data: "adm_users"},
- },
- []telegramInlineButton{
- {Text: "🔓 开注设置", Data: "adm_openreg"},
- {Text: "🎟 生成兑换码", Data: "adm_gencode"},
- },
- []telegramInlineButton{
- {Text: "⚙️ 设备策略", Data: "adm_devicepolicy"},
- {Text: "🛠 管理命令", Data: "adm_mgo_commands"},
- },
- )
- }
- return telegramCommandReply{Text: header, Buttons: rows}
- }
-
- if user == nil {
- header = "MediaStationGo\n\n你还没有绑定媒体中心账号。"
- rows = append(rows, []telegramInlineButton{{Text: "🔗 绑定账号", Data: "act_bind"}})
- if s.openRegEnabled(ctx) {
- rows = append(rows, []telegramInlineButton{{Text: "📝 注册新账号", Data: "act_register"}})
- }
- rows = append(rows, []telegramInlineButton{{Text: "🎟 兑换码注册", Data: "act_redeem_register"}})
- } else {
- adult := map[bool]string{true: "已隐藏", false: "已显示"}[user.HideAdult]
- header = fmt.Sprintf("MediaStationGo\n\n账号:%s\n到期:%s\n成人目录:%s",
- user.Username, formatExpiry(user.ExpiredAt), adult)
- rows = append(rows,
- []telegramInlineButton{
- {Text: "👤 我的账号", Data: "act_account"},
- {Text: "📅 签到", Data: "act_signin"},
- },
- []telegramInlineButton{
- {Text: "📱 我的设备", Data: "act_devices"},
- {Text: map[bool]string{true: "🔞 显示成人目录", false: "🔞 隐藏成人目录"}[user.HideAdult], Data: "adult_toggle"},
- },
- []telegramInlineButton{
- {Text: "✏️ 改用户名", Data: "act_setname"},
- {Text: "🔑 改密码", Data: "act_setpass"},
- },
- []telegramInlineButton{{Text: "🎟 兑换码续期", Data: "act_redeem_renew"}},
- )
- }
-
- if isAdmin {
- rows = append(rows,
- []telegramInlineButton{{Text: "—— 管理员 ——", Data: "noop"}},
- []telegramInlineButton{
- {Text: "📊 容量/状态", Data: "adm_capacity"},
- {Text: "👥 用户管理", Data: "adm_users"},
- },
- []telegramInlineButton{
- {Text: "🔓 开注设置", Data: "adm_openreg"},
- {Text: "🎟 生成兑换码", Data: "adm_gencode"},
- },
- []telegramInlineButton{
- {Text: "⚙️ 设备策略", Data: "adm_devicepolicy"},
- {Text: "🛠 管理命令", Data: "adm_mgo_commands"},
- },
- )
- }
-
- return telegramCommandReply{Text: header, Buttons: rows}
-}
-
// 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_bind":
- if isGroup {
- return telegramCommandReply{Text: telegramGroupPrivateUserHint("绑定账号")}, true
- }
- return telegramCommandReply{Text: "请发送:/start 用户名 密码 绑定已有账号。"}, true
- case data == "act_register":
+ 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
}
@@ -173,50 +89,63 @@ func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *mo
}
s.setPending(int64(msg.From.ID), "register")
return telegramCommandReply{Text: "请发送新账号的 用户名 密码(空格分隔),例如:alice mypass123"}, true
- case data == "act_redeem_register":
+ 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 data == "act_redeem_renew":
+ case "act_redeem_renew":
if isGroup {
return telegramCommandReply{Text: telegramGroupPrivateUserHint("兑换码续期")}, true
}
s.setPending(int64(msg.From.ID), "redeem_renew")
return telegramCommandReply{Text: "请发送你的续期兑换码,将为当前绑定账号续期。"}, 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 data == "act_setname":
- if isGroup {
- return telegramCommandReply{Text: telegramGroupPrivateUserHint("修改用户名")}, true
- }
- s.setPending(int64(msg.From.ID), "setname")
- return telegramCommandReply{Text: "请发送:当前密码 新用户名。"}, true
- case data == "act_setpass":
- if isGroup {
- return telegramCommandReply{Text: telegramGroupPrivateUserHint("修改密码")}, true
- }
- s.setPending(int64(msg.From.ID), "setpass")
- return telegramCommandReply{Text: "请发送:当前密码 新密码(新密码至少 6 位)。"}, true
- case strings.HasPrefix(data, "kick:"):
- return s.replyKick(ctx, msg, strings.TrimPrefix(data, "kick:")), 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
+}
- // ── 管理员专属 ──
- if isGroup && !isAdmin {
- return telegramCommandReply{}, true
+func telegramPrivateOnlyMenuReply(isGroup bool, action, privateText string) telegramCommandReply {
+ if isGroup {
+ return telegramCommandReply{Text: telegramGroupPrivateUserHint(action)}
}
- if !isAdmin {
- return telegramCommandReply{Text: "此功能仅管理员可用。"}, true
+ 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":
@@ -236,6 +165,12 @@ func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *mo
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:"):
@@ -248,12 +183,6 @@ func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *mo
return s.replyUserDelete(ctx, strings.TrimPrefix(data, "udel:")), true
case strings.HasPrefix(data, "urenew:"):
return s.replyUserRenew(ctx, strings.TrimPrefix(data, "urenew:")), 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
}
@@ -288,1343 +217,3 @@ func (s *TelegramBotService) handlePendingText(ctx context.Context, channel *mod
}
return telegramCommandReply{}, false
}
-
-// ── 用户自助 ──────────────────────────────────────────────────────────────
-
-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.repo.UserDevice.ListByUser(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 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) 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) 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.repo.UserDevice.ListByUser(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.repo.UserDevice.ListByUser(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 = "(已踢下线)"
- }
- 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 {
- 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: "请先绑定账号。"}
- }
- // Verify the device belongs to this user before kicking.
- 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 err := s.repo.UserDevice.SetKicked(ctx, d.ID, true); err != nil {
- return telegramCommandReply{Text: "操作失败:" + err.Error()}
- }
- return s.replyDevices(ctx, msg)
-}
-
-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
-}
-
-// ── 兑换码流程 ───────────────────────────────────────────────────────────────
-
-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
-}
-
-// ── 管理员:容量 / 开注 / 兑换码 / 用户管理 / 设备策略 ─────────────────────────
-
-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:%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)}
-}
-
-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, ":") // /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)
-}
-
-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)
- 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
- }
- 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
-}
-
-// 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 保护名单中,不可禁用/删除。"
- }
- return ""
-}
-
-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)
-}
-
-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)
-}
-
-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_menu_layout.go b/internal/service/telegram_menu_layout.go
new file mode 100644
index 0000000..851ca67
--- /dev/null
+++ b/internal/service/telegram_menu_layout.go
@@ -0,0 +1,115 @@
+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_compat.go b/internal/service/telegram_mgo_compat.go
index 7c75931..bd5a928 100644
--- a/internal/service/telegram_mgo_compat.go
+++ b/internal/service/telegram_mgo_compat.go
@@ -12,169 +12,6 @@ import (
"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: "未找到用户。"}
- }
- devices, _ := s.repo.UserDevice.ListByUser(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) 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.repo.UserDevice.ListByUser(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") + ""}
-}
-
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"}
diff --git a/internal/service/telegram_mgo_users.go b/internal/service/telegram_mgo_users.go
new file mode 100644
index 0000000..552ed3b
--- /dev/null
+++ b/internal/service/telegram_mgo_users.go
@@ -0,0 +1,184 @@
+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
new file mode 100644
index 0000000..399f1b3
--- /dev/null
+++ b/internal/service/telegram_polling.go
@@ -0,0 +1,208 @@
+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
new file mode 100644
index 0000000..dc9862f
--- /dev/null
+++ b/internal/service/telegram_redeem.go
@@ -0,0 +1,185 @@
+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
new file mode 100644
index 0000000..f710cd2
--- /dev/null
+++ b/internal/service/telegram_reply.go
@@ -0,0 +1,127 @@
+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
new file mode 100644
index 0000000..faf62b9
--- /dev/null
+++ b/internal/service/telegram_stats.go
@@ -0,0 +1,224 @@
+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
new file mode 100644
index 0000000..d496eee
--- /dev/null
+++ b/internal/service/telegram_unbind.go
@@ -0,0 +1,224 @@
+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
new file mode 100644
index 0000000..61c5c3f
--- /dev/null
+++ b/internal/service/telegram_user_self.go
@@ -0,0 +1,225 @@
+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 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 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/test_db_test.go b/internal/service/test_db_test.go
new file mode 100644
index 0000000..f7e082f
--- /dev/null
+++ b/internal/service/test_db_test.go
@@ -0,0 +1,26 @@
+package service
+
+import (
+ "testing"
+
+ "github.com/glebarez/sqlite"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+)
+
+func newServiceTestDB(t *testing.T, models ...any) *gorm.DB {
+ t.Helper()
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if sqlDB, err := db.DB(); err == nil {
+ t.Cleanup(func() { _ = sqlDB.Close() })
+ }
+ if len(models) > 0 {
+ if err := db.AutoMigrate(models...); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return db
+}
diff --git a/internal/service/thetvdb.go b/internal/service/thetvdb.go
index 51453d8..40d2be2 100644
--- a/internal/service/thetvdb.go
+++ b/internal/service/thetvdb.go
@@ -130,6 +130,7 @@ func (t *TheTVDBProvider) SearchSeries(ctx context.Context, query string) (*Matc
r := p.Data[0]
m := &Match{
TheTVDBID: r.ID,
+ MediaType: "tv",
Title: r.Name,
Overview: r.Overview,
PosterURL: r.Image,
@@ -240,6 +241,7 @@ func (t *TheTVDBProvider) GetSeriesMatchByID(ctx context.Context, seriesID strin
}
m := &Match{
TheTVDBID: idValue,
+ MediaType: "tv",
Title: out.Data.Name,
Overview: out.Data.Overview,
PosterURL: firstNonEmpty(out.Data.Image, out.Data.ImageURL),
diff --git a/internal/service/tmdb.go b/internal/service/tmdb.go
index 00d339d..7b93769 100644
--- a/internal/service/tmdb.go
+++ b/internal/service/tmdb.go
@@ -18,7 +18,6 @@ package service
import (
"context"
"encoding/json"
- "errors"
"fmt"
"net/http"
"net/url"
@@ -129,6 +128,7 @@ type Match struct {
BangumiID int `json:"bangumi_id"`
DoubanID string `json:"douban_id,omitempty"`
TheTVDBID string `json:"thetvdb_id,omitempty"`
+ MediaType string `json:"media_type,omitempty"`
Title string `json:"title"`
OriginalName string `json:"original_name,omitempty"`
Overview string `json:"overview"`
@@ -142,184 +142,6 @@ type Match struct {
NSFW bool `json:"nsfw,omitempty"`
}
-type tmdbMovieSearchResult struct {
- ID int `json:"id"`
- Title string `json:"title"`
- OriginalTitle string `json:"original_title"`
- OriginalLanguage string `json:"original_language"`
- Overview string `json:"overview"`
- PosterPath string `json:"poster_path"`
- BackdropPath string `json:"backdrop_path"`
- ReleaseDate string `json:"release_date"`
- VoteAverage float32 `json:"vote_average"`
- GenreIDs []int `json:"genre_ids"`
-}
-
-type tmdbTVSearchResult struct {
- ID int `json:"id"`
- Name string `json:"name"`
- OriginalName string `json:"original_name"`
- OriginalLanguage string `json:"original_language"`
- OriginCountry []string `json:"origin_country"`
- Overview string `json:"overview"`
- PosterPath string `json:"poster_path"`
- BackdropPath string `json:"backdrop_path"`
- FirstAirDate string `json:"first_air_date"`
- VoteAverage float32 `json:"vote_average"`
- GenreIDs []int `json:"genre_ids"`
-}
-
-// SearchMovie issues `/search/movie` and returns the best match, or nil
-// when no result is found. The `year` argument is optional (0 = any).
-func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int) (*Match, error) {
- matches, err := t.SearchMovieCandidates(ctx, query, year)
- if err != nil || len(matches) == 0 {
- return nil, err
- }
- return matches[0], nil
-}
-
-// SearchMovieCandidates returns the first TMDb result page as manual-scrape
-// candidates. Automatic scrape still uses SearchMovie's first-result behavior,
-// while manual correction can show alternatives when the top result is wrong.
-func (t *TMDbProvider) SearchMovieCandidates(ctx context.Context, query string, year int) ([]*Match, error) {
- if query == "" {
- return nil, errors.New("empty query")
- }
-
- // Resolve API key from config or database
- apiKey := t.resolveAPIKey(ctx)
- if apiKey == "" {
- return nil, nil
- }
- base := t.resolveBaseURL(ctx)
-
- q := url.Values{}
- q.Set("api_key", apiKey)
- q.Set("query", query)
- q.Set("language", "zh-CN")
- q.Set("include_adult", "false")
- if year > 0 {
- q.Set("year", fmt.Sprintf("%d", year))
- }
- u := base + "/search/movie?" + q.Encode()
-
- type page struct {
- Results []tmdbMovieSearchResult `json:"results"`
- }
-
- var p page
- if err := t.getJSON(ctx, u, &p); err != nil {
- return nil, err
- }
- if len(p.Results) == 0 {
- return nil, nil
- }
- out := make([]*Match, 0, len(p.Results))
- for _, r := range p.Results {
- out = append(out, t.movieSearchResultToMatch(r))
- }
- return out, nil
-}
-
-func (t *TMDbProvider) movieSearchResultToMatch(r tmdbMovieSearchResult) *Match {
- m := &Match{
- TMDbID: r.ID,
- Title: r.Title,
- OriginalName: r.OriginalTitle,
- Overview: r.Overview,
- Rating: r.VoteAverage,
- Languages: nonEmptyStrings(r.OriginalLanguage),
- Genres: genreIDStrings(r.GenreIDs),
- }
- if r.PosterPath != "" {
- m.PosterURL = t.imgCDN + "/w500" + r.PosterPath
- }
- if r.BackdropPath != "" {
- m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
- }
- if len(r.ReleaseDate) >= 4 {
- _, _ = fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year)
- }
- return m
-}
-
-// SearchTV issues `/search/tv` and returns the best match. Used by anime /
-// tv libraries before falling back to SearchMovie.
-func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*Match, error) {
- matches, err := t.SearchTVCandidates(ctx, query, year)
- if err != nil || len(matches) == 0 {
- return nil, err
- }
- return matches[0], nil
-}
-
-// SearchTVCandidates returns the first TMDb TV result page for manual scrape.
-func (t *TMDbProvider) SearchTVCandidates(ctx context.Context, query string, year int) ([]*Match, error) {
- if query == "" {
- return nil, errors.New("empty query")
- }
-
- apiKey := t.resolveAPIKey(ctx)
- if apiKey == "" {
- return nil, nil
- }
- base := t.resolveBaseURL(ctx)
-
- q := url.Values{}
- q.Set("api_key", apiKey)
- q.Set("query", query)
- q.Set("language", "zh-CN")
- q.Set("include_adult", "false")
- if year > 0 {
- q.Set("first_air_date_year", fmt.Sprintf("%d", year))
- }
- u := base + "/search/tv?" + q.Encode()
-
- type page struct {
- Results []tmdbTVSearchResult `json:"results"`
- }
-
- var p page
- if err := t.getJSON(ctx, u, &p); err != nil {
- return nil, err
- }
- if len(p.Results) == 0 {
- return nil, nil
- }
- out := make([]*Match, 0, len(p.Results))
- for _, r := range p.Results {
- out = append(out, t.tvSearchResultToMatch(r))
- }
- return out, nil
-}
-
-func (t *TMDbProvider) tvSearchResultToMatch(r tmdbTVSearchResult) *Match {
- m := &Match{
- TMDbID: r.ID,
- Title: r.Name,
- OriginalName: r.OriginalName,
- Overview: r.Overview,
- Rating: r.VoteAverage,
- Languages: nonEmptyStrings(r.OriginalLanguage),
- Countries: deduplicate(r.OriginCountry),
- Genres: genreIDStrings(r.GenreIDs),
- }
- if m.Title == "" {
- m.Title = r.OriginalName
- }
- if r.PosterPath != "" {
- m.PosterURL = t.imgCDN + "/w500" + r.PosterPath
- }
- if r.BackdropPath != "" {
- m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
- }
- if len(r.FirstAirDate) >= 4 {
- _, _ = fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year)
- }
- return m
-}
-
func (t *TMDbProvider) getJSON(ctx context.Context, url string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
@@ -374,6 +196,7 @@ func (t *TMDbProvider) GetMovieMatch(ctx context.Context, tmdbID int) (*Match, e
}
m := &Match{
TMDbID: r.ID,
+ MediaType: "movie",
Title: r.Title,
OriginalName: r.OriginalTitle,
Overview: r.Overview,
@@ -443,6 +266,7 @@ func (t *TMDbProvider) GetTVMatch(ctx context.Context, tmdbID int) (*Match, erro
}
m := &Match{
TMDbID: r.ID,
+ MediaType: "tv",
Title: r.Name,
OriginalName: r.OriginalName,
Overview: r.Overview,
diff --git a/internal/service/tmdb_search.go b/internal/service/tmdb_search.go
new file mode 100644
index 0000000..6330253
--- /dev/null
+++ b/internal/service/tmdb_search.go
@@ -0,0 +1,187 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/url"
+)
+
+type tmdbMovieSearchResult struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ OriginalTitle string `json:"original_title"`
+ OriginalLanguage string `json:"original_language"`
+ Overview string `json:"overview"`
+ PosterPath string `json:"poster_path"`
+ BackdropPath string `json:"backdrop_path"`
+ ReleaseDate string `json:"release_date"`
+ VoteAverage float32 `json:"vote_average"`
+ GenreIDs []int `json:"genre_ids"`
+}
+
+type tmdbTVSearchResult struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ OriginalName string `json:"original_name"`
+ OriginalLanguage string `json:"original_language"`
+ OriginCountry []string `json:"origin_country"`
+ Overview string `json:"overview"`
+ PosterPath string `json:"poster_path"`
+ BackdropPath string `json:"backdrop_path"`
+ FirstAirDate string `json:"first_air_date"`
+ VoteAverage float32 `json:"vote_average"`
+ GenreIDs []int `json:"genre_ids"`
+}
+
+// SearchMovie issues `/search/movie` and returns the best match, or nil
+// when no result is found. The `year` argument is optional (0 = any).
+func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int) (*Match, error) {
+ matches, err := t.SearchMovieCandidates(ctx, query, year)
+ if err != nil || len(matches) == 0 {
+ return nil, err
+ }
+ return matches[0], nil
+}
+
+// SearchMovieCandidates returns the first TMDb result page as manual-scrape
+// candidates. Automatic scrape still uses SearchMovie's first-result behavior,
+// while manual correction can show alternatives when the top result is wrong.
+func (t *TMDbProvider) SearchMovieCandidates(ctx context.Context, query string, year int) ([]*Match, error) {
+ if query == "" {
+ return nil, errors.New("empty query")
+ }
+
+ apiKey := t.resolveAPIKey(ctx)
+ if apiKey == "" {
+ return nil, nil
+ }
+ base := t.resolveBaseURL(ctx)
+
+ q := url.Values{}
+ q.Set("api_key", apiKey)
+ q.Set("query", query)
+ q.Set("language", "zh-CN")
+ q.Set("include_adult", "false")
+ if year > 0 {
+ q.Set("year", fmt.Sprintf("%d", year))
+ }
+ u := base + "/search/movie?" + q.Encode()
+
+ type page struct {
+ Results []tmdbMovieSearchResult `json:"results"`
+ }
+
+ var p page
+ if err := t.getJSON(ctx, u, &p); err != nil {
+ return nil, err
+ }
+ if len(p.Results) == 0 {
+ return nil, nil
+ }
+ out := make([]*Match, 0, len(p.Results))
+ for _, r := range p.Results {
+ out = append(out, t.movieSearchResultToMatch(r))
+ }
+ return out, nil
+}
+
+func (t *TMDbProvider) movieSearchResultToMatch(r tmdbMovieSearchResult) *Match {
+ m := &Match{
+ TMDbID: r.ID,
+ MediaType: "movie",
+ Title: r.Title,
+ OriginalName: r.OriginalTitle,
+ Overview: r.Overview,
+ Rating: r.VoteAverage,
+ Languages: nonEmptyStrings(r.OriginalLanguage),
+ Genres: genreIDStrings(r.GenreIDs),
+ }
+ if r.PosterPath != "" {
+ m.PosterURL = t.imgCDN + "/w500" + r.PosterPath
+ }
+ if r.BackdropPath != "" {
+ m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
+ }
+ if len(r.ReleaseDate) >= 4 {
+ _, _ = fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year)
+ }
+ return m
+}
+
+// SearchTV issues `/search/tv` and returns the best match. Used by anime /
+// tv libraries before falling back to SearchMovie.
+func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*Match, error) {
+ matches, err := t.SearchTVCandidates(ctx, query, year)
+ if err != nil || len(matches) == 0 {
+ return nil, err
+ }
+ return matches[0], nil
+}
+
+// SearchTVCandidates returns the first TMDb TV result page for manual scrape.
+func (t *TMDbProvider) SearchTVCandidates(ctx context.Context, query string, year int) ([]*Match, error) {
+ if query == "" {
+ return nil, errors.New("empty query")
+ }
+
+ apiKey := t.resolveAPIKey(ctx)
+ if apiKey == "" {
+ return nil, nil
+ }
+ base := t.resolveBaseURL(ctx)
+
+ q := url.Values{}
+ q.Set("api_key", apiKey)
+ q.Set("query", query)
+ q.Set("language", "zh-CN")
+ q.Set("include_adult", "false")
+ if year > 0 {
+ q.Set("first_air_date_year", fmt.Sprintf("%d", year))
+ }
+ u := base + "/search/tv?" + q.Encode()
+
+ type page struct {
+ Results []tmdbTVSearchResult `json:"results"`
+ }
+
+ var p page
+ if err := t.getJSON(ctx, u, &p); err != nil {
+ return nil, err
+ }
+ if len(p.Results) == 0 {
+ return nil, nil
+ }
+ out := make([]*Match, 0, len(p.Results))
+ for _, r := range p.Results {
+ out = append(out, t.tvSearchResultToMatch(r))
+ }
+ return out, nil
+}
+
+func (t *TMDbProvider) tvSearchResultToMatch(r tmdbTVSearchResult) *Match {
+ m := &Match{
+ TMDbID: r.ID,
+ MediaType: "tv",
+ Title: r.Name,
+ OriginalName: r.OriginalName,
+ Overview: r.Overview,
+ Rating: r.VoteAverage,
+ Languages: nonEmptyStrings(r.OriginalLanguage),
+ Countries: deduplicate(r.OriginCountry),
+ Genres: genreIDStrings(r.GenreIDs),
+ }
+ if m.Title == "" {
+ m.Title = r.OriginalName
+ }
+ if r.PosterPath != "" {
+ m.PosterURL = t.imgCDN + "/w500" + r.PosterPath
+ }
+ if r.BackdropPath != "" {
+ m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
+ }
+ if len(r.FirstAirDate) >= 4 {
+ _, _ = fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year)
+ }
+ return m
+}
diff --git a/internal/service/token_svc_pending_test.go b/internal/service/token_svc_pending_test.go
index 6fb2e77..d79653b 100644
--- a/internal/service/token_svc_pending_test.go
+++ b/internal/service/token_svc_pending_test.go
@@ -4,9 +4,7 @@ import (
"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"
@@ -15,13 +13,7 @@ import (
func newTokenTestRepo(t *testing.T) *repository.Container {
t.Helper()
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- if err != nil {
- t.Fatal(err)
- }
- if err := db.AutoMigrate(&model.User{}, &model.RefreshToken{}, &model.Setting{}); err != nil {
- t.Fatal(err)
- }
+ db := newServiceTestDB(t, &model.User{}, &model.RefreshToken{}, &model.Setting{})
return repository.New(db)
}
diff --git a/internal/service/transcoder.go b/internal/service/transcoder.go
index 13b9051..675d13b 100644
--- a/internal/service/transcoder.go
+++ b/internal/service/transcoder.go
@@ -29,7 +29,6 @@ import (
"os"
"os/exec"
"path/filepath"
- "strconv"
"strings"
"sync"
"time"
@@ -406,131 +405,6 @@ func hasFFmpegListEntry(output, name string) bool {
return false
}
-// buildFFmpegArgs assembles the ffmpeg command line for the configured
-// encoder. The function is package-level so the unit test can pin its
-// behaviour without spawning a real ffmpeg process.
-func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []string {
- enc := ""
- if cfg.Transcoder.HardwareAccel {
- enc = normalizedHardwareEncoder(cfg.Transcoder.Encoder)
- }
- bitrate := cfg.Transcoder.VideoBitrate
- if bitrate == "" {
- bitrate = "1500k"
- }
- maxrate := cfg.Transcoder.MaxRate
- if maxrate == "" {
- maxrate = "1800k"
- }
- bufsize := cfg.Transcoder.BufSize
- if bufsize == "" {
- bufsize = "3000k"
- }
- preset := cfg.Transcoder.Preset
- if preset == "" {
- preset = "veryfast"
- }
- height := cfg.Transcoder.MaxHeight
- if height <= 0 {
- height = 720
- }
- segDur := cfg.Transcoder.SegmentSeconds
- if segDur <= 0 {
- segDur = 4
- }
-
- // Hardware-accel arguments differ in three places:
- // - Optional input flags (-hwaccel + device init)
- // - Optional input pixel-format upload filter
- // - The actual -c:v encoder name + preset/quality flag
- var pre, vf, vcodec, vpreset string
- switch enc {
- case "nvenc":
- pre = "-hwaccel cuda -hwaccel_output_format cuda"
- vf = fmt.Sprintf("scale_cuda=-2:min(%d\\,ih)", height)
- vcodec = "h264_nvenc"
- vpreset = "p4"
- case "qsv":
- pre = "-hwaccel qsv -hwaccel_output_format qsv"
- vf = fmt.Sprintf("scale_qsv=-1:min(%d\\,ih)", height)
- vcodec = "h264_qsv"
- vpreset = preset
- case "vaapi":
- device := cfg.App.VAAPIDevice
- if device == "" {
- device = "/dev/dri/renderD128"
- }
- pre = fmt.Sprintf("-hwaccel vaapi -vaapi_device %s -hwaccel_output_format vaapi", device)
- vf = fmt.Sprintf("scale_vaapi=-2:min(%d\\,ih),format=nv12|vaapi,hwupload", height)
- vcodec = "h264_vaapi"
- vpreset = ""
- default:
- // software
- pre = ""
- vf = fmt.Sprintf("scale=-2:min(%d\\,ih)", height)
- vcodec = "libx264"
- vpreset = preset
- }
-
- args := []string{"-y", "-hide_banner", "-nostdin", "-fflags", "+genpts"}
- for _, p := range splitNonEmptyArgs(pre) {
- args = append(args, p)
- }
- if cfg.Transcoder.Realtime {
- args = append(args, "-re")
- }
- args = append(args, "-i", source, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", vf, "-c:v", vcodec)
- if cfg.Transcoder.Threads > 0 && vcodec == "libx264" {
- args = append(args, "-threads", strconv.Itoa(cfg.Transcoder.Threads))
- }
- if vpreset != "" {
- args = append(args, "-preset", vpreset)
- }
- args = append(args,
- "-pix_fmt", "yuv420p",
- "-b:v", bitrate,
- "-maxrate", maxrate,
- "-bufsize", bufsize,
- "-c:a", "aac",
- "-ar", "48000",
- "-b:a", "128k",
- "-ac", "2",
- "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", segDur),
- "-f", "hls",
- "-hls_time", fmt.Sprintf("%d", segDur),
- "-hls_list_size", "0",
- "-hls_segment_filename", segments,
- playlist,
- )
- return args
-}
-
-// splitNonEmptyArgs is a tiny helper that mirrors strings.Fields for the
-// pre-input flag string without dragging the strings import into the hot
-// path of every call to buildFFmpegArgs.
-func splitNonEmptyArgs(s string) []string {
- if s == "" {
- return nil
- }
- out := make([]string, 0, 4)
- field := make([]rune, 0, 16)
- flush := func() {
- if len(field) > 0 {
- out = append(out, string(field))
- field = field[:0]
- }
- }
- for _, r := range s {
- if r == ' ' || r == '\t' {
- flush()
- continue
- }
- field = append(field, r)
- }
- flush()
- return out
-}
-
// HumanFFmpegProfile is exposed for the admin UI / settings view.
func (t *TranscoderService) HumanFFmpegProfile() string {
return fmt.Sprintf("ffmpeg=%s, encoder=%s, output=%s",
diff --git a/internal/service/transcoder_ffmpeg_args.go b/internal/service/transcoder_ffmpeg_args.go
new file mode 100644
index 0000000..30bb026
--- /dev/null
+++ b/internal/service/transcoder_ffmpeg_args.go
@@ -0,0 +1,166 @@
+package service
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/ShukeBta/MediaStationGo/internal/config"
+)
+
+type ffmpegArgSettings struct {
+ encoder string
+ bitrate string
+ maxrate string
+ bufsize string
+ preset string
+ height int
+ segmentSeconds int
+ realtime bool
+ threads int
+ vaapiDevice string
+}
+
+type ffmpegVideoPlan struct {
+ preInput string
+ filter string
+ codec string
+ preset string
+}
+
+// buildFFmpegArgs assembles the ffmpeg command line for the configured
+// encoder. The function is package-level so the unit test can pin its
+// behaviour without spawning a real ffmpeg process.
+func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []string {
+ settings := ffmpegArgSettingsFromConfig(cfg)
+ video := ffmpegVideoPlanForSettings(settings)
+
+ args := baseFFmpegArgs(video.preInput, settings.realtime)
+ args = appendInputAndVideoArgs(args, source, settings, video)
+ args = appendOutputHLSArgs(args, settings, segments, playlist)
+ return args
+}
+
+func ffmpegArgSettingsFromConfig(cfg *config.Config) ffmpegArgSettings {
+ settings := ffmpegArgSettings{
+ bitrate: ffmpegDefaultString(cfg.Transcoder.VideoBitrate, "1500k"),
+ maxrate: ffmpegDefaultString(cfg.Transcoder.MaxRate, "1800k"),
+ bufsize: ffmpegDefaultString(cfg.Transcoder.BufSize, "3000k"),
+ preset: ffmpegDefaultString(cfg.Transcoder.Preset, "veryfast"),
+ height: cfg.Transcoder.MaxHeight,
+ segmentSeconds: cfg.Transcoder.SegmentSeconds,
+ realtime: cfg.Transcoder.Realtime,
+ threads: cfg.Transcoder.Threads,
+ vaapiDevice: ffmpegDefaultString(cfg.App.VAAPIDevice, "/dev/dri/renderD128"),
+ }
+ if cfg.Transcoder.HardwareAccel {
+ settings.encoder = normalizedHardwareEncoder(cfg.Transcoder.Encoder)
+ }
+ if settings.height <= 0 {
+ settings.height = 720
+ }
+ if settings.segmentSeconds <= 0 {
+ settings.segmentSeconds = 4
+ }
+ return settings
+}
+
+func ffmpegDefaultString(value, fallback string) string {
+ if value == "" {
+ return fallback
+ }
+ return value
+}
+
+func ffmpegVideoPlanForSettings(settings ffmpegArgSettings) ffmpegVideoPlan {
+ switch settings.encoder {
+ case "nvenc":
+ return ffmpegVideoPlan{
+ preInput: "-hwaccel cuda -hwaccel_output_format cuda",
+ filter: fmt.Sprintf("scale_cuda=-2:min(%d\\,ih)", settings.height),
+ codec: "h264_nvenc",
+ preset: "p4",
+ }
+ case "qsv":
+ return ffmpegVideoPlan{
+ preInput: "-hwaccel qsv -hwaccel_output_format qsv",
+ filter: fmt.Sprintf("scale_qsv=-1:min(%d\\,ih)", settings.height),
+ codec: "h264_qsv",
+ preset: settings.preset,
+ }
+ case "vaapi":
+ return ffmpegVideoPlan{
+ preInput: fmt.Sprintf("-hwaccel vaapi -vaapi_device %s -hwaccel_output_format vaapi", settings.vaapiDevice),
+ filter: fmt.Sprintf("scale_vaapi=-2:min(%d\\,ih),format=nv12|vaapi,hwupload", settings.height),
+ codec: "h264_vaapi",
+ }
+ default:
+ return ffmpegVideoPlan{
+ filter: fmt.Sprintf("scale=-2:min(%d\\,ih)", settings.height),
+ codec: "libx264",
+ preset: settings.preset,
+ }
+ }
+}
+
+func baseFFmpegArgs(preInput string, realtime bool) []string {
+ args := []string{"-y", "-hide_banner", "-nostdin", "-fflags", "+genpts"}
+ args = append(args, splitNonEmptyArgs(preInput)...)
+ if realtime {
+ args = append(args, "-re")
+ }
+ return args
+}
+
+func appendInputAndVideoArgs(args []string, source string, settings ffmpegArgSettings, video ffmpegVideoPlan) []string {
+ args = append(args, "-i", source, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec)
+ if settings.threads > 0 && video.codec == "libx264" {
+ args = append(args, "-threads", strconv.Itoa(settings.threads))
+ }
+ if video.preset != "" {
+ args = append(args, "-preset", video.preset)
+ }
+ return args
+}
+
+func appendOutputHLSArgs(args []string, settings ffmpegArgSettings, segments, playlist string) []string {
+ return append(args,
+ "-pix_fmt", "yuv420p",
+ "-b:v", settings.bitrate,
+ "-maxrate", settings.maxrate,
+ "-bufsize", settings.bufsize,
+ "-c:a", "aac",
+ "-ar", "48000",
+ "-b:a", "128k",
+ "-ac", "2",
+ "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", settings.segmentSeconds),
+ "-f", "hls",
+ "-hls_time", fmt.Sprintf("%d", settings.segmentSeconds),
+ "-hls_list_size", "0",
+ "-hls_segment_filename", segments,
+ playlist,
+ )
+}
+
+// splitNonEmptyArgs mirrors the old whitespace split for pre-input flags.
+func splitNonEmptyArgs(s string) []string {
+ if s == "" {
+ return nil
+ }
+ out := make([]string, 0, 4)
+ field := make([]rune, 0, 16)
+ flush := func() {
+ if len(field) > 0 {
+ out = append(out, string(field))
+ field = field[:0]
+ }
+ }
+ for _, r := range s {
+ if r == ' ' || r == '\t' {
+ flush()
+ continue
+ }
+ field = append(field, r)
+ }
+ flush()
+ return out
+}
diff --git a/internal/service/transfer.go b/internal/service/transfer.go
index a7d3c6c..07e76a2 100644
--- a/internal/service/transfer.go
+++ b/internal/service/transfer.go
@@ -101,3 +101,36 @@ func copyFile(src, dst string) error {
}
return f.Close()
}
+
+// moveFile tries os.Rename first (instant on same fs), then falls back
+// to copy + remove for cross-device moves.
+//
+// If dst already exists, moveFile returns an error instead of overwriting it.
+// OrganizeMedia checks this before calling transferFile; this remains the
+// second line of defense against different releases collapsing to one name.
+func moveFile(src, dst string) error {
+ if _, err := os.Stat(dst); err == nil {
+ return fmt.Errorf("destination already exists: %s", dst)
+ }
+ if err := os.Rename(src, dst); err == nil {
+ return nil
+ }
+ in, err := os.Open(src) // #nosec G304 -- src is selected from configured media/download roots by the organizer.
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+ f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) // #nosec G304,G302 -- dst is organizer-generated; media files must remain readable by local players.
+ if err != nil {
+ return err
+ }
+ if _, werr := io.Copy(f, in); werr != nil {
+ _ = f.Close()
+ _ = os.Remove(dst)
+ return werr
+ }
+ if cerr := f.Close(); cerr != nil {
+ return cerr
+ }
+ return os.Remove(src)
+}
diff --git a/internal/service/watcher_test.go b/internal/service/watcher_test.go
index c36d8b7..46000e1 100644
--- a/internal/service/watcher_test.go
+++ b/internal/service/watcher_test.go
@@ -6,9 +6,7 @@ import (
"testing"
"github.com/fsnotify/fsnotify"
- "github.com/glebarez/sqlite"
"go.uber.org/zap"
- "gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
@@ -25,13 +23,7 @@ func TestWatcherRefreshMapsHostLibraryPathToContainerPath(t *testing.T) {
t.Setenv("MEDIASTATION_MEDIA_DIR", hostMedia)
t.Setenv("MEDIASTATION_MEDIA_CONTAINER_DIR", containerMedia)
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- if err != nil {
- t.Fatalf("open db: %v", err)
- }
- if err := db.AutoMigrate(&model.Library{}); err != nil {
- t.Fatalf("migrate: %v", err)
- }
+ db := newServiceTestDB(t, &model.Library{})
repos := repository.New(db)
lib := model.Library{
Base: model.Base{ID: "lib-tv"},
diff --git a/web/eslint.config.js b/web/eslint.config.js
index 267f86c..fde0fd4 100644
--- a/web/eslint.config.js
+++ b/web/eslint.config.js
@@ -7,6 +7,16 @@ import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist', 'node_modules'] },
js.configs.recommended,
+ {
+ files: ['public/**/*.js'],
+ languageOptions: {
+ ecmaVersion: 2022,
+ globals: {
+ ...globals.browser,
+ ...globals.serviceworker,
+ },
+ },
+ },
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
diff --git a/web/public/artwork-cache-sw.js b/web/public/artwork-cache-sw.js
index 1faa511..591691e 100644
--- a/web/public/artwork-cache-sw.js
+++ b/web/public/artwork-cache-sw.js
@@ -1,4 +1,6 @@
-const ARTWORK_CACHE = 'mediastationgo-artwork-v1'
+const ARTWORK_CACHE_PREFIX = 'mediastationgo-artwork-'
+const ARTWORK_CACHE = `${ARTWORK_CACHE_PREFIX}v2`
+const MIN_CACHEABLE_ARTWORK_BYTES = 128
const STRIP_QUERY_KEYS = ['token', 'profile_id', 'profile_pin_token']
function isArtworkRequest(url) {
@@ -33,6 +35,14 @@ self.addEventListener('fetch', (event) => {
event.respondWith(cacheArtwork(request))
})
+self.addEventListener('install', (event) => {
+ event.waitUntil(self.skipWaiting())
+})
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(deleteOldArtworkCaches().then(() => self.clients.claim()))
+})
+
async function cacheArtwork(request) {
const cache = await caches.open(ARTWORK_CACHE)
const cacheKey = normalizedArtworkRequest(request)
@@ -40,9 +50,63 @@ async function cacheArtwork(request) {
if (cached) return cached
const response = await fetch(request)
- const contentType = response.headers.get('Content-Type') || ''
- if (response.ok && contentType.toLowerCase().startsWith('image/')) {
- await cache.put(cacheKey, response.clone())
+ const cacheResponse = await cloneCacheableArtworkResponse(response)
+ if (cacheResponse) {
+ await cache.put(cacheKey, cacheResponse)
+ await deleteOldArtworkVariants(cache, cacheKey)
}
return response
}
+
+async function cloneCacheableArtworkResponse(response) {
+ if (!response.ok) return null
+ const contentType = response.headers.get('Content-Type') || ''
+ if (!contentType.toLowerCase().startsWith('image/')) return null
+ const cacheControl = response.headers.get('Cache-Control') || ''
+ if (/\bno-store\b/i.test(cacheControl)) return null
+
+ const contentLength = Number(response.headers.get('Content-Length') || '0')
+ if (Number.isFinite(contentLength) && contentLength > 0 && contentLength <= MIN_CACHEABLE_ARTWORK_BYTES) {
+ return null
+ }
+
+ const buffer = await response.clone().arrayBuffer()
+ if (buffer.byteLength <= MIN_CACHEABLE_ARTWORK_BYTES) return null
+ return new Response(buffer, {
+ status: response.status,
+ statusText: response.statusText,
+ headers: new Headers(response.headers),
+ })
+}
+
+async function deleteOldArtworkCaches() {
+ const names = await caches.keys()
+ await Promise.all(names.map((name) => {
+ if (!name.startsWith(ARTWORK_CACHE_PREFIX) || name === ARTWORK_CACHE) return undefined
+ return caches.delete(name)
+ }))
+}
+
+async function deleteOldArtworkVariants(cache, currentRequest) {
+ const currentURL = new URL(currentRequest.url)
+ const currentIdentity = artworkIdentity(currentURL)
+ if (!currentIdentity) return
+
+ const keys = await cache.keys()
+ await Promise.all(keys.map(async (key) => {
+ if (key.url === currentRequest.url) return
+ const keyURL = new URL(key.url)
+ if (artworkIdentity(keyURL) !== currentIdentity) return
+ await cache.delete(key)
+ }))
+}
+
+function artworkIdentity(url) {
+ if (url.pathname === '/api/img') {
+ return `${url.origin}${url.pathname}?url=${url.searchParams.get('url') || ''}`
+ }
+ if (url.pathname.startsWith('/api/cloud/play/')) {
+ return `${url.origin}${url.pathname}?ref=${url.searchParams.get('ref') || ''}`
+ }
+ return ''
+}
diff --git a/web/src/App.tsx b/web/src/App.tsx
index fad9dbd..707e3c1 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -1,108 +1,11 @@
-import { Component, Suspense, lazy, type ErrorInfo, type ReactNode } from 'react'
+import { Component, Suspense, type ErrorInfo, type ReactNode } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom'
+import { appRoutes, type AppRoute } from './appRoutes'
import { Layout } from './components/Layout'
import { RequireAdmin, RequireAuth } from './components/RequireAuth'
import { LoginPage } from './pages/LoginPage'
-// Lazy-loaded routes — the login screen and the layout shell ship in the
-// initial bundle; everything else is fetched on first navigation.
-const HomePage = lazy(() => import('./pages/HomePage').then((m) => ({ default: m.HomePage })))
-const LibraryPage = lazy(() =>
- import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage })),
-)
-const LibrariesPage = lazy(() =>
- import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage })),
-)
-const SearchPage = lazy(() =>
- import('./pages/SearchPage').then((m) => ({ default: m.SearchPage })),
-)
-const FavouritesPage = lazy(() =>
- import('./pages/FavouritesPage').then((m) => ({ default: m.FavouritesPage })),
-)
-const PlaylistsPage = lazy(() =>
- import('./pages/PlaylistsPage').then((m) => ({ default: m.PlaylistsPage })),
-)
-const PlaylistDetailPage = lazy(() =>
- import('./pages/PlaylistDetailPage').then((m) => ({ default: m.PlaylistDetailPage })),
-)
-const MediaDetailPage = lazy(() =>
- import('./pages/MediaDetailPage').then((m) => ({ default: m.MediaDetailPage })),
-)
-const PlayerPage = lazy(() =>
- import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage })),
-)
-const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })))
-const DownloadsPage = lazy(() =>
- import('./pages/DownloadsPage').then((m) => ({ default: m.DownloadsPage })),
-)
-const SubscriptionsPage = lazy(() =>
- import('./pages/SubscriptionsPage').then((m) => ({ default: m.SubscriptionsPage })),
-)
-const ProfilePage = lazy(() =>
- import('./pages/ProfilePage').then((m) => ({ default: m.ProfilePage })),
-)
-const StatsPage = lazy(() => import('./pages/StatsPage').then((m) => ({ default: m.StatsPage })))
-const DiscoverPage = lazy(() =>
- import('./pages/DiscoverPage').then((m) => ({ default: m.DiscoverPage })),
-)
-const TasksPage = lazy(() => import('./pages/TasksPage').then((m) => ({ default: m.TasksPage })))
-const RecycleBinPage = lazy(() =>
- import('./pages/RecycleBinPage').then((m) => ({ default: m.RecycleBinPage })),
-)
-const DlnaPage = lazy(() => import('./pages/DlnaPage').then((m) => ({ default: m.DlnaPage })))
-const FileManagerPage = lazy(() =>
- import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })),
-)
-const StoragePage = lazy(() =>
- import('./pages/StoragePage').then((m) => ({ default: m.StoragePage })),
-)
-const DuplicatesPage = lazy(() =>
- import('./pages/DuplicatesPage').then((m) => ({ default: m.DuplicatesPage })),
-)
-const SchedulerPage = lazy(() =>
- import('./pages/SchedulerPage').then((m) => ({ default: m.SchedulerPage })),
-)
-const WatchHistoryPage = lazy(() =>
- import('./pages/WatchHistoryPage').then((m) => ({ default: m.WatchHistoryPage })),
-)
-const PosterWallPage = lazy(() =>
- import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })),
-)
-const SitesPage = lazy(() =>
- import('./pages/SitesPage').then((m) => ({ default: m.SitesPage })),
-)
-const SiteSearchPage = lazy(() =>
- import('./pages/SiteSearchPage').then((m) => ({ default: m.SiteSearchPage })),
-)
-const AIAssistantPage = lazy(() =>
- import('./pages/AIAssistantPage').then((m) => ({ default: m.AIAssistantPage })),
-)
-const StrmPage = lazy(() =>
- import('./pages/StrmPage').then((m) => ({ default: m.StrmPage })),
-)
-const ProfileManagementPage = lazy(() =>
- import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })),
-)
-const NotifyChannelsPage = lazy(() =>
- import('./pages/NotifyChannelsPage').then((m) => ({ default: m.NotifyChannelsPage })),
-)
-const SettingsPage = lazy(() =>
- import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })),
-)
-const AssistantChatPage = lazy(() =>
- import('./pages/AssistantChatPage').then((m) => ({ default: m.AssistantChatPage })),
-)
-const DownloadClientsPage = lazy(() =>
- import('./pages/DownloadClientsPage').then((m) => ({ default: m.DownloadClientsPage })),
-)
-const StorageConfigPage = lazy(() =>
- import('./pages/StorageConfigPage').then((m) => ({ default: m.StorageConfigPage })),
-)
-const LicensePage = lazy(() =>
- import('./pages/LicensePage').then((m) => ({ default: m.LicensePage })),
-)
-
const Loading = () => 加载中…
class AppErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> { @@ -140,177 +43,35 @@ class AppErrorBoundary extends Component<{ children: ReactNode }, { hasError: bo } } +function routeElement(route: AppRoute) { + if (!route.adminOnly) return route.element + return- 当前观影 Profile -
-+ 当前观影 Profile +
+{description}
} +{description}
}- {item.description} -
-{item.description}
+- {scopeLabel || media.title} · {targetIds.length > 1 ? `将应用到 ${targetIds.length} 个媒体` : '单个媒体'} -
-{item.overview || '暂无简介'}
-{candidateIDText(item)}
-+ {title} · {targetCount > 1 ? `将应用到 ${targetCount} 个媒体` : '单个媒体'} +
+{item.overview || '暂无简介'}
+{candidateIDText(item)}
++
{media.overview || "暂无简介内容"}
@@ -104,7 +106,7 @@ export const MediaCard = ({ {/* Progress Bar overlay */} {progress !== undefined && progress > 0 && progress < 1 && ( -+
{media.title}
-+ {item.overview || `订阅关键词:${keyword}`} +
+ +自然语言搜索 · 基于观影历史的智能推荐
+- 自然语言搜索 · 基于观影历史的智能推荐 -
-- {item.overview || `订阅关键词:${keyword}`} -
- -- 推荐基于你的最近观看历史。点击标题在媒体库中查找。 -
- - {recs && recs.length > 0 && ( -- 还没有推荐结果 — 先去看几部片子,我再给你挑。 -
- )} -
提示: 当前未配置外部 AI Provider,系统将使用本地规则引擎解析查询。
管理员可在 API 配置{' '}
diff --git a/web/src/pages/AIAssistantRecommendationsSection.tsx b/web/src/pages/AIAssistantRecommendationsSection.tsx
new file mode 100644
index 0000000..39536bd
--- /dev/null
+++ b/web/src/pages/AIAssistantRecommendationsSection.tsx
@@ -0,0 +1,47 @@
+import { Link } from 'react-router-dom'
+import { Loader2, Search, Wand2 } from 'lucide-react'
+
+type AIAssistantRecommendationsSectionProps = {
+ recs: string[] | null
+ recommending: boolean
+ onRecommend: () => void
+}
+
+export function AIAssistantRecommendationsSection({
+ recs,
+ recommending,
+ onRecommend,
+}: AIAssistantRecommendationsSectionProps) {
+ return (
+ 推荐基于你的最近观看历史。点击标题在媒体库中查找。 还没有推荐结果 — 先去看几部片子,我再给你挑。为你推荐
+
+
+ {recs.map((title, index) => (
+
+ )}
+
+ {recs && recs.length === 0 && (
+ 智能搜索
+
+
+
| 名称 | +路径 | +类型 | +操作 | +
|---|---|---|---|
| {l.name} | +{l.path} | +{l.type} | ++ + + | +
| 名称 | -路径 | -类型 | -操作 | -
|---|---|---|---|
| {l.name} | -{l.path} | -{l.type} | -- - - | -
| 用户名 | -角色 | -状态 | -权限说明 | -最近登录 | -操作 | -
|---|---|---|---|---|---|
|
- {editingID === u.id ? (
- setEditingUsername(e.target.value)}
- />
- ) : (
-
- {u.username}
- {u.is_default_admin && |
- {u.role === 'admin' ? '管理员' : '观看用户'} | -- {u.is_active ? '正常' : '已禁用'} - | -- {u.role === 'admin' ? '全部管理权限' : '仅浏览/播放/外部播放器,无下载与文件操作'} - | -- {u.last_login_at ? new Date(u.last_login_at).toLocaleString() : '从未登录'} - | -- {editingID === u.id ? ( - <> - - - > - ) : ( - - )} - - - - | -
| 用户名 | +角色 | +状态 | +权限说明 | +最近登录 | +操作 | +
|---|---|---|---|---|---|
|
+ {editingID === u.id ? (
+ onEditingUsernameChange(e.target.value)}
+ />
+ ) : (
+
+ {u.username}
+ {u.is_default_admin && |
+ {u.role === 'admin' ? '管理员' : '观看用户'} | ++ {u.is_active ? '正常' : '已禁用'} + | ++ {u.role === 'admin' ? '全部管理权限' : '仅浏览/播放/外部播放器,无下载与文件操作'} + | ++ + {u.last_login_at ? new Date(u.last_login_at).toLocaleString() : '从未登录'} + {u.realtime_online && 在线} + {(u.realtime_device_count ?? 0) > 0 && {u.realtime_device_count} 台} + + | ++ {editingID === u.id ? ( + <> + + + > + ) : ( + + )} + + + + | +
选择或创建一个会话开始对话
-说点什么开始对话…
- )} - {active.messages.map((m) => ( -选择或创建一个会话开始对话
+说点什么开始对话…
+ )} + {active.messages.map((message) => ( ++ 设置后可自动递归扫描下载/待整理目录,整理到媒体库目录;也可以在这里立即执行一次。 +
++ 可用占位符:{'{title}'} {'{year}'} {'{season}'} {'{season:02}'} {'{episode}'} {'{episode:02}'} {'{category}'}。扩展名会自动补齐。 +
++ 挂载后不会复制网盘文件;后台会递归读取该目录里的子文件夹和媒体文件,扫描到的影片会自动加入对应媒体库。小目录通常几十秒,大目录取决于网盘接口速度。 + 如果已有同名同类型媒体库,会在首页和 Emby/SenPlayer 中自动归并显示。 +
+{error}
+ if (items.length === 0) return该目录为空
+ + return ( ++ 只需在系统设置填写公开域名,扫描会自动为网盘媒体生成 STRM/302 播放入口;中断后再次扫描会去重补齐。 +
+{source}
+{discoverItemMetaText(item)}
+{overview || '当前数据源没有返回简介。'}
+- 多源推荐:TMDb / 豆瓣 / Bangumi,可按需组合显示 -
-{error}
-- 当前选择的推荐源暂未返回内容,可切换豆瓣 / Bangumi 或检查网络代理。 -
-{source}
-- {[item.media_type, item.year && item.year > 0 ? item.year : '', item.rating ? `★ ${item.rating.toFixed(1)}` : ''] - .filter(Boolean) - .join(' · ')} -
-{item.overview || '当前数据源没有返回简介。'}
-+ 多源推荐:TMDb / 豆瓣 / Bangumi,可按需组合显示 +
+{sectionLabel(key)}:{message}
+ ))} ++ 当前选择的推荐源暂未返回内容,可切换豆瓣 / Bangumi 或检查网络代理。 +
++ {item.overview || item.save_path || '已隐藏原始种子 URL,避免泄露私有 Token。'} +
+- {item.overview || item.save_path || '已隐藏原始种子 URL,避免泄露私有 Token。'} -
-| + 0 && entries.every((entry) => selectedPaths.includes(entry.path))} + onChange={(event) => onSelectAll(event.target.checked)} + /> + | +名称 | +大小 | +修改时间 | +选择 | +
|---|---|---|---|---|
| + onToggleSelectedPath(entry, event.target.checked)} + /> + | ++ + | +{entry.is_dir ? '—' : fmtBytes(entry.size)} | +{new Date(entry.modified * 1000).toLocaleString()} | ++ + | +
- 设置后可自动递归扫描下载/待整理目录,整理到媒体库目录;也可以在这里立即执行一次。 -
-- 可用占位符:{'{title}'} {'{year}'} {'{season}'} {'{season:02}'} {'{episode}'} {'{episode:02}'} {'{category}'}。扩展名会自动补齐。 -
-来源优先使用选中项;未选中时使用当前目录。
-| 动作 | -来源 | -目标 | -原因 | -
|---|---|---|---|
| {item.action} | -{item.source} | -{item.target || '—'} | -{item.reason || '—'} | -
{selected.path}
-先在下方列表点击“操作”选择文件或目录。
- )} -加载中…
} {error &&| - 0 && data.entries.every((entry) => selectedPaths.includes(entry.path))} - onChange={(event) => { - const entries = data.entries ?? [] - if (event.target.checked) { - setSelectedPaths(entries.map((entry) => entry.path)) - } else { - setSelectedPaths([]) - } - }} - /> - | -名称 | -大小 | -修改时间 | -选择 | -
|---|---|---|---|---|
| - toggleSelectedPath(entry, event.target.checked)} - /> - | -- - | -{entry.is_dir ? '—' : fmtBytes(entry.size)} | -{new Date(entry.modified * 1000).toLocaleString()} | -- - | -
空目录。
} diff --git a/web/src/pages/FileManagerToolbar.tsx b/web/src/pages/FileManagerToolbar.tsx new file mode 100644 index 0000000..1d287c8 --- /dev/null +++ b/web/src/pages/FileManagerToolbar.tsx @@ -0,0 +1,46 @@ +import { ChevronUp, Home, RefreshCw } from 'lucide-react' + +type FileManagerToolbarProps = { + currentPath?: string + parentPath?: string + recursive: boolean + onRoot: () => void + onParent: (path: string) => void + onRefresh: () => void + onRecursiveChange: (value: boolean) => void +} + +export function FileManagerToolbar({ + currentPath, + parentPath, + recursive, + onRoot, + onParent, + onRefresh, + onRecursiveChange, +}: FileManagerToolbarProps) { + return ( +{selected.path}
+先在下方列表点击“操作”选择文件或目录。
+ )} +您的家庭影视站暂无内容
-- 前往管理后台添加媒体目录,扫描后首页将展示本周力荐、继续观看和最近入库。 -
- - 前往管理后台 - -- {featuredItem.overview || '家庭私人媒体中心收藏。支持多端播放、外部播放器、智能刮削与订阅下载。'} -
- -按整部电影、剧集、番剧和综艺合集展示新增内容。
-- {media.title} -
-- 已观看到 {Math.round(progress * 100)}% -
-您的家庭影视站暂无内容
++ 前往管理后台添加媒体目录,扫描后首页将展示本周力荐、继续观看和最近入库。 +
+ + 前往管理后台 + ++ {featuredItem.overview || '家庭私人媒体中心收藏。支持多端播放、外部播放器、智能刮削与订阅下载。'} +
+ +按整部电影、剧集、番剧和综艺合集展示新增内容。
++ {media.title} +
++ 已观看到 {Math.round(progress * 100)}% +
+- 共 {previews.length} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。 -
-暂无媒体库,请到管理后台添加目录。
-按目录进入完整媒体库;下方每个目录也会直接展示最新内容。
-{library.path}
-- {library.path} · {preview.total.toLocaleString()} 个条目 · 最新 {cards.length} 部 -
-+ 共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。 +
+暂无媒体库,请到管理后台添加目录。
+按目录进入完整媒体库;下方每个目录也会直接展示最新内容。
+{library.path}
++ {library.path} · {preview.total.toLocaleString()} 个条目 · 最新 {cards.length} 部 +
+{message}
+{library.type} · {library.path}
} - {loadingAll && !loading && total > items.length && ( -正在继续加载全部条目:{items.length} / {total}
- )} - {scanProgress &&{scanProgress}
} -该媒体库暂无内容,触发一次扫描后再来看看
-- {selectedSeries.rep.overview || '暂无简介'} -
- - {/* 从第一集开始 */} - {(() => { - const firstEps = [...(visibleEpisodes.length > 0 ? visibleEpisodes : selectedEpisodes.flatMap((s) => s.episodes))] - firstEps.sort((a, b) => - (a.season_num || 0) - (b.season_num || 0) - || (a.episode_num || 0) - (b.episode_num || 0), - ) - const first = firstEps.length > 0 ? firstEps[0] : null - return first ? ( -系统后台高级控制面板
-- {ep.original_name || (ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : ep.title)} -
-- {ep.duration_sec > 0 - ? `${Math.floor(ep.duration_sec / 60)} 分钟` - : formatSize(ep.size_bytes)} -
-该库尚未发现任何剧集,触发一次扫描后再来看看
-{library.type} · {library.path}
} + {loadingAllText &&{loadingAllText}
} + {scanProgress &&{scanProgress}
} ++ {series.rep.overview || '暂无简介'} +
+ + {firstEpisode && ( +系统后台高级控制面板
++ {episodeDisplayTitle(ep, visibleEpisodes)} +
++ {ep.duration_sec > 0 + ? `${Math.floor(ep.duration_sec / 60)} 分钟` + : formatSize(ep.size_bytes)} +
+绑定授权密钥以解锁全部功能
-- 输入从授权服务器获取的许可证密钥,激活后即可使用所有高级功能。 -
-- 尚未绑定许可证。请在上方输入密钥完成激活。 -
-- 需要获取许可证?请联系管理员获取 MediaStationGo 授权密钥。 -
-- 授权服务器地址可在系统设置中配置 -
-{label}
-- {value} -
+绑定授权密钥以解锁全部功能
++ 输入从授权服务器获取的许可证密钥,激活后即可使用所有高级功能。 +
++ 尚未绑定许可证。请在上方输入密钥完成激活。 +
++ 需要获取许可证?请联系管理员获取 MediaStationGo 授权密钥。 +
++ 授权服务器地址可在系统设置中配置 +
+{label}
++ {value} +
+来源优先使用选中项;未选中时使用当前目录。
+| 动作 | +来源 | +目标 | +原因 | +
|---|---|---|---|
| {item.action} | +{item.source} | +{item.target || '—'} | +{item.reason || '—'} | +
系统后台高级控制面板
+ {isEpisodeArtworkTarget(media) && ( ++ {media.title} +
+ )} ++ {media.overview} +
+媒体资源已被移除或不存在
-- {media.overview} -
-系统后台高级控制面板
-媒体资源已被移除或不存在
+/start 用户名 密码 绑定账号、切换隐藏成人媒体库和目录。不配置群组/频道时,普通用户不会被放行。若测试通知超时,可填写反代 API 地址或代理地址。
+ + 来自 TMDb / 豆瓣 / Bangumi。电影入队最佳资源;剧集/动漫优先整季或全集包,否则按集批量入队。 +
++ {item.overview || `订阅关键词:${keyword}`} +
+{item.overview || '暂无简介。'}
+总集数未知,订阅时会跳过本地已有单集,优先补新集。
+ )} +输入关键词开始搜索
-- 支持电影、电视剧、动漫等媒体内容的快速搜索 -
-未找到匹配的媒体
-尝试其他关键词,或者添加媒体库后执行扫描
-- 来自 TMDb / 豆瓣 / Bangumi。电影入队最佳资源;剧集/动漫优先整季或全集包,否则按集批量入队。 -
-- {item.overview || `订阅关键词:${keyword}`} -
-{item.overview || '暂无简介。'}
-总集数未知,订阅时会跳过本地已有单集,优先补新集。
- )} -输入关键词开始搜索
+支持电影、电视剧、动漫等媒体内容的快速搜索
+未找到匹配的媒体
+尝试其他关键词,或者添加媒体库后执行扫描
++ {form.type === "mteam" + ? "馒头:控制台 → 实验室 → 存取令牌;第三方工具通过 x-api-key 请求头访问" + : form.type === "yemapt" + ? "YemaPT:个人详情页 → 第三方对接专用 auth;通过 Authorization 请求头原样访问" + : "站点的访问 API Key"} +
++ 站点 RSS 订阅地址,用于获取最新资源 +
++ 格式: https://www.example.com/ +
+暂无站点
-- 点击「添加站点」添加 PT/BT 站点 -
-加载中...
-暂无站点
++ 点击「添加站点」添加 PT/BT 站点 +
+加载中...
+- 聚合快照:{new Date(snap.generated_at).toLocaleString()} · 实时监控每 2 秒刷新 -
-- {monitorError ? '监控重试中' : '实时监控中'} -
-- 最近刷新:{lastMonitorAt ? new Date(lastMonitorAt).toLocaleTimeString() : '—'} -
-实时监控错误:{monitorError}
} -{label}
-{value}
- {typeof meter === 'number' &&+ 聚合快照:{new Date(generatedAt).toLocaleString()} · 实时监控每 2 秒刷新 +
++ {monitorError ? '监控重试中' : '实时监控中'} +
++ 最近刷新:{lastMonitorAt ? new Date(lastMonitorAt).toLocaleTimeString() : '—'} +
+实时监控错误:{monitorError}
} +{label}
+{value}
+ {typeof meter === 'number' &&- 配置 OpenList / Alist / S3 / WebDAV / CloudDrive2 / 网盘(115 / 夸克)后端,支持本地转存、网盘挂载和 302/反代播放 +
+ 配置 OpenList / Alist / WebDAV / CloudDrive2 / 115 后端,支持本地转存、网盘挂载和 302/反代播放
- {transferMode === 'move' - ? '移动模式会先上传到外部存储,上传成功后才删除本地源文件;远端已存在且未覆盖时不会删除本地。' - : '复制本地媒体文件到外部存储,保留本地源文件;自动跳过远端已存在文件。'} -
-- 115 / 夸克原生上传需要私有分片上传协议。推荐把 115、123、夸克等挂载到 OpenList、CloudDrive2 或 Alist 后,在这里选择 OpenList / CloudDrive2 / Alist 转存。 -
- )} - {type === 'openlist' && ( -- OpenList 优先使用服务地址 + 用户名密码/Token 调用 API 进行浏览、挂载、转存和获取播放直链;WebDAV URL 只是兼容备用。默认端口常见为 5244,未配置 HTTPS 反代时请填写 http://。 -
- )} - {type === 'clouddrive2' && ( -- CloudDrive2 已经对接 115、123、阿里、夸克等网盘;这里通过它的 WebDAV 入口浏览、挂载和上传,播放默认走服务端反代以携带认证头。 -
- )} -- 只需在系统设置填写公开域名,扫描会自动为网盘媒体生成 STRM/302 播放入口;中断后再次扫描会去重补齐。 -
-- 挂载后不会复制网盘文件;后台会递归读取该目录里的子文件夹和媒体文件,扫描到的影片会自动加入对应媒体库。小目录通常几十秒,大目录取决于网盘接口速度。 - 如果已有同名同类型媒体库,会在首页和 Emby/SenPlayer 中自动归并显示。 -
-{error}
- ) : items.length === 0 ? ( -该目录为空
- ) : ( -+ 挂载/扫描只需要读取;只有手动开启这里,才允许把本地文件转存写入到该外部存储。可随时关闭。 +
++ {transferMode === 'move' + ? '移动模式会先上传到外部存储,上传成功后才删除本地源文件;远端已存在且未覆盖时不会删除本地。' + : '复制本地媒体文件到外部存储,保留本地源文件;自动跳过远端已存在文件。'} +
++ 115 原生上传需要私有分片上传协议。推荐把 115、123、阿里等网盘挂载到 OpenList、CloudDrive2 或 Alist 后,在这里选择 OpenList / CloudDrive2 / Alist 转存。 +
+ )} + {type === 'openlist' && ( ++ OpenList 优先使用服务地址 + 用户名密码/Token 调用 API 进行浏览、挂载、转存和获取播放直链;WebDAV URL 只是兼容备用。默认端口常见为 5244,未配置 HTTPS 反代时请填写 http://。 +
+ )} + {type === 'clouddrive2' && ( ++ CloudDrive2 已经对接 115、123、阿里等网盘;这里通过它的 WebDAV 入口浏览、挂载和上传,播放默认走服务端反代以携带认证头。 +
+ )} +未找到匹配的媒体。
+ )} ++ 只需要填写自己的访问域名,系统会按媒体库内每个媒体批量生成可播放的 .strm 文件。 +
+
+ 生成内容为 域名 + /api/stream/媒体ID?token=...;第三方客户端播放优先方式由上方「STRMURL / 302反代」模式决定。域名会同步保存到系统设置中的「公开访问域名 / STRM 域名」。
+
+ 导入后会创建一条 container=strm 的媒体记录,播放时会 302 重定向到该 URL。 +
+- 将外部 HTTP / WebDAV / Alist 直链以"虚拟文件"形式纳入媒体库 -
-- 只需要填写自己的访问域名,系统会按媒体库内每个媒体批量生成可播放的 .strm 文件。 -
-
- 生成内容为 域名 + /api/stream/媒体ID?token=...;第三方客户端播放优先方式由上方「STRMURL / 302反代」模式决定。域名会同步保存到系统设置中的「公开访问域名 / STRM 域名」。
-
- 导入后会创建一条 container=strm 的媒体记录,播放时会 302 重定向到该 URL。 -
-未找到匹配的媒体。
- )} -+ 将外部 HTTP / WebDAV / Alist 直链以"虚拟文件"形式纳入媒体库 +
++ {subscription.overview || subscription.filter || '已隐藏订阅源地址,避免多用户场景泄露私有 RSS Token。'} +
+{subscription.archive_reason || '订阅已完成'}
++ {subscription.archived_at ? new Date(subscription.archived_at).toLocaleString() : '完成时间未知'} +
+{subscriptionProgressLabel(subscription)}
+加载中…
} {!loading && items.length === 0 &&暂无订阅。
} @@ -229,202 +146,18 @@ export function SubscriptionsPage() { {items.length > 0 && (- {subscription.overview || subscription.filter || '已隐藏订阅源地址,避免多用户场景泄露私有 RSS Token。'} -
-- {subscription.archive_reason || '订阅已完成'} -
-- {subscription.archived_at ? new Date(subscription.archived_at).toLocaleString() : '完成时间未知'} -
-{subscriptionProgressLabel(subscription)}
-暂无运行中转码。
+ return ( +| 媒体 ID | +编码器 | +开始时间 | +就绪 | +
|---|---|---|---|
| {t.media_id} | +{t.encoder || 'libx264'} | +{new Date(t.started_at).toLocaleTimeString()} | ++ {t.playlist_ok ? ( + + ready + + ) : ( + + starting + + )} + | +
暂无运行中下载。
+ return ( +| 名称 | +状态 | +进度 | +体积 | +
|---|---|---|---|
| + {t.name} + | +{t.state} | +
+
+
+
+
+
+ {(t.progress * 100).toFixed(1)}%
+ |
+ {fmtBytes(t.size)} | +
暂无运行中转码。
} - {snap.transcodes.length > 0 && ( -| 媒体 ID | -编码器 | -开始时间 | -就绪 | -
|---|---|---|---|
| {t.media_id} | -{t.encoder || 'libx264'} | -{new Date(t.started_at).toLocaleTimeString()} | -- {t.playlist_ok ? ( - - ready - - ) : ( - - starting - - )} - | -
暂无运行中下载。
} - {torrents.length > 0 && ( -| 名称 | -状态 | -进度 | -体积 | -
|---|---|---|---|
| - {t.name} - | -{t.state} | -
-
-
-
-
-
- {(t.progress * 100).toFixed(1)}%
- |
- {fmtBytes(t.size)} | -