diff --git a/README.md b/README.md index ae588a3..7bc7e66 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# MediaStationGo +# MMTL (My Movie and TV Library)

- MediaStationGo Logo + MMTL Logo

适合 NAS、家庭共享和多端播放的私人媒体中心

diff --git a/README_EN.md b/README_EN.md index 36bea25..59f2c2f 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,7 +1,7 @@ -# MediaStationGo +# MMTL (My Movie and TV Library)

- MediaStationGo Logo + MMTL Logo

A lightweight, polished, NAS-friendly private media center

diff --git a/cmd/server/main.go b/cmd/server/main.go index 5eafad8..4725a56 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -26,7 +26,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/database" - "github.com/ShukeBta/MediaStationGo/internal/handler" "github.com/ShukeBta/MediaStationGo/internal/repository" "github.com/ShukeBta/MediaStationGo/internal/service" ) @@ -63,7 +62,7 @@ func main() { defer func() { _ = logger.Sync() }() appVersion := effectiveVersion(version) - logger.Info("starting MediaStationGo", + logger.Info("starting MMTL", zap.String("version", appVersion), zap.Int("port", cfg.App.Port), zap.String("data_dir", cfg.App.DataDir), @@ -95,12 +94,6 @@ func main() { applyCPUThreadLimit(cfg, logger) services := service.NewWithVersion(cfg, logger, repos, appVersion) - if repaired, err := services.RepairCloudPathMetadata(context.Background()); err != nil { - logger.Warn("cloud path metadata repair failed", zap.Error(err)) - } else if repaired > 0 { - logger.Info("cloud path metadata repair completed", zap.Int("media_count", repaired)) - } - // 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致 // 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。 if cleaned, err := services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil { @@ -143,8 +136,6 @@ func main() { } }() go services.Boot() - go handler.RunLicenseHeartbeatLoop(services.Context(), services) - go services.TelegramBot.StartPolling(context.Background()) // Graceful shutdown. stop := make(chan os.Signal, 1) @@ -158,5 +149,5 @@ func main() { logger.Error("graceful shutdown failed", zap.Error(err)) } services.Close() - logger.Info("MediaStationGo stopped") + logger.Info("MMTL stopped") } diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 4c3d6af..892fc22 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -4,7 +4,6 @@ import ( "path/filepath" "strings" "testing" - "time" "github.com/glebarez/sqlite" "gorm.io/gorm" @@ -54,106 +53,6 @@ func TestOpenSQLiteWithNilLoggerConfiguresPool(t *testing.T) { } } -func TestEnforceTelegramBindingOneToOneCleansDuplicatesAndAddsIndex(t *testing.T) { - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.TelegramBinding{}); err != nil { - t.Fatal(err) - } - createdAt := time.Now().Add(-time.Hour) - rows := []model.TelegramBinding{ - {TelegramUserID: 10001, ChatID: 10001, UserID: "user-1"}, - {TelegramUserID: 10002, ChatID: 10002, UserID: "user-1"}, - } - for i := range rows { - rows[i].CreatedAt = createdAt.Add(time.Duration(i) * time.Minute) - if err := db.Create(&rows[i]).Error; err != nil { - t.Fatal(err) - } - } - - if err := enforceTelegramBindingOneToOne(db); err != nil { - t.Fatal(err) - } - - var count int64 - if err := db.Model(&model.TelegramBinding{}).Where("user_id = ?", "user-1").Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("active bindings for user-1 = %d, want 1", count) - } - var kept model.TelegramBinding - if err := db.First(&kept, "user_id = ?", "user-1").Error; err != nil { - t.Fatal(err) - } - if kept.TelegramUserID != 10002 { - t.Fatalf("kept telegram binding = %d, want newest 10002", kept.TelegramUserID) - } - if err := db.Create(&model.TelegramBinding{TelegramUserID: 10003, ChatID: 10003, UserID: "user-1"}).Error; err == nil { - t.Fatal("expected unique index to reject another active binding for the same user") - } -} - -func TestEnsureSubscriptionIdentityUniquenessArchivesDuplicatesAndAddsIndex(t *testing.T) { - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.Subscription{}); err != nil { - t.Fatal(err) - } - createdAt := time.Now().Add(-time.Hour) - rows := []model.Subscription{ - {UserID: "user-1", Name: "Example", FeedURL: "site-search://search?keyword=Example", Filter: "Example", Resolution: "1080p", Priority: 50}, - {UserID: "user-1", Name: "Example", FeedURL: "site-search://search?keyword=Example", Filter: "Example", Resolution: "1080p", Priority: 50}, - } - for i := range rows { - rows[i].CreatedAt = createdAt.Add(time.Duration(i) * time.Minute) - if err := db.Select("*").Omit("DeletedAt").Create(&rows[i]).Error; err != nil { - t.Fatal(err) - } - } - - if err := ensureSubscriptionIdentityUniqueness(db); err != nil { - t.Fatal(err) - } - var active []model.Subscription - if err := db.Where("archived_at IS NULL").Order("created_at asc").Find(&active).Error; err != nil { - t.Fatal(err) - } - if len(active) != 1 || active[0].ID != rows[0].ID { - t.Fatalf("active subscriptions = %#v, want earliest row only", active) - } - var archived model.Subscription - if err := db.First(&archived, "id = ?", rows[1].ID).Error; err != nil { - t.Fatal(err) - } - if archived.ArchivedAt == nil || archived.ArchiveReason != duplicateSubscriptionMigrationReason { - t.Fatalf("duplicate was not archived by migration: %#v", archived) - } - - duplicate := rows[0] - duplicate.ID = "" - duplicate.CreatedAt = time.Time{} - duplicate.UpdatedAt = time.Time{} - model.RefreshSubscriptionIdentity(&duplicate) - if err := db.Select("*").Omit("DeletedAt").Create(&duplicate).Error; err == nil { - t.Fatal("expected active identity index to reject a duplicate rule") - } - different := rows[0] - different.ID = "" - different.CreatedAt = time.Time{} - different.UpdatedAt = time.Time{} - different.Resolution = "2160p" - model.RefreshSubscriptionIdentity(&different) - if err := db.Select("*").Omit("DeletedAt").Create(&different).Error; err != nil { - t.Fatalf("different rule should be allowed: %v", err) - } -} - func TestEnsurePerformanceIndexesCreatesHotPathIndexes(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { diff --git a/internal/database/schema_migration.go b/internal/database/schema_migration.go index 0ed7db7..e58b799 100644 --- a/internal/database/schema_migration.go +++ b/internal/database/schema_migration.go @@ -14,12 +14,6 @@ func AutoMigrate(db *gorm.DB) error { if err := ensurePostgresColumnCompatibility(db); err != nil { return err } - if err := enforceTelegramBindingOneToOne(db); err != nil { - return err - } - if err := ensureSubscriptionIdentityUniqueness(db); err != nil { - return err - } if err := ensurePerformanceIndexes(db); err != nil { return err } @@ -44,7 +38,6 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error { `ALTER TABLE playback_histories ALTER COLUMN media_id TYPE varchar(128)`, `ALTER TABLE favorites ALTER COLUMN media_id TYPE varchar(128)`, `ALTER TABLE playlist_items ALTER COLUMN media_id TYPE varchar(128)`, - `ALTER TABLE strm_records ALTER COLUMN media_id TYPE varchar(128)`, } for _, stmt := range statements { if err := db.Exec(stmt).Error; err != nil { @@ -84,39 +77,3 @@ func ensurePerformanceIndexes(db *gorm.DB) error { } return nil } - -func enforceTelegramBindingOneToOne(db *gorm.DB) error { - if !db.Migrator().HasTable(&model.TelegramBinding{}) { - return nil - } - return db.Transaction(func(tx *gorm.DB) error { - if err := tx.Exec(` -DELETE FROM telegram_bindings -WHERE deleted_at IS NULL - AND user_id IN ( - SELECT user_id - FROM telegram_bindings - WHERE deleted_at IS NULL - GROUP BY user_id - HAVING COUNT(*) > 1 - ) - AND id NOT IN ( - SELECT id - FROM ( - SELECT id, - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY updated_at DESC, created_at DESC, id DESC) AS rn - FROM telegram_bindings - WHERE deleted_at IS NULL - ) AS ranked_bindings - WHERE rn = 1 - ) -`).Error; err != nil { - return err - } - return tx.Exec(` -CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_bindings_user_id_active -ON telegram_bindings(user_id) -WHERE deleted_at IS NULL -`).Error - }) -} diff --git a/internal/database/schema_subscription_identity.go b/internal/database/schema_subscription_identity.go deleted file mode 100644 index aa9c88e..0000000 --- a/internal/database/schema_subscription_identity.go +++ /dev/null @@ -1,54 +0,0 @@ -package database - -import ( - "time" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -const duplicateSubscriptionMigrationReason = "迁移合并重复订阅规则" - -func ensureSubscriptionIdentityUniqueness(db *gorm.DB) error { - if db == nil || !db.Migrator().HasTable(&model.Subscription{}) { - return nil - } - return db.Transaction(func(tx *gorm.DB) error { - var rows []model.Subscription - if err := tx.Unscoped().Order("created_at asc, id asc").Find(&rows).Error; err != nil { - return err - } - seen := make(map[string]string) - for i := range rows { - row := &rows[i] - key := model.RefreshSubscriptionIdentity(row) - updates := map[string]any{"identity_key": key} - if !row.DeletedAt.Valid && row.ArchivedAt == nil { - activeKey := row.UserID + "\x00" + key - if _, duplicate := seen[activeKey]; duplicate { - archivedAt := row.UpdatedAt - if archivedAt.IsZero() { - archivedAt = row.CreatedAt - } - if archivedAt.IsZero() { - archivedAt = time.Now() - } - updates["enabled"] = false - updates["archived_at"] = &archivedAt - updates["archive_reason"] = duplicateSubscriptionMigrationReason - } else { - seen[activeKey] = row.ID - } - } - if err := tx.Unscoped().Model(&model.Subscription{}).Where("id = ?", row.ID).Updates(updates).Error; err != nil { - return err - } - } - return tx.Exec(` -CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_user_identity_active -ON subscriptions(user_id, identity_key) -WHERE deleted_at IS NULL AND archived_at IS NULL AND identity_key <> '' -`).Error - }) -} diff --git a/internal/database/sqlite_migration_bootstrap.go b/internal/database/sqlite_migration_bootstrap.go index 8abf895..33db6f9 100644 --- a/internal/database/sqlite_migration_bootstrap.go +++ b/internal/database/sqlite_migration_bootstrap.go @@ -68,8 +68,6 @@ func targetLooksLikeBootstrapOnly(target *gorm.DB) (bool, error) { &model.Favorite{}, &model.Playlist{}, &model.PlaylistItem{}, - &model.DownloadTask{}, - &model.Subscription{}, } { if !target.Migrator().HasTable(m) { continue diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 8ceb57c..a1ed07c 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -43,7 +43,6 @@ func createUserHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - refreshLicenseCapacityBestEffort(c.Request.Context(), svc) u, _, err := svc.Auth.Register(c.Request.Context(), req.Username, req.Password) if err != nil { writeUserMutationError(c, svc, err) @@ -227,8 +226,7 @@ func writeUserMutationError(c *gin.Context, svc *service.Container, err error) { case errors.Is(err, service.ErrUsernameTaken): c.JSON(http.StatusConflict, gin.H{"error": "username already taken"}) case errors.Is(err, service.ErrUserLimitReached): - maxUsers := service.LicensedMaxUsers(c.Request.Context(), svc.Repo) - c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": maxUsers}) + c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": service.UserLimit}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } diff --git a/internal/handler/admin_settings.go b/internal/handler/admin_settings.go index af5f605..ed8afbb 100644 --- a/internal/handler/admin_settings.go +++ b/internal/handler/admin_settings.go @@ -59,9 +59,6 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc { if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" { svc.Transcoder.StopAll() } - if req.Key == "cloud.auto_sync_enabled" && !service.ParseBoolSetting(req.Value, false) && svc.Scan != nil { - _ = svc.Scan.CancelAllCloudScans() - } c.Status(http.StatusNoContent) } } diff --git a/internal/handler/ai.go b/internal/handler/ai.go deleted file mode 100644 index 67abab1..0000000 --- a/internal/handler/ai.go +++ /dev/null @@ -1,79 +0,0 @@ -// Package handler — AI integration endpoints. -package handler - -import ( - "net/http" - "strings" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/middleware" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -type smartSearchReq struct { - Query string `json:"query" binding:"required"` -} - -func smartSearchHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req smartSearchReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - intent, err := svc.AI.SmartSearch(c.Request.Context(), req.Query) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - // Run the actual library search using the cleaned query so the - // caller can render local + external results in one round-trip. - items, _ := svc.Media.SearchMediaVisible(c.Request.Context(), intent.Query, 60, mediaVisibilityForRequest(c, svc)) - external := service.SearchExternalMedia( - c.Request.Context(), - intent.Query, - intent.Year, - intent.Type, - svc.TMDb, - svc.Douban, - svc.Bangumi, - ) - service.EnrichExternalMediaAvailability(c.Request.Context(), svc.Repo, external) - c.JSON(http.StatusOK, gin.H{ - "intent": intent, - "items": items, - "external_items": external, - }) - } -} - -func aiRecommendHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - uid, _ := c.Get(middleware.CtxUserID) - hist, err := svc.Playback.RecentHistory(c.Request.Context(), toString(uid), 10) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - titles := make([]string, 0, len(hist)) - visibility := mediaVisibilityForRequest(c, svc) - for _, h := range hist { - if h.Media != nil && visibility.Allows(h.Media) && strings.TrimSpace(h.Media.Title) != "" { - titles = append(titles, h.Media.Title) - } - } - out, err := svc.AI.Recommend(c.Request.Context(), titles, 8) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"titles": out}) - } -} - -func aiStatusHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - c.JSON(http.StatusOK, svc.AI.Status(c.Request.Context())) - } -} diff --git a/internal/handler/assistant.go b/internal/handler/assistant.go deleted file mode 100644 index a59c6a1..0000000 --- a/internal/handler/assistant.go +++ /dev/null @@ -1,147 +0,0 @@ -// Package handler — multi-turn AI assistant chat endpoints. -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/middleware" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func listAssistantSessionsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - uid, _ := c.Get(middleware.CtxUserID) - role, _ := c.Get(middleware.CtxUserRole) - rows, err := svc.Assistant.ListSessions( - c.Request.Context(), toString(uid), role == "admin", - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, rows) - } -} - -type createSessionReq struct { - Title string `json:"title"` -} - -func createAssistantSessionHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req createSessionReq - _ = c.ShouldBindJSON(&req) - uid, _ := c.Get(middleware.CtxUserID) - sess, err := svc.Assistant.CreateSession(c.Request.Context(), toString(uid), req.Title) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusCreated, sess) - } -} - -func getAssistantSessionHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - uid, _ := c.Get(middleware.CtxUserID) - role, _ := c.Get(middleware.CtxUserRole) - view, err := svc.Assistant.GetSession( - c.Request.Context(), c.Param("id"), toString(uid), role == "admin", - ) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, view) - } -} - -func deleteAssistantSessionHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - uid, _ := c.Get(middleware.CtxUserID) - role, _ := c.Get(middleware.CtxUserRole) - if err := svc.Assistant.DeleteSession( - c.Request.Context(), c.Param("id"), toString(uid), role == "admin", - ); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.Status(http.StatusNoContent) - } -} - -type chatReq struct { - SessionID string `json:"session_id" binding:"required"` - Message string `json:"message" binding:"required"` -} - -func assistantChatHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req chatReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - uid, _ := c.Get(middleware.CtxUserID) - role, _ := c.Get(middleware.CtxUserRole) - view, err := svc.Assistant.Chat( - c.Request.Context(), req.SessionID, toString(uid), req.Message, role == "admin", - ) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, view) - } -} - -type executeReq struct { - SessionID string `json:"session_id" binding:"required"` - Action map[string]interface{} `json:"action" binding:"required"` -} - -func assistantExecuteHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req executeReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - uid, _ := c.Get(middleware.CtxUserID) - opID, err := svc.Assistant.Execute( - c.Request.Context(), req.SessionID, toString(uid), req.Action, - ) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"op_id": opID}) - } -} - -func assistantUndoHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.Assistant.Undo(c.Request.Context(), c.Param("op_id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) - } -} - -func assistantHistoryHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - uid, _ := c.Get(middleware.CtxUserID) - role, _ := c.Get(middleware.CtxUserRole) - rows, err := svc.Assistant.History( - c.Request.Context(), toString(uid), role == "admin", - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": rows}) - } -} diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 19855db..41547c5 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -62,7 +62,6 @@ func registerHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - refreshLicenseCapacityBestEffort(c.Request.Context(), svc) u, tokens, err := svc.Auth.Register(c.Request.Context(), req.Username, req.Password) if err != nil { if errors.Is(err, service.ErrUsernameTaken) { diff --git a/internal/handler/cloud.go b/internal/handler/cloud.go deleted file mode 100644 index 3c9ba7a..0000000 --- a/internal/handler/cloud.go +++ /dev/null @@ -1,160 +0,0 @@ -// Package handler — cloud-disk (网盘) endpoints: directory browsing, QR-code -// login, media import and 302 playback redirects. -package handler - -import ( - "net/http" - "strings" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// cloudListHandler browses a configured cloud disk directory. -func cloudListHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - typ := c.Param("type") - if !service.IsAdminCloudConfigurable(typ) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider", "items": []any{}}) - return - } - dir := c.Query("dir") - entries, err := svc.StorageCfg.CloudList(c.Request.Context(), typ, dir) - if err != nil { - c.JSON(http.StatusOK, gin.H{"error": err.Error(), "items": []any{}}) - return - } - c.JSON(http.StatusOK, gin.H{"items": entries}) - } -} - -func cloudMkdirHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - typ := c.Param("type") - if !service.IsAdminCloudConfigurable(typ) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"}) - return - } - var in struct { - Dir string `json:"dir"` - Name string `json:"name" binding:"required"` - } - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - entry, err := svc.StorageCfg.CloudMkdir(c.Request.Context(), typ, in.Dir, in.Name) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"entry": entry}) - } -} - -func cloudRenameHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - typ := c.Param("type") - if !service.IsAdminCloudConfigurable(typ) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"}) - return - } - var in struct { - Ref string `json:"ref" binding:"required"` - Name string `json:"name" binding:"required"` - } - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - entry, err := svc.StorageCfg.CloudRename(c.Request.Context(), typ, in.Ref, in.Name) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"entry": entry}) - } -} - -// cloudImportHandler turns a cloud file into a playable 302-backed media item. -func cloudImportHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - typ := c.Param("type") - if !service.IsAdminCloudConfigurable(typ) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"}) - return - } - var in struct { - Ref string `json:"ref" binding:"required"` - Name string `json:"name"` - Size int64 `json:"size"` - } - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - m, err := svc.StorageCfg.CloudImport(c.Request.Context(), typ, in.Ref, in.Name, in.Size) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, m) - } -} - -func cloudScanAllHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if svc.Scan == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "scanner unavailable"}) - return - } - statuses, err := svc.Scan.StartAllCloudLibraryScans() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusAccepted, gin.H{ - "items": statuses, - "scan_queued": true, - "message": "已开始扫描所有启用的网盘媒体库", - "resume_message": "中断后再次点击扫描会重新遍历,但已入库媒体会去重更新,只补齐缺失项。", - "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", - }) - } -} - -func cloudScanCancelHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if svc.Scan == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "scanner unavailable"}) - return - } - libraryID := strings.TrimSpace(c.Query("library_id")) - provider := strings.TrimSpace(c.Query("provider")) - cancelled := 0 - if libraryID != "" { - if svc.Scan.CancelCloudScan(libraryID) { - cancelled = 1 - } - } else if provider != "" { - cancelled = svc.Scan.CancelCloudScansForProvider(provider) - } else { - cancelled = svc.Scan.CancelAllCloudScans() - } - c.JSON(http.StatusOK, gin.H{ - "cancelled": cancelled, - "message": "已发送中断信号;正在等待当前网盘请求返回后停止", - }) - } -} - -func cloudScanStatusHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if svc.Scan == nil { - c.JSON(http.StatusOK, gin.H{"items": []service.CloudScanStatus{}}) - return - } - c.JSON(http.StatusOK, gin.H{"items": svc.Scan.CloudScanStatuses()}) - } -} diff --git a/internal/handler/cloud_mount.go b/internal/handler/cloud_mount.go deleted file mode 100644 index be4d8a6..0000000 --- a/internal/handler/cloud_mount.go +++ /dev/null @@ -1,149 +0,0 @@ -package handler - -import ( - "net/http" - "strings" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -// cloudMountHandler creates or reuses a cloud:// media library for a cloud -// directory, then queues a recursive import scan. The scan runs outside the -// request so large 115/OpenList folders do not make the UI report a timeout. -func cloudMountHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - typ := c.Param("type") - if !service.IsAdminCloudConfigurable(typ) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"}) - return - } - var in struct { - Dir string `json:"dir"` - DirPath string `json:"dir_path"` - Name string `json:"name"` - MediaType string `json:"media_type"` - } - _ = c.ShouldBindJSON(&in) - if !cloud.IsCloudType(typ) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"}) - return - } - if _, err := svc.StorageCfg.CloudProvider(c.Request.Context(), typ); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - path := service.BuildCloudLibraryPath(typ, in.Dir, in.DirPath) - name := strings.TrimSpace(in.Name) - if name == "" { - name = cloudMountLibraryName(typ, strings.TrimSpace(in.Dir), strings.TrimSpace(in.DirPath)) - } - mediaType := strings.TrimSpace(in.MediaType) - if mediaType == "" || strings.EqualFold(mediaType, "auto") { - displayDir := strings.TrimSpace(in.DirPath) - if displayDir == "" { - displayDir = strings.TrimSpace(in.Dir) - } - mediaType = service.InferCloudMountMediaType(displayDir, name) - } - libs, err := svc.Repo.Library.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - var lib *model.Library - alreadyMounted := false - if conflict := service.FindCloudMountConflict(libs, typ, in.Dir, in.DirPath); conflict != nil { - lib = &conflict.Library - alreadyMounted = conflict.Exact - if conflict.Nested { - c.JSON(http.StatusOK, gin.H{ - "library": lib, - "skipped": true, - "reason": "cloud mount overlaps an existing mounted parent/child directory", - "conflict_library": conflict.Library, - }) - return - } - } - if lib == nil { - lib = &model.Library{Name: name, Path: path, Type: mediaType, Enabled: true} - if err := svc.Repo.Library.Create(c.Request.Context(), lib); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } else if alreadyMounted { - updates := map[string]any{} - if path != "" && path != lib.Path { - updates["path"] = path - lib.Path = path - } - if mediaType != "" && mediaType != lib.Type { - updates["type"] = mediaType - lib.Type = mediaType - } - currentDisplayName, _ := service.CloudLibraryDisplayName(*lib) - if name != "" && name != lib.Name && (currentDisplayName == "" || currentDisplayName != name || strings.Contains(lib.Name, " · ")) { - updates["name"] = name - lib.Name = name - } - if len(updates) > 0 { - if err := svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Library{}).Where("id = ?", lib.ID).Updates(updates).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - } - if svc.Scan != nil { - libID := lib.ID - if svc.WSHub != nil { - svc.WSHub.Publish("scan", gin.H{ - "library_id": libID, - "cloud": true, - "queued": true, - "stage": "queued", - "message": "云盘扫描已加入后台队列,会递归扫描并自动加入媒体库", - "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", - }) - } - _, _, _ = svc.Scan.StartCloudLibraryScan(libID, false) - } - c.JSON(http.StatusAccepted, gin.H{ - "library": lib, - "already_mounted": alreadyMounted, - "scan_queued": svc.Scan != nil, - "message": "挂载后会后台递归扫描,发现的媒体会自动加入当前媒体库", - "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", - }) - } -} - -func cloudMountLibraryName(typ, dir, displayDir string) string { - base := service.CloudMountProviderLabel(typ) - displayDir = strings.Trim(strings.TrimSpace(strings.ReplaceAll(displayDir, "\\", "/")), "/") - if displayDir != "" { - parts := strings.Split(displayDir, "/") - for i := len(parts) - 1; i >= 0; i-- { - if part := strings.TrimSpace(parts[i]); part != "" { - return part - } - } - } - if dir == "" || dir == "0" { - return base - } - dir = strings.Trim(strings.TrimSpace(strings.ReplaceAll(dir, "\\", "/")), "/") - if dir == "" { - return base - } - parts := strings.Split(dir, "/") - for i := len(parts) - 1; i >= 0; i-- { - if part := strings.TrimSpace(parts[i]); part != "" { - return part - } - } - return base -} diff --git a/internal/handler/cloud_playback.go b/internal/handler/cloud_playback.go deleted file mode 100644 index ad833f1..0000000 --- a/internal/handler/cloud_playback.go +++ /dev/null @@ -1,284 +0,0 @@ -package handler - -import ( - "crypto/sha256" - "encoding/hex" - "io" - "net/http" - "net/url" - "path" - "sort" - "strings" - "time" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/service" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -type cloudPlaybackRequest struct { - svc *service.Container - c *gin.Context - typ string - ref string - link *cloud.DirectLink - resolveStart time.Time - resolveDur time.Duration -} - -// cloudPlayHandler resolves a cloud file to its direct link and either issues a -// 302 redirect (true offload — host does not stream the bytes) or, when the -// provider requires authenticated headers, reverse-proxies the response. -func cloudPlayHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - typ := c.Param("type") - ref := c.Query("ref") - if !service.IsAdminCloudConfigurable(typ) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"}) - return - } - if ref == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "ref required"}) - return - } - if !enforceScopedCloudPlaybackToken(c, svc, typ, ref) { - return - } - serveCloudResolvedLink(svc, c, typ, ref) - } -} - -func serveCloudResolvedLink(svc *service.Container, c *gin.Context, typ, ref string) { - if isCloudImageRef(ref) && svc != nil && svc.ImageProxy != nil { - if svc.ImageProxy.ServeCloudCached(c.Writer, c.Request, typ+":"+ref) { - return - } - } - if svc == nil || svc.StorageCfg == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud storage service unavailable"}) - return - } - resolveStart := time.Now() - link, err := svc.StorageCfg.CloudResolve(c.Request.Context(), typ, ref, c.Request.UserAgent()) - resolveDur := time.Since(resolveStart) - if err != nil { - logCloudPlayback(svc, "cloud playback resolve failed", - append(cloudPlaybackLogFields(typ, ref, nil, resolveDur), zap.Error(err))...) - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - if isCloudImageRef(ref) && svc.ImageProxy != nil { - if err := svc.ImageProxy.ServeCloudResolved(c.Request.Context(), c.Writer, c.Request, typ+":"+ref, link); err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - } - return - } - if isCloudImageRef(ref) { - c.Header("Cache-Control", "public, max-age=2592000, immutable") - } - if !link.Proxy { - // Pure offload: send the client straight to the cloud CDN. - setRedirectNoStoreHeaders(c) - logCloudPlayback(svc, "cloud playback redirect", - append(cloudPlaybackLogFields(typ, ref, link, resolveDur), - zap.String("mode", "redirect"), - zap.Int("status", http.StatusFound), - zap.String("method", c.Request.Method), - zap.String("range", c.GetHeader("Range")), - )...) - c.Redirect(http.StatusFound, link.URL) - return - } - proxyCloudResolvedLink(cloudPlaybackRequest{ - svc: svc, - c: c, - typ: typ, - ref: ref, - link: link, - resolveStart: resolveStart, - resolveDur: resolveDur, - }) -} - -func proxyCloudResolvedLink(playback cloudPlaybackRequest) { - c := playback.c - clientMethod := playback.c.Request.Method - if clientMethod == "" { - clientMethod = http.MethodGet - } - upstreamMethod := clientMethod - req, err := http.NewRequestWithContext(c.Request.Context(), upstreamMethod, playback.link.URL, nil) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - for k, v := range playback.link.Headers { - req.Header.Set(k, v) - } - if rng := c.GetHeader("Range"); rng != "" { - req.Header.Set("Range", rng) - } - if accept := c.GetHeader("Accept"); accept != "" { - req.Header.Set("Accept", accept) - } - if c.GetHeader("Accept-Encoding") == "" { - req.Header.Set("Accept-Encoding", "identity") - } - upstreamStart := time.Now() - resp, err := http.DefaultClient.Do(req) - upstreamHeaderDur := time.Since(upstreamStart) - if err != nil { - logCloudPlayback(playback.svc, "cloud playback proxy upstream failed", - append(cloudPlaybackLogFields(playback.typ, playback.ref, playback.link, playback.resolveDur), - zap.String("mode", "proxy"), - zap.String("method", clientMethod), - zap.String("upstream_method", upstreamMethod), - zap.String("range", c.GetHeader("Range")), - zap.Int64("upstream_header_ms", durationMilliseconds(upstreamHeaderDur)), - zap.Error(err), - )...) - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - defer resp.Body.Close() - for _, h := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} { - if v := resp.Header.Get(h); v != "" { - c.Header(h, v) - } - } - if c.Writer.Header().Get("Accept-Ranges") == "" { - c.Header("Accept-Ranges", "bytes") - } - if resp.StatusCode >= 400 { - handleCloudProxyError(playback, req, resp, clientMethod, upstreamMethod, upstreamHeaderDur) - return - } - streamCloudProxyResponse(playback, req, resp, clientMethod, upstreamMethod, upstreamHeaderDur) -} - -func handleCloudProxyError(playback cloudPlaybackRequest, req *http.Request, resp *http.Response, clientMethod, upstreamMethod string, upstreamHeaderDur time.Duration) { - c := playback.c - c.Header("Cache-Control", "no-store") - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - fields := append(cloudPlaybackLogFields(playback.typ, playback.ref, playback.link, playback.resolveDur), - zap.String("mode", "proxy"), - zap.String("method", clientMethod), - zap.String("upstream_method", upstreamMethod), - zap.String("range", c.GetHeader("Range")), - zap.String("upstream_range", req.Header.Get("Range")), - zap.Int("status", resp.StatusCode), - zap.String("content_range", resp.Header.Get("Content-Range")), - zap.String("content_length", resp.Header.Get("Content-Length")), - zap.String("upstream_error_body", strings.TrimSpace(string(body))), - zap.Int64("upstream_header_ms", durationMilliseconds(upstreamHeaderDur)), - zap.Int64("total_ms", durationMilliseconds(time.Since(playback.resolveStart))), - ) - logCloudPlayback(playback.svc, "cloud playback proxy upstream returned error", fields...) - c.Status(resp.StatusCode) - if clientMethod != http.MethodHead && len(body) > 0 { - _, _ = c.Writer.Write(body) - } -} - -func streamCloudProxyResponse(playback cloudPlaybackRequest, req *http.Request, resp *http.Response, clientMethod, upstreamMethod string, upstreamHeaderDur time.Duration) { - c := playback.c - c.Status(resp.StatusCode) - var copied int64 - var copyErr error - streamStart := time.Now() - if c.Request.Method != http.MethodHead { - copied, copyErr = io.Copy(c.Writer, resp.Body) - } - fields := append(cloudPlaybackLogFields(playback.typ, playback.ref, playback.link, playback.resolveDur), - zap.String("mode", "proxy"), - zap.String("method", clientMethod), - zap.String("upstream_method", upstreamMethod), - zap.String("range", c.GetHeader("Range")), - zap.String("upstream_range", req.Header.Get("Range")), - zap.Int("status", resp.StatusCode), - zap.String("content_range", resp.Header.Get("Content-Range")), - zap.String("content_length", resp.Header.Get("Content-Length")), - zap.Int64("upstream_header_ms", durationMilliseconds(upstreamHeaderDur)), - zap.Int64("stream_ms", durationMilliseconds(time.Since(streamStart))), - zap.Int64("total_ms", durationMilliseconds(time.Since(playback.resolveStart))), - zap.Int64("bytes", copied), - ) - if copyErr != nil { - logCloudPlayback(playback.svc, "cloud playback proxy copy failed", append(fields, zap.Error(copyErr))...) - return - } - logCloudPlayback(playback.svc, "cloud playback proxy finished", fields...) -} - -func isCloudImageRef(ref string) bool { - ref = strings.ToLower(strings.TrimSpace(ref)) - for _, suffix := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn"} { - if strings.HasSuffix(ref, suffix) { - return true - } - } - return false -} - -func logCloudPlayback(svc *service.Container, msg string, fields ...zap.Field) { - if svc == nil || svc.Log == nil { - return - } - svc.Log.Info(msg, fields...) -} - -func cloudPlaybackLogFields(typ, ref string, link *cloud.DirectLink, resolveDur time.Duration) []zap.Field { - refHash, refExt := cloudPlaybackRefFingerprint(ref) - fields := []zap.Field{ - zap.String("provider", strings.TrimSpace(typ)), - zap.String("ref_hash", refHash), - zap.String("ref_ext", refExt), - zap.Int64("resolve_ms", durationMilliseconds(resolveDur)), - } - if link != nil { - fields = append(fields, - zap.String("target_host", cloudPlaybackLinkHost(link.URL)), - zap.Bool("headers_required", len(link.Headers) > 0), - zap.Strings("header_names", cloudPlaybackHeaderNames(link.Headers)), - ) - } - return fields -} - -func cloudPlaybackRefFingerprint(ref string) (string, string) { - ref = strings.TrimSpace(ref) - sum := sha256.Sum256([]byte(ref)) - ext := strings.ToLower(path.Ext(strings.Trim(strings.ReplaceAll(ref, "\\", "/"), "/"))) - return hex.EncodeToString(sum[:])[:12], ext -} - -func cloudPlaybackLinkHost(raw string) string { - u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || u.Host == "" { - return "" - } - return u.Host -} - -func cloudPlaybackHeaderNames(headers map[string]string) []string { - if len(headers) == 0 { - return nil - } - out := make([]string, 0, len(headers)) - for key := range headers { - if key = strings.TrimSpace(key); key != "" { - out = append(out, key) - } - } - sort.Strings(out) - return out -} - -func durationMilliseconds(d time.Duration) int64 { - if d <= 0 { - return 0 - } - return d.Milliseconds() -} diff --git a/internal/handler/cloud_playback_test.go b/internal/handler/cloud_playback_test.go deleted file mode 100644 index d8c4a48..0000000 --- a/internal/handler/cloud_playback_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package handler - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -func TestProxyCloudResolvedLinkUsesHEADWithoutSyntheticRange(t *testing.T) { - gin.SetMode(gin.TestMode) - var upstreamMethod, upstreamRange string - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - upstreamMethod = r.Method - upstreamRange = r.Header.Get("Range") - w.Header().Set("Content-Type", "video/mp4") - w.Header().Set("Content-Length", "123456") - w.Header().Set("Accept-Ranges", "bytes") - w.WriteHeader(http.StatusOK) - })) - defer upstream.Close() - - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodHead, "/api/cloud/play/openlist?ref=movie", nil) - - proxyCloudResolvedLink(cloudPlaybackRequest{ - c: c, - typ: "openlist", - ref: "movie", - link: &cloud.DirectLink{ - URL: upstream.URL + "/movie.mp4", - Proxy: true, - }, - }) - - if upstreamMethod != http.MethodHead { - t.Fatalf("upstream method = %q, want HEAD", upstreamMethod) - } - if upstreamRange != "" { - t.Fatalf("upstream Range = %q, want empty", upstreamRange) - } - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - if got := rec.Header().Get("Content-Length"); got != "123456" { - t.Fatalf("Content-Length = %q, want full upstream length", got) - } - if rec.Body.Len() != 0 { - t.Fatalf("HEAD response body length = %d, want 0", rec.Body.Len()) - } -} diff --git a/internal/handler/cloud_qr.go b/internal/handler/cloud_qr.go deleted file mode 100644 index f04013d..0000000 --- a/internal/handler/cloud_qr.go +++ /dev/null @@ -1,49 +0,0 @@ -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -// cloud115QRStartHandler begins a 115 QR-code login and returns the session + -// QR image URL for the frontend to render. -func cloud115QRStartHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if c.Param("type") != cloud.Type115 { - c.JSON(http.StatusBadRequest, gin.H{"error": "qr login is only supported for 115"}) - return - } - sess, err := cloud.QRStart(c.Request.Context(), nil) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, sess) - } -} - -// cloud115QRPollHandler polls a 115 QR session; on confirmation it returns the -// session cookie so the frontend can save it as the storage credential. -func cloud115QRPollHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if c.Param("type") != cloud.Type115 { - c.JSON(http.StatusBadRequest, gin.H{"error": "qr login is only supported for 115"}) - return - } - var sess cloud.QRSession - if err := c.ShouldBindJSON(&sess); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - st, err := cloud.QRPoll(c.Request.Context(), nil, &sess) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, st) - } -} diff --git a/internal/handler/cloud_test.go b/internal/handler/cloud_test.go deleted file mode 100644 index c749116..0000000 --- a/internal/handler/cloud_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package handler - -import ( - "bytes" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/service" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -var handlerTestJPEG = []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00, 0xff, 0xd9} - -func TestCloudMountLibraryNameDefaultsToDirectoryBaseName(t *testing.T) { - tests := []struct { - name string - provider string - dir string - displayDir string - want string - }{ - {name: "openlist directory", provider: "openlist", dir: "/国产剧", displayDir: "/国产剧", want: "国产剧"}, - {name: "nested directory", provider: "openlist", dir: "id-123", displayDir: "剧集/国产剧", want: "国产剧"}, - {name: "provider root", provider: "openlist", dir: "", displayDir: "", want: "OpenList"}, - {name: "115 root id", provider: "cloud115", dir: "0", displayDir: "", want: "115 网盘"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := cloudMountLibraryName(tt.provider, tt.dir, tt.displayDir); got != tt.want { - t.Fatalf("cloudMountLibraryName() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestCloudPlaybackDiagnosticsDoNotExposeRawRefOrURL(t *testing.T) { - rawRef := "/剧集/国产剧/很长的敏感文件名.S01E01.mkv" - refHash, refExt := cloudPlaybackRefFingerprint(rawRef) - if refHash == "" || strings.Contains(rawRef, refHash) { - t.Fatalf("ref hash should be a short fingerprint, got %q", refHash) - } - if refExt != ".mkv" { - t.Fatalf("ref ext = %q, want .mkv", refExt) - } - if host := cloudPlaybackLinkHost("https://cdn.example.test/movie.mkv?token=secret"); host != "cdn.example.test" { - t.Fatalf("host = %q, want cdn.example.test", host) - } - names := cloudPlaybackHeaderNames(map[string]string{ - "Authorization": "Bearer secret", - "Cookie": "sid=secret", - }) - if got := strings.Join(names, ","); got != "Authorization,Cookie" { - t.Fatalf("header names = %q", got) - } -} - -func TestAdminCloudHandlersRejectQuarkBrowsing(t *testing.T) { - gin.SetMode(gin.TestMode) - router := gin.New() - router.GET("/admin/cloud/:type/list", cloudListHandler(nil)) - - req := httptest.NewRequest(http.MethodGet, "/admin/cloud/quark/list?dir=0", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("status = %d body=%s, want 400", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "unsupported cloud provider") { - t.Fatalf("body = %s, want unsupported cloud provider", w.Body.String()) - } -} - -func TestCloudPlayRejectsQuarkProvider(t *testing.T) { - gin.SetMode(gin.TestMode) - router := gin.New() - router.GET("/api/cloud/play/:type", cloudPlayHandler(nil)) - - req := httptest.NewRequest(http.MethodGet, "/api/cloud/play/quark?ref=file-1", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("status = %d body=%s, want 400", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "unsupported cloud provider") { - t.Fatalf("body = %s, want unsupported cloud provider", w.Body.String()) - } -} - -func TestCloudArtworkProxyServesCachedImageWithoutCloudResolve(t *testing.T) { - gin.SetMode(gin.TestMode) - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(handlerTestJPEG) - })) - defer upstream.Close() - - imageProxy := service.NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, zap.NewNop()) - stableKey := "openlist:/Anime/JianLai/poster.jpg" - if err := imageProxy.PrefetchCloudResolved(t.Context(), stableKey, &cloud.DirectLink{URL: upstream.URL + "/poster.jpg"}); err != nil { - t.Fatal(err) - } - - router := gin.New() - router.GET("/api/img/cloud/:type", cloudArtworkProxyHandler(&service.Container{ImageProxy: imageProxy})) - - req := httptest.NewRequest(http.MethodGet, "/api/img/cloud/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body=%s, want 200", w.Code, w.Body.String()) - } - if got := w.Body.Bytes(); !bytes.Equal(got, handlerTestJPEG) { - t.Fatalf("body = %q, want cached poster", got) - } - if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "max-age=2592000") { - t.Fatalf("cache-control = %q, want long static cache", got) - } -} - -func TestCloudArtworkProxyAcceptsCachedTBNImage(t *testing.T) { - gin.SetMode(gin.TestMode) - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(handlerTestJPEG) - })) - defer upstream.Close() - - imageProxy := service.NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, zap.NewNop()) - stableKey := "openlist:/Movies/Movie.tbn" - if err := imageProxy.PrefetchCloudResolved(t.Context(), stableKey, &cloud.DirectLink{URL: upstream.URL + "/Movie.tbn"}); err != nil { - t.Fatal(err) - } - - router := gin.New() - router.GET("/api/img/cloud/:type", cloudArtworkProxyHandler(&service.Container{ImageProxy: imageProxy})) - - req := httptest.NewRequest(http.MethodGet, "/api/img/cloud/openlist?ref=%2FMovies%2FMovie.tbn", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body=%s, want 200", w.Code, w.Body.String()) - } - if got := w.Body.Bytes(); !bytes.Equal(got, handlerTestJPEG) { - t.Fatalf("body = %q, want cached tbn poster", got) - } -} diff --git a/internal/handler/discover.go b/internal/handler/discover.go deleted file mode 100644 index 89b83b1..0000000 --- a/internal/handler/discover.go +++ /dev/null @@ -1,48 +0,0 @@ -// Package handler — TMDb discovery endpoints. -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// trendingHandler 返回 TMDb 当日热门列表。 -// -// 当本机无法连接 TMDb(GFW / 代理未配 / API key 无效)时,TMDb 调用会 -// 在 15 秒后超时;这种情况下不应该让首页显示 500 错误,而是把空列表 -// 直接返回——前端按 items.length === 0 渲染"暂无推荐"即可。 -func trendingHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - items, err := svc.Discover.Trending(c.Request.Context()) - if err != nil { - svc.Log.Warn("discover trending failed (returning empty list)", zap.Error(err)) - c.JSON(http.StatusOK, gin.H{"items": []service.Match{}, "error": err.Error()}) - return - } - if items == nil { - items = []service.Match{} - } - svc.Discover.WarmMatchArtwork(items) - c.JSON(http.StatusOK, gin.H{"items": items}) - } -} - -func popularHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - items, err := svc.Discover.Popular(c.Request.Context()) - if err != nil { - svc.Log.Warn("discover popular failed (returning empty list)", zap.Error(err)) - c.JSON(http.StatusOK, gin.H{"items": []service.Match{}, "error": err.Error()}) - return - } - if items == nil { - items = []service.Match{} - } - svc.Discover.WarmMatchArtwork(items) - c.JSON(http.StatusOK, gin.H{"items": items}) - } -} diff --git a/internal/handler/discover_extra.go b/internal/handler/discover_extra.go deleted file mode 100644 index aa5b017..0000000 --- a/internal/handler/discover_extra.go +++ /dev/null @@ -1,338 +0,0 @@ -// Package handler — multi-section discover endpoints. -// -// The Vue DiscoverView paginates a configurable list of "sections" -// (trending day/week, popular movies, top rated, etc.) and asks the -// backend for a feed keyed by section name. We mirror that surface so -// the React DiscoverPage can render the same rails without a rewrite. -package handler - -import ( - "context" - "errors" - "net/http" - "strconv" - "strings" - "time" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -type discoverSectionDef struct { - Key string - Label string - Provider string -} - -var discoverSectionCatalog = []discoverSectionDef{ - {Key: "tmdb_trending_day", Label: "TMDb 今日趋势", Provider: "tmdb"}, - {Key: "tmdb_trending_week", Label: "TMDb 本周热门", Provider: "tmdb"}, - {Key: "tmdb_latest_movie", Label: "TMDb 最新电影", Provider: "tmdb"}, - {Key: "tmdb_latest_tv", Label: "TMDb 最新剧集", Provider: "tmdb"}, - {Key: "tmdb_popular_movie", Label: "TMDb 热门电影", Provider: "tmdb"}, - {Key: "tmdb_popular_tv", Label: "TMDb 热门剧集", Provider: "tmdb"}, - {Key: "tmdb_top_rated_movie", Label: "TMDb 高分电影", Provider: "tmdb"}, - {Key: "tmdb_upcoming_movie", Label: "TMDb 即将上映", Provider: "tmdb"}, - {Key: "douban_hot_movie", Label: "豆瓣热门电影", Provider: "douban"}, - {Key: "douban_hot_tv", Label: "豆瓣热门剧集", Provider: "douban"}, - {Key: "douban_top_movie", Label: "豆瓣高分电影", Provider: "douban"}, - {Key: "bangumi_calendar", Label: "Bangumi 每日放送", Provider: "bangumi"}, -} - -const discoverFeedSectionTimeout = 20 * time.Second -const discoverFeedBangumiTimeout = 30 * time.Second -const discoverFeedSlowSectionThreshold = 2 * time.Second - -// discoverSectionsHandler returns the catalog of sections the UI can -// pick from. The names match the upstream Vue UI so existing settings -// keep working. -func discoverSectionsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - sections := make([]gin.H, 0, len(discoverSectionCatalog)) - for _, section := range enabledDiscoverSections(c.Request.Context(), svc) { - sections = append(sections, gin.H{"key": section.Key, "label": section.Label, "provider": section.Provider}) - } - c.JSON(http.StatusOK, gin.H{"sections": sections}) - } -} - -// discoverFeedHandler resolves one or more section keys (?sections=a,b) -// to TMDb / Douban / Bangumi rails and returns the joined results keyed by -// section name. Unknown keys are silently dropped so URL typos don't break -// the page. -func discoverFeedHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - rawSections := c.Query("sections") - if strings.TrimSpace(rawSections) == "" { - rawSections = strings.Join(defaultDiscoverSectionKeys(c.Request.Context(), svc), ",") - } - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - if page < 1 { - page = 1 - } - keys := strings.Split(rawSections, ",") - out := gin.H{} - meta := gin.H{} - artworkItems := []service.ExternalMediaResult{} - for _, raw := range keys { - k := strings.TrimSpace(raw) - if k == "" { - continue - } - if provider := discoverSectionProvider(k); provider != "" && !discoverProviderEnabled(c.Request.Context(), svc, provider) { - out[k] = []service.ExternalMediaResult{} - meta[k] = gin.H{"page": page, "has_next": false, "disabled": true} - continue - } - sectionTimeout := discoverSectionTimeout(k) - sectionCtx, cancel := context.WithTimeout(c.Request.Context(), sectionTimeout) - started := time.Now() - items, err := discoverSectionItems(sectionCtx, svc, k, page) - elapsed := time.Since(started) - cancel() - metaEntry := gin.H{"page": page, "has_next": false, "duration_ms": elapsed.Milliseconds()} - if err != nil { - logDiscoverFetchFailed(svc, k, page, elapsed, sectionTimeout, err) - if cached, ok := cachedDiscoverSection(svc, k, page); ok { - items = cached - metaEntry["stale"] = true - metaEntry["warning"] = discoverFeedStaleMessage(err) - } else if fallbackItems, fallbackKey, ok := fallbackDiscoverSectionItems(c.Request.Context(), svc, k, page); ok { - items = fallbackItems - metaEntry["fallback"] = fallbackKey - metaEntry["warning"] = discoverFeedFallbackMessage(fallbackKey, err) - rememberDiscoverSection(svc, k, page, items) - } else { - metaEntry["error"] = discoverFeedErrorMessage(err) - items = nil - } - } else { - logDiscoverFetchSlow(svc, k, page, elapsed, len(items)) - rememberDiscoverSection(svc, k, page, items) - } - artworkItems = append(artworkItems, items...) - out[k] = items - metaEntry["has_next"] = discoverSectionHasNext(k, len(items)) - meta[k] = metaEntry - } - out["_meta"] = meta - if svc != nil && svc.Discover != nil { - svc.Discover.WarmExternalArtwork(artworkItems) - } - c.JSON(http.StatusOK, out) - } -} - -func cachedDiscoverSection(svc *service.Container, key string, page int) ([]service.ExternalMediaResult, bool) { - if svc == nil || svc.Discover == nil { - return nil, false - } - return svc.Discover.CachedSection(key, page) -} - -func rememberDiscoverSection(svc *service.Container, key string, page int, items []service.ExternalMediaResult) { - if svc == nil || svc.Discover == nil { - return - } - svc.Discover.RememberSection(key, page, items) -} - -func fallbackDiscoverSectionItems(parent context.Context, svc *service.Container, key string, page int) ([]service.ExternalMediaResult, string, bool) { - fallbackKey := fallbackDiscoverSectionKey(key) - if fallbackKey == "" || svc == nil || svc.Discover == nil { - return nil, "", false - } - ctx, cancel := context.WithTimeout(parent, discoverSectionTimeout(fallbackKey)) - defer cancel() - items, err := discoverSectionItems(ctx, svc, fallbackKey, page) - if err != nil || len(items) == 0 { - return nil, fallbackKey, false - } - if svc.Log != nil { - svc.Log.Info("discover section fallback used", - zap.String("section", key), - zap.String("fallback_section", fallbackKey), - zap.Int("page", page), - zap.Int("items", len(items))) - } - return items, fallbackKey, true -} - -func fallbackDiscoverSectionKey(key string) string { - switch key { - case "douban_hot_movie": - return "tmdb_popular_movie" - case "douban_hot_tv": - return "tmdb_popular_tv" - case "douban_top_movie": - return "tmdb_top_rated_movie" - default: - return "" - } -} - -func logDiscoverFetchFailed(svc *service.Container, key string, page int, elapsed, timeout time.Duration, err error) { - if svc == nil || svc.Log == nil || err == nil { - return - } - svc.Log.Warn("discover section fetch failed", - zap.String("section", key), - zap.String("provider", discoverSectionProvider(key)), - zap.Int("page", page), - zap.Duration("duration", elapsed), - zap.Int64("duration_ms", elapsed.Milliseconds()), - zap.Duration("timeout", timeout), - zap.Error(err)) -} - -func logDiscoverFetchSlow(svc *service.Container, key string, page int, elapsed time.Duration, itemCount int) { - if svc == nil || svc.Log == nil || elapsed < discoverFeedSlowSectionThreshold { - return - } - svc.Log.Info("discover section fetch slow", - zap.String("section", key), - zap.String("provider", discoverSectionProvider(key)), - zap.Int("page", page), - zap.Int("items", itemCount), - zap.Duration("duration", elapsed), - zap.Int64("duration_ms", elapsed.Milliseconds()), - zap.Duration("slow_threshold", discoverFeedSlowSectionThreshold)) -} - -func discoverFeedErrorMessage(err error) string { - if err == nil { - return "" - } - if errors.Is(err, context.DeadlineExceeded) { - return "推荐源响应超时,已跳过本次加载" - } - var timeout interface{ Timeout() bool } - if errors.As(err, &timeout) && timeout.Timeout() { - return "推荐源响应超时,已跳过本次加载" - } - msg := strings.ToLower(err.Error()) - if strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline exceeded") || strings.Contains(msg, "context deadline exceeded") { - return "推荐源响应超时,已跳过本次加载" - } - return "推荐源暂时不可用,已跳过本次加载" -} - -func discoverFeedStaleMessage(err error) string { - if discoverFeedErrorMessage(err) == "推荐源响应超时,已跳过本次加载" { - return "推荐源响应超时,已显示上次成功结果" - } - return "推荐源暂时不可用,已显示上次成功结果" -} - -func discoverFeedFallbackMessage(fallbackKey string, err error) string { - if strings.TrimSpace(fallbackKey) == "" { - return discoverFeedErrorMessage(err) - } - return "推荐源暂时不可用,已显示同类备用榜单" -} - -func discoverSectionTimeout(key string) time.Duration { - if key == "bangumi_calendar" { - return discoverFeedBangumiTimeout - } - return discoverFeedSectionTimeout -} - -func enabledDiscoverSections(ctx context.Context, svc *service.Container) []discoverSectionDef { - sections := make([]discoverSectionDef, 0, len(discoverSectionCatalog)) - for _, section := range discoverSectionCatalog { - if !discoverProviderEnabled(ctx, svc, section.Provider) { - continue - } - sections = append(sections, section) - } - return sections -} - -func defaultDiscoverSectionKeys(ctx context.Context, svc *service.Container) []string { - preferred := []string{"tmdb_trending_day", "tmdb_latest_movie", "tmdb_latest_tv", "douban_hot_movie", "douban_hot_tv", "bangumi_calendar"} - enabled := map[string]struct{}{} - for _, section := range enabledDiscoverSections(ctx, svc) { - enabled[section.Key] = struct{}{} - } - out := make([]string, 0, len(preferred)) - for _, key := range preferred { - if _, ok := enabled[key]; ok { - out = append(out, key) - } - } - if len(out) > 0 { - return out - } - for _, section := range enabledDiscoverSections(ctx, svc) { - out = append(out, section.Key) - if len(out) >= 4 { - break - } - } - return out -} - -func discoverSectionProvider(key string) string { - for _, section := range discoverSectionCatalog { - if section.Key == key { - return section.Provider - } - } - switch key { - case "trending_day", "trending_week", "latest_movie", "latest_tv", "popular_movie", "popular_tv", "top_rated_movie", "upcoming_movie": - return "tmdb" - default: - return "" - } -} - -func discoverProviderEnabled(ctx context.Context, svc *service.Container, provider string) bool { - if svc == nil || svc.APIConfig == nil || strings.TrimSpace(provider) == "" { - return true - } - cfg, err := svc.APIConfig.Get(ctx, provider) - if err != nil || cfg == nil { - return true - } - return cfg.Enabled -} - -func discoverSectionItems(ctx context.Context, svc *service.Container, k string, page int) ([]service.ExternalMediaResult, error) { - switch k { - case "tmdb_trending_day", "tmdb_trending_week", "tmdb_latest_movie", "tmdb_latest_tv", "tmdb_popular_movie", "tmdb_popular_tv", "tmdb_top_rated_movie", "tmdb_upcoming_movie", - "trending_day", "trending_week", "latest_movie", "latest_tv", "popular_movie", "popular_tv", "top_rated_movie", "upcoming_movie": - return svc.Discover.TMDbSection(ctx, k, page) - case "douban_hot_movie", "douban_hot_tv", "douban_top_movie": - if svc.Douban == nil { - return []service.ExternalMediaResult{}, nil - } - return svc.Douban.Discover(ctx, k, page) - case "bangumi_calendar": - if svc.Bangumi == nil { - return []service.ExternalMediaResult{}, nil - } - if page > 1 { - return []service.ExternalMediaResult{}, nil - } - return svc.Bangumi.Calendar(ctx) - default: - return []service.ExternalMediaResult{}, nil - } -} - -func discoverSectionHasNext(key string, itemCount int) bool { - if itemCount <= 0 { - return false - } - switch discoverSectionProvider(key) { - case "tmdb": - return itemCount >= 20 - case "douban": - return itemCount >= 24 - default: - return false - } -} diff --git a/internal/handler/discover_extra_test.go b/internal/handler/discover_extra_test.go deleted file mode 100644 index b5ec25f..0000000 --- a/internal/handler/discover_extra_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package handler - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/glebarez/sqlite" - "go.uber.org/zap" - "go.uber.org/zap/zaptest/observer" - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func TestDiscoverProviderEnabledHonorsAPIConfigToggle(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.APIConfig{}); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - apiConfig := service.NewAPIConfigService(zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())) - enabled := false - if _, err := apiConfig.Update(t.Context(), "douban", service.APIConfigPatch{Enabled: &enabled}); err != nil { - t.Fatal(err) - } - svc := &service.Container{APIConfig: apiConfig} - - if discoverProviderEnabled(t.Context(), svc, "douban") { - t.Fatal("disabled API config should disable discover provider") - } - if !discoverProviderEnabled(t.Context(), svc, "missing-provider") { - t.Fatal("missing API config should keep discover provider available") - } -} - -func TestDiscoverFetchFailureLogIncludesDiagnostics(t *testing.T) { - core, observed := observer.New(zap.WarnLevel) - logger := zap.New(core) - - logDiscoverFetchFailed( - &service.Container{Log: logger}, - "tmdb_latest_movie", - 2, - 1500*time.Millisecond, - discoverSectionTimeout("tmdb_latest_movie"), - context.DeadlineExceeded, - ) - - entries := observed.FilterMessage("discover section fetch failed").All() - if len(entries) != 1 { - t.Fatalf("expected one failure log entry, got %d", len(entries)) - } - fields := entries[0].ContextMap() - if fields["section"] != "tmdb_latest_movie" || fields["provider"] != "tmdb" { - t.Fatalf("unexpected section/provider fields: %#v", fields) - } - if fields["page"] != int64(2) && fields["page"] != 2 { - t.Fatalf("page field missing or wrong: %#v", fields["page"]) - } - if fields["duration_ms"] != int64(1500) && fields["duration_ms"] != 1500 { - t.Fatalf("duration_ms field missing or wrong: %#v", fields["duration_ms"]) - } - if _, ok := fields["timeout"]; !ok { - t.Fatalf("timeout field missing: %#v", fields) - } -} - -func TestDiscoverSectionTimeoutRaisesBangumiBudget(t *testing.T) { - if got := discoverSectionTimeout("bangumi_calendar"); got != discoverFeedBangumiTimeout { - t.Fatalf("bangumi timeout = %s, want %s", got, discoverFeedBangumiTimeout) - } - if got := discoverSectionTimeout("tmdb_latest_movie"); got != discoverFeedSectionTimeout { - t.Fatalf("tmdb timeout = %s, want %s", got, discoverFeedSectionTimeout) - } -} - -func TestDiscoverSlowFetchLogIncludesSectionTiming(t *testing.T) { - core, observed := observer.New(zap.InfoLevel) - logger := zap.New(core) - - logDiscoverFetchSlow(&service.Container{Log: logger}, "douban_hot_movie", 1, discoverFeedSlowSectionThreshold-time.Millisecond, 24) - if got := observed.FilterMessage("discover section fetch slow").Len(); got != 0 { - t.Fatalf("fast section should not log, got %d entries", got) - } - - logDiscoverFetchSlow(&service.Container{Log: logger}, "douban_hot_movie", 1, discoverFeedSlowSectionThreshold, 24) - entries := observed.FilterMessage("discover section fetch slow").All() - if len(entries) != 1 { - t.Fatalf("expected one slow log entry, got %d", len(entries)) - } - fields := entries[0].ContextMap() - if fields["section"] != "douban_hot_movie" || fields["provider"] != "douban" { - t.Fatalf("unexpected section/provider fields: %#v", fields) - } - if fields["items"] != int64(24) && fields["items"] != 24 { - t.Fatalf("items field missing or wrong: %#v", fields["items"]) - } - if _, ok := fields["duration_ms"]; !ok { - t.Fatalf("duration_ms field missing: %#v", fields) - } - if _, ok := fields["slow_threshold"]; !ok { - t.Fatalf("slow_threshold field missing: %#v", fields) - } -} - -func TestDiscoverFeedErrorMessageHidesTechnicalTimeout(t *testing.T) { - for _, err := range []error{ - context.DeadlineExceeded, - errors.New("timeout of 30000ms exceeded"), - } { - got := discoverFeedErrorMessage(err) - if got != "推荐源响应超时,已跳过本次加载" { - t.Fatalf("message for %q = %q", err, got) - } - } - if got := discoverFeedErrorMessage(errors.New("upstream 503")); got != "推荐源暂时不可用,已跳过本次加载" { - t.Fatalf("generic message = %q", got) - } -} - -func TestDefaultDiscoverSectionKeysSkipDisabledProviders(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.APIConfig{}); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - apiConfig := service.NewAPIConfigService(zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())) - disabled := false - for _, provider := range []string{"douban", "bangumi"} { - if _, err := apiConfig.Update(t.Context(), provider, service.APIConfigPatch{Enabled: &disabled}); err != nil { - t.Fatal(err) - } - } - svc := &service.Container{APIConfig: apiConfig} - - keys := defaultDiscoverSectionKeys(t.Context(), svc) - for _, key := range keys { - switch discoverSectionProvider(key) { - case "douban", "bangumi": - t.Fatalf("disabled provider key %q should not be selected by default; keys=%v", key, keys) - } - } - if len(keys) == 0 { - t.Fatal("default keys should keep enabled providers") - } -} - -func TestDefaultDiscoverSectionKeysIncludeLatestTMDbRails(t *testing.T) { - keys := defaultDiscoverSectionKeys(t.Context(), &service.Container{}) - keySet := map[string]struct{}{} - for _, key := range keys { - keySet[key] = struct{}{} - } - for _, key := range []string{"tmdb_latest_movie", "tmdb_latest_tv"} { - if _, ok := keySet[key]; !ok { - t.Fatalf("default discover keys should include %q: %v", key, keys) - } - } -} - -func TestFallbackDiscoverSectionKeyUsesTMDbForDoubanRails(t *testing.T) { - cases := map[string]string{ - "douban_hot_movie": "tmdb_popular_movie", - "douban_hot_tv": "tmdb_popular_tv", - "douban_top_movie": "tmdb_top_rated_movie", - "tmdb_latest_tv": "", - } - for key, want := range cases { - if got := fallbackDiscoverSectionKey(key); got != want { - t.Fatalf("fallbackDiscoverSectionKey(%q) = %q, want %q", key, got, want) - } - } -} diff --git a/internal/handler/download_client_handler.go b/internal/handler/download_client_handler.go deleted file mode 100644 index 7c49948..0000000 --- a/internal/handler/download_client_handler.go +++ /dev/null @@ -1,274 +0,0 @@ -// Package handler — 下载客户端管理 HTTP 端点。 -package handler - -import ( - "context" - "encoding/json" - "net/http" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// DownloadClientHandler 处理下载客户端的 CRUD 操作。 -type DownloadClientHandler struct { - svc *service.Container - log *zap.Logger -} - -// NewDownloadClientHandler 创建下载客户端处理器。 -func NewDownloadClientHandler(svc *service.Container, log *zap.Logger) *DownloadClientHandler { - return &DownloadClientHandler{svc: svc, log: log} -} - -// downloadClientCreateRequest 创建下载客户端请求体。 -type downloadClientCreateRequest struct { - Name string `json:"name" binding:"required"` - Type string `json:"type" binding:"required,oneof=qbittorrent transmission aria2"` - Host string `json:"host" binding:"required"` - Username string `json:"username"` - Password string `json:"password"` - IsDefault bool `json:"is_default"` - Extra map[string]string `json:"extra,omitempty"` -} - -// downloadClientUpdateRequest 更新下载客户端请求体。 -type downloadClientUpdateRequest struct { - Name string `json:"name"` - Type string `json:"type" binding:"omitempty,oneof=qbittorrent transmission aria2"` - Host string `json:"host"` - Username string `json:"username"` - Password string `json:"password"` - IsDefault *bool `json:"is_default"` - Enabled *bool `json:"enabled"` - Extra map[string]string `json:"extra,omitempty"` -} - -// Create 创建新的下载客户端。 -func (h *DownloadClientHandler) Create(c *gin.Context) { - var req downloadClientCreateRequest - if err := c.ShouldBindJSON(&req); err != nil { - Error(c, http.StatusBadRequest, ErrInvalidParams, err.Error()) - return - } - - ctx := c.Request.Context() - _ = h.svc.Repo.Setting.Set(ctx, "download_clients.managed", "true") - normalizedHost, err := service.NormalizeDownloadClientHost(req.Type, req.Host) - if err != nil { - Error(c, http.StatusBadRequest, ErrInvalidParams, err.Error()) - return - } - - // 加密密码 - password := req.Password - if password != "" && h.svc.Crypto != nil { - password = h.svc.Crypto.Encrypt(password) - } - - // 加密 Extra 配置 - extraStr := "" - if len(req.Extra) > 0 { - extraJSON, _ := json.Marshal(req.Extra) - extraStr = string(extraJSON) - if h.svc.Crypto != nil { - extraStr = h.svc.Crypto.Encrypt(extraStr) - } - } - - // 如果设为默认,先清除其他默认 - if req.IsDefault { - _ = h.svc.Repo.DownloadClient.ClearDefault(ctx) - } - - client := &model.DownloadClient{ - Name: req.Name, - Type: req.Type, - Host: normalizedHost, - Username: req.Username, - Password: password, - IsDefault: req.IsDefault, - Enabled: true, - Extra: extraStr, - } - - if err := h.svc.Repo.DownloadClient.Create(ctx, client); err != nil { - Error(c, http.StatusInternalServerError, ErrInternal, "创建失败: "+err.Error()) - return - } - - if err := h.svc.Downloads.ReloadConfig(ctx); err != nil { - h.log.Warn("failed to reload download clients after create", zap.Error(err)) - Error(c, http.StatusInternalServerError, ErrInternal, "客户端已保存,但运行时重载失败: "+err.Error()) - return - } - - Success(c, client) -} - -// List 返回所有下载客户端。 -func (h *DownloadClientHandler) List(c *gin.Context) { - ctx := c.Request.Context() - clients, err := h.svc.Repo.DownloadClient.List(ctx) - if err != nil { - Error(c, http.StatusInternalServerError, ErrInternal, "查询失败") - return - } - Success(c, clients) -} - -// Get 返回指定下载客户端详情。 -func (h *DownloadClientHandler) Get(c *gin.Context) { - id := c.Param("id") - ctx := c.Request.Context() - client, err := h.svc.Repo.DownloadClient.FindByID(ctx, id) - if err != nil || client == nil { - Error(c, http.StatusNotFound, ErrNotFound, "客户端不存在") - return - } - Success(c, client) -} - -// Update 更新下载客户端。 -func (h *DownloadClientHandler) Update(c *gin.Context) { - id := c.Param("id") - var req downloadClientUpdateRequest - if err := c.ShouldBindJSON(&req); err != nil { - Error(c, http.StatusBadRequest, ErrInvalidParams, err.Error()) - return - } - - ctx := c.Request.Context() - client, err := h.svc.Repo.DownloadClient.FindByID(ctx, id) - if err != nil || client == nil { - Error(c, http.StatusNotFound, ErrNotFound, "客户端不存在") - return - } - - if req.Name != "" { - client.Name = req.Name - } - if req.Type != "" { - client.Type = req.Type - } - if req.Host != "" { - clientType := client.Type - if req.Type != "" { - clientType = req.Type - } - normalizedHost, err := service.NormalizeDownloadClientHost(clientType, req.Host) - if err != nil { - Error(c, http.StatusBadRequest, ErrInvalidParams, err.Error()) - return - } - client.Host = normalizedHost - } - if req.Username != "" { - client.Username = req.Username - } - if req.Password != "" { - if h.svc.Crypto != nil { - client.Password = h.svc.Crypto.Encrypt(req.Password) - } else { - client.Password = req.Password - } - } - if req.IsDefault != nil && *req.IsDefault { - _ = h.svc.Repo.DownloadClient.ClearDefault(ctx) - client.IsDefault = *req.IsDefault - } - if req.Enabled != nil { - client.Enabled = *req.Enabled - } - if len(req.Extra) > 0 { - extraJSON, _ := json.Marshal(req.Extra) - extraStr := string(extraJSON) - if h.svc.Crypto != nil { - client.Extra = h.svc.Crypto.Encrypt(extraStr) - } else { - client.Extra = extraStr - } - } - normalizedHost, err := service.NormalizeDownloadClientHost(client.Type, client.Host) - if err != nil { - Error(c, http.StatusBadRequest, ErrInvalidParams, err.Error()) - return - } - client.Host = normalizedHost - - if err := h.svc.Repo.DownloadClient.Update(ctx, client); err != nil { - Error(c, http.StatusInternalServerError, ErrInternal, "更新失败") - return - } - clearLegacyQBitSettingsIfNoDefault(c.Request.Context(), h.svc) - - if err := h.svc.Downloads.ReloadConfig(ctx); err != nil { - h.log.Warn("failed to reload download clients after update", zap.Error(err)) - Error(c, http.StatusInternalServerError, ErrInternal, "客户端已更新,但运行时重载失败: "+err.Error()) - return - } - - Success(c, client) -} - -// Delete 删除下载客户端。 -func (h *DownloadClientHandler) Delete(c *gin.Context) { - id := c.Param("id") - ctx := c.Request.Context() - _ = h.svc.Repo.Setting.Set(ctx, "download_clients.managed", "true") - - _, err := h.svc.Repo.DownloadClient.FindByID(ctx, id) - if err != nil { - Error(c, http.StatusNotFound, ErrNotFound, "客户端不存在") - return - } - - if delErr := h.svc.Repo.DownloadClient.Delete(ctx, id); delErr != nil { - Error(c, http.StatusInternalServerError, ErrInternal, "删除失败") - return - } - - clearLegacyQBitSettingsIfNoDefault(c.Request.Context(), h.svc) - if err := h.svc.Downloads.ReloadConfig(ctx); err != nil { - h.log.Warn("failed to reload download clients after delete", zap.Error(err)) - Error(c, http.StatusInternalServerError, ErrInternal, "客户端已删除,但运行时重载失败: "+err.Error()) - return - } - - SuccessWithMessage(c, "已删除", nil) -} - -func clearLegacyQBitSettingsIfNoDefault(ctx context.Context, svc *service.Container) { - if svc == nil || svc.Repo == nil || svc.Repo.DownloadClient == nil || svc.Repo.Setting == nil { - return - } - defaultClient, err := svc.Repo.DownloadClient.FindDefault(ctx) - if err != nil || defaultClient != nil { - return - } - _ = svc.Repo.Setting.Set(ctx, "qbittorrent.url", "") - _ = svc.Repo.Setting.Set(ctx, "qbittorrent.username", "") - _ = svc.Repo.Setting.Set(ctx, "qbittorrent.password", "") -} - -// Test 测试下载客户端连接。 -func (h *DownloadClientHandler) Test(c *gin.Context) { - id := c.Param("id") - ctx := c.Request.Context() - - client, err := h.svc.Repo.DownloadClient.FindByID(ctx, id) - if err != nil || client == nil { - Error(c, http.StatusNotFound, ErrNotFound, "客户端不存在") - return - } - - if err := h.svc.DownloadMgr.TestConnection(ctx, client); err != nil { - Error(c, http.StatusBadRequest, ErrExternal, "连接测试失败: "+err.Error()) - return - } - - SuccessWithMessage(c, "连接成功", nil) -} diff --git a/internal/handler/download_clients.go b/internal/handler/download_clients.go deleted file mode 100644 index 308eeac..0000000 --- a/internal/handler/download_clients.go +++ /dev/null @@ -1,108 +0,0 @@ -// Package handler — download client (qBittorrent / Aria2 / Transmission) -// configuration endpoints. -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func listDownloadClientsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - rows, err := svc.DownloadClients.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if rows == nil { - rows = []model.DownloadClient{} - } - c.JSON(http.StatusOK, rows) - } -} - -func createDownloadClientHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var in service.DownloadClientInput - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - row, err := svc.DownloadClients.Create(c.Request.Context(), in) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - // 让真正发起下载的 DownloadService 立刻读到新的 qb 配置, - // 避免保存后还要重启进程才能生效。 - if err := svc.Downloads.ReloadConfig(c.Request.Context()); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "client saved but runtime reload failed: " + err.Error()}) - return - } - c.JSON(http.StatusCreated, row) - } -} - -func updateDownloadClientHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var in service.DownloadClientInput - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - row, err := svc.DownloadClients.Update(c.Request.Context(), c.Param("id"), in) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if err := svc.Downloads.ReloadConfig(c.Request.Context()); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "client updated but runtime reload failed: " + err.Error()}) - return - } - c.JSON(http.StatusOK, row) - } -} - -func deleteDownloadClientHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.DownloadClients.Delete(c.Request.Context(), c.Param("id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if err := svc.Downloads.ReloadConfig(c.Request.Context()); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "client deleted but runtime reload failed: " + err.Error()}) - return - } - c.Status(http.StatusNoContent) - } -} - -func testDownloadClientHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.DownloadClients.Test(c.Request.Context(), c.Param("id")); err != nil { - c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) - } -} - -func aria2StatsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - clientID := c.Query("client_id") - if clientID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "client_id required"}) - return - } - out, err := svc.DownloadClients.Aria2GlobalStats(c.Request.Context(), clientID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, out) - } -} diff --git a/internal/handler/downloads.go b/internal/handler/downloads.go deleted file mode 100644 index 25e3917..0000000 --- a/internal/handler/downloads.go +++ /dev/null @@ -1,258 +0,0 @@ -// Package handler — download manager endpoints. -package handler - -import ( - "context" - "errors" - "net/http" - "net/url" - "strings" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/middleware" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -type addDownloadReq struct { - URL string `json:"url" binding:"required"` - SavePath string `json:"save_path"` - Title string `json:"title"` - PosterURL string `json:"poster_url"` - BackdropURL string `json:"backdrop_url"` - Overview string `json:"overview"` - MediaType string `json:"media_type"` - MediaCategory string `json:"media_category"` - SourceCategory string `json:"source_category"` -} - -// resolvePTDownloadURL 把站点搜索结果里的"详情/获取签名"URL 解析成 qb 能直接 -// 拉到 .torrent 文件的真实下载 URL。 -// -// 链路: -// -// 1. 拿 URL 的 host,到 sites 表里找 base_url 同源的站点。 -// 2. 如果站点的 type 是已知 PT 框架(mteam/nexusphp/unit3d/...), -// 就用对应适配器的 GetDownloadURL,传入从 URL 里 parse 出来的 id。 -// 3. 任一步失败都直接返回原 URL,让 qb 自己去拉(保持向后兼容)。 -// -// 这一步存在的意义:M-Team 等站点的搜索结果里 download_url 是 -// /api/torrent/genDlToken?id=xxx,需要带 x-api-key 才能调用,qb 自己 -// 是没法识别这种 PT 专属端点的。 -func resolvePTDownloadURL(ctx context.Context, svc *service.Container, raw string, log *zap.Logger) string { - if raw == "" || svc == nil || svc.Site == nil { - return raw - } - resolved := svc.Site.ResolveDownloadURL(ctx, raw) - if resolved == raw { - return raw - } - log.Info("resolved PT download URL", - zap.String("from", redactDownloadURL(raw)), - zap.String("to", redactDownloadURL(resolved))) - return resolved -} - -func addDownloadHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req addDownloadReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - uid, _ := c.Get(middleware.CtxUserID) - // 把站点搜索 URL 转换成真实可下载 URL(M-Team 走 genDlToken 等)。 - realURL := resolvePTDownloadURL(c.Request.Context(), svc, req.URL, svc.Log) - fallbackTitle := req.Title - if strings.TrimSpace(fallbackTitle) == "" { - fallbackTitle = realURL - } - meta := enrichDownloadTaskMeta(c.Request.Context(), svc, service.DownloadTaskMeta{ - Title: req.Title, - PosterURL: req.PosterURL, - BackdropURL: req.BackdropURL, - Overview: req.Overview, - MediaType: req.MediaType, - MediaCategory: req.MediaCategory, - SourceCategory: req.SourceCategory, - }, fallbackTitle, req.MediaType) - t, err := svc.Downloads.AddDownloadWithMeta(c.Request.Context(), uid.(string), realURL, req.SavePath, meta) - if err != nil { - if errors.Is(err, service.ErrMediaAlreadyInLibrary) { - c.JSON(http.StatusConflict, gin.H{"error": "media already exists in library"}) - return - } - if errors.Is(err, service.ErrDownloadAlreadyExists) { - c.JSON(http.StatusOK, t) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - svc.Audit.Record(c.Request.Context(), uid.(string), "download.add", redactDownloadURL(realURL), c.ClientIP(), "") - c.JSON(http.StatusCreated, t) - } -} - -func listDownloadsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - rows, live, err := svc.Downloads.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - rows = visibleDownloadRows(c, rows) - enrichAndPersistDownloadRows(c.Request.Context(), svc, rows) - if !isAdminRequest(c) { - live = visibleLiveTorrents(rows, live) - } - taskViews, torrentViews := service.DownloadViews(rows, live) - enrichDownloadTorrentViews(c.Request.Context(), svc, torrentViews) - c.JSON(http.StatusOK, gin.H{ - "tasks": taskViews, - "torrents": torrentViews, - }) - } -} - -func visibleDownloadRows(c *gin.Context, rows []model.DownloadTask) []model.DownloadTask { - if isAdminRequest(c) { - return rows - } - uid, _ := c.Get(middleware.CtxUserID) - userID, _ := uid.(string) - filtered := make([]model.DownloadTask, 0, len(rows)) - for _, row := range rows { - if row.UserID == userID { - filtered = append(filtered, row) - } - } - return filtered -} - -func visibleLiveTorrents(rows []model.DownloadTask, live []service.QBitTorrent) []service.QBitTorrent { - if len(rows) == 0 || len(live) == 0 { - return nil - } - filtered := make([]service.QBitTorrent, 0, len(live)) - for _, torrent := range live { - matchedByID := false - for _, row := range rows { - if strings.TrimSpace(row.ExternalID) != "" && strings.EqualFold(row.ExternalID, torrent.Hash) && - (strings.TrimSpace(row.DownloadClientID) == "" || row.DownloadClientID == torrent.ClientID) { - filtered = append(filtered, torrent) - matchedByID = true - break - } - } - if matchedByID { - continue - } - torrentTitle := normalizeTitle(torrent.Name) - if torrentTitle == "" { - continue - } - for _, row := range rows { - if strings.TrimSpace(row.DownloadClientID) != "" && strings.TrimSpace(torrent.ClientID) != "" && row.DownloadClientID != torrent.ClientID { - continue - } - rowTitle := normalizeTitle(row.Title) - if rowTitle == "" { - continue - } - if strings.Contains(torrentTitle, rowTitle) || strings.Contains(rowTitle, torrentTitle) { - filtered = append(filtered, torrent) - break - } - } - } - return filtered -} - -func isAdminRequest(c *gin.Context) bool { - role, _ := c.Get(middleware.CtxUserRole) - return role == "admin" -} - -func normalizeTitle(title string) string { - title = strings.ToLower(title) - var b strings.Builder - for _, r := range title { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r > 127 { - b.WriteRune(r) - } - } - return b.String() -} - -func redactDownloadURL(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if strings.HasPrefix(strings.ToLower(raw), "magnet:") { - return "magnet:?xt=***" - } - u, err := url.Parse(raw) - if err != nil || u.Host == "" { - return "[redacted-download-url]" - } - u.RawQuery = "" - u.Fragment = "" - base := u.String() - if base == "" { - return u.Scheme + "://" + u.Host - } - return base -} - -func deleteDownloadHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - hash := c.Param("hash") - withFiles := c.Query("delete_files") == "true" - if err := svc.Downloads.Delete(c.Request.Context(), hash, withFiles, c.Query("client_id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.Status(http.StatusNoContent) - } -} - -type relocateDownloadReq struct { - Hash string `json:"hash" binding:"required"` - Location string `json:"location" binding:"required"` - ClientID string `json:"client_id"` -} - -// relocateDownloadHandler moves a torrent's data to a new directory while -// keeping it seeding (qBittorrent setLocation). -func relocateDownloadHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req relocateDownloadReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := svc.Downloads.RelocateTorrent(c.Request.Context(), req.Hash, req.Location, req.ClientID); err != nil { - status := http.StatusInternalServerError - if errors.Is(err, service.ErrDownloadOperationUnsupported) { - status = http.StatusBadRequest - } - c.JSON(status, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"hash": strings.TrimSpace(req.Hash), "location": strings.TrimSpace(req.Location)}) - } -} - -func reloadDownloadConfigHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.Downloads.ReloadConfig(c.Request.Context()); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.Status(http.StatusNoContent) - } -} diff --git a/internal/handler/downloads_extra.go b/internal/handler/downloads_extra.go deleted file mode 100644 index c3c33c6..0000000 --- a/internal/handler/downloads_extra.go +++ /dev/null @@ -1,112 +0,0 @@ -// Package handler — pause/resume/organize on individual download tasks -// and a thin sync-trigger surface used by the Vue UI's auto-sync toggle. -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func downloadPauseHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.Downloads.PauseDownloadTask(c.Request.Context(), c.Param("id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) - } -} - -func downloadResumeHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.Downloads.ResumeDownloadTask(c.Request.Context(), c.Param("id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) - } -} - -// downloadOrganizeOneHandler runs the file organizer for one task. -// It looks up the task, then delegates to OrganizerService.OrganizePath(). -func downloadOrganizeOneHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var t model.DownloadTask - if err := svc.Repo.DB.WithContext(c.Request.Context()). - Where("id = ?", c.Param("id")).First(&t).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) - return - } - if t.SavePath == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "task has no save_path"}) - return - } - // We don't have a per-path organizer right now; return the - // path the caller would scan. The general OrganizeAll endpoint - // (below) is the supported workflow. - c.JSON(http.StatusOK, gin.H{ - "ok": true, - "path": t.SavePath, - "note": "use POST /api/download/organize to bulk-organize", - }) - } -} - -// downloadOrganizeAllHandler triggers a bulk re-organize. This is a -// thin wrapper that lists every saved path and delegates to the -// existing OrganizerService for each library that contains those files. -func downloadOrganizeAllHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - // Walk each library through the unified organize pipeline so rename, - // scan, scrape and task reporting stay identical to manual organize. - libs, err := svc.Repo.Library.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - results := make([]any, 0, len(libs)) - for _, l := range libs { - resp, err := organizePipeline(svc).Run(c.Request.Context(), service.OrganizePipelineRequest{ - Scope: service.OrganizeScopeLibrary, - Trigger: service.OrganizeTriggerManual, - TaskName: "批量整理媒体库:" + l.Name, - LibraryID: l.ID, - }) - if err != nil { - results = append(results, gin.H{"library": l.Name, "error": err.Error()}) - continue - } - results = append(results, gin.H{"library": l.Name, "result": resp.Result}) - } - c.JSON(http.StatusOK, gin.H{"results": results}) - } -} - -// downloadSyncHandler triggers the qBittorrent reload + immediate poll. -func downloadSyncHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.Downloads.ReloadConfig(c.Request.Context()); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) - } -} - -// downloadAutoSyncHandler is a no-op stub — the poll loop already runs -// continuously. Returning 200 keeps the Vue UI's toggle happy. -func downloadAutoSyncHandler(_ *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"ok": true, "auto_sync": true}) - } -} - -// downloadTasksAliasHandler is the alias used by the Vue UI; it -// returns the same shape as listDownloadsHandler but at /download/tasks. -func downloadTasksAliasHandler(svc *service.Container) gin.HandlerFunc { - return listDownloadsHandler(svc) -} diff --git a/internal/handler/duplicate.go b/internal/handler/duplicate.go deleted file mode 100644 index 2c9e3ca..0000000 --- a/internal/handler/duplicate.go +++ /dev/null @@ -1,46 +0,0 @@ -// Package handler — duplicate-file finder. -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func listDuplicatesHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - libraryID := c.Query("library_id") - report, err := svc.Duplicate.Current(c.Request.Context(), libraryID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, report) - } -} - -func detectDuplicatesHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - libraryID := c.Query("library_id") - report, err := svc.Duplicate.Detect(c.Request.Context(), libraryID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, report) - } -} - -func unmarkDuplicatesHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - libraryID := c.Query("library_id") - n, err := svc.Duplicate.Unmark(c.Request.Context(), libraryID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"unmarked": n}) - } -} diff --git a/internal/handler/emby_images.go b/internal/handler/emby_images.go index 49e1915..799c9a0 100644 --- a/internal/handler/emby_images.go +++ b/internal/handler/emby_images.go @@ -40,11 +40,6 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc { embyServePlaceholderImage(c) return } - if typ, ref, ok := service.ParseCloudArtworkURL(raw); ok { - c.Request = req - serveCloudResolvedLink(svc, c, typ, ref) - return - } if svc.ImageProxy == nil { embyServePlaceholderImage(c) return diff --git a/internal/handler/emby_items_test.go b/internal/handler/emby_items_test.go index 94afd63..c296c0a 100644 --- a/internal/handler/emby_items_test.go +++ b/internal/handler/emby_items_test.go @@ -1,7 +1,6 @@ package handler import ( - "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -19,7 +18,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/repository" "github.com/ShukeBta/MediaStationGo/internal/service" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" ) func TestEmbyItemImageServesWithoutAPIAuth(t *testing.T) { @@ -92,63 +90,6 @@ func TestEmbyItemImageServesWithoutAPIAuth(t *testing.T) { } } -func TestEmbyItemImageServesCachedCloudArtworkWithoutResolve(t *testing.T) { - gin.SetMode(gin.TestMode) - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(handlerTestJPEG) - })) - defer upstream.Close() - - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("open db: %v", err) - } - if err := db.AutoMigrate(&model.Media{}); err != nil { - t.Fatalf("migrate: %v", err) - } - - cfg := &config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}} - imageProxy := service.NewImageProxy(cfg, zap.NewNop()) - ref := "/Movies/Cloud Movie/poster.jpg" - if err := imageProxy.PrefetchCloudResolved(t.Context(), "openlist:"+ref, &cloud.DirectLink{URL: upstream.URL + "/poster.jpg"}); err != nil { - t.Fatalf("prefetch cloud poster: %v", err) - } - repos := repository.New(db) - if err := db.Create(&model.Media{ - Base: model.Base{ID: "cloud-media-1"}, - Title: "Cloud Poster Test", - Path: "cloud://openlist/Movies/Cloud Movie/movie.mkv", - PosterURL: service.CloudArtworkURL("openlist", ref), - }).Error; err != nil { - t.Fatalf("create media: %v", err) - } - - router := gin.New() - registerEmbyRoutes(router, "test-secret", &service.Container{ - Repo: repos, - Emby: service.NewEmbyService(cfg, zap.NewNop(), repos), - ImageProxy: imageProxy, - }) - - req := httptest.NewRequest(http.MethodGet, "/Items/cloud-media-1/Images/Primary", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) - } - if got := w.Body.Bytes(); !bytes.Equal(got, handlerTestJPEG) { - t.Fatalf("body = %q, want cached cloud poster", got) - } - if location := w.Header().Get("Location"); location != "" { - t.Fatalf("expected direct cached image response, got redirect to %q", location) - } - if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "max-age=2592000") { - t.Fatalf("image Cache-Control = %q, want long browser cache", got) - } -} - func TestEmbyMissingItemImageReturnsTransparentPlaceholder(t *testing.T) { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) diff --git a/internal/handler/emby_playback_cloud_routes_test.go b/internal/handler/emby_playback_cloud_routes_test.go deleted file mode 100644 index b8baa4c..0000000 --- a/internal/handler/emby_playback_cloud_routes_test.go +++ /dev/null @@ -1,223 +0,0 @@ -package handler - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "github.com/glebarez/sqlite" - "go.uber.org/zap" - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/middleware" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func TestEmbyVideoStreamUsesSTRMWhenRedirectProxyDisabled(t *testing.T) { - gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("open db: %v", err) - } - if err := db.AutoMigrate(model.AllModels()...); err != nil { - t.Fatalf("migrate: %v", err) - } - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), service.CloudPlaybackModeSettingKey, service.CloudPlaybackModeSTRM); err != nil { - t.Fatalf("set cloud playback mode: %v", err) - } - if err := repos.Setting.Set(t.Context(), service.CloudPlaybackSTRMEnabledSettingKey, "true"); err != nil { - t.Fatalf("enable strm playback: %v", err) - } - if err := repos.Setting.Set(t.Context(), service.CloudPlaybackRedirectEnabledSettingKey, "false"); err != nil { - t.Fatalf("disable redirect playback: %v", err) - } - if err := repos.User.Create(t.Context(), &model.User{ - Base: model.Base{ID: "user-1"}, - Username: "tester", - PasswordHash: "x", - Role: "admin", - Tier: "plus", - IsActive: true, - }); err != nil { - t.Fatalf("create user: %v", err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatalf("create library: %v", err) - } - if err := db.Create(&model.Media{ - Base: model.Base{ID: "cloud-1"}, - LibraryID: lib.ID, - Title: "Cloud Movie", - Path: "cloud://openlist/Movies/Movie.mkv", - STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv", - Container: "mkv", - }).Error; err != nil { - t.Fatalf("create media: %v", err) - } - - const secret = "test-secret" - router := gin.New() - cfg := &config.Config{Secrets: config.SecretsConfig{JWTSecret: secret}} - registerEmbyRoutes(router, secret, &service.Container{ - Repo: repos, - Emby: service.NewEmbyService(cfg, zap.NewNop(), repos), - Stream: service.NewStreamService(cfg, zap.NewNop(), repos, nil), - }) - - token := signedTestToken(t, secret) - req := httptest.NewRequest(http.MethodGet, "/videos/cloud-1/stream?api_key="+token, nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusFound { - t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) - } - loc := w.Header().Get("Location") - if !strings.Contains(loc, "/api/stream/cloud-1") || !strings.Contains(loc, "api_key=") { - t.Fatalf("STRM mode should redirect /Videos fallback to tokenized /api/stream, got %q", loc) - } - if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") { - t.Fatalf("STRM fallback redirect Cache-Control = %q, want no-store", got) - } - if strings.Contains(loc, "/api/cloud/play/") { - t.Fatalf("STRM mode should not expose cloud play directly from /Videos fallback: %q", loc) - } -} - -func TestEmbyVideoStreamIssuesTokenForSessionFallbackSTRMRedirect(t *testing.T) { - gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("open db: %v", err) - } - if err := db.AutoMigrate(model.AllModels()...); err != nil { - t.Fatalf("migrate: %v", err) - } - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), service.CloudPlaybackModeSettingKey, service.CloudPlaybackModeSTRM); err != nil { - t.Fatalf("set cloud playback mode: %v", err) - } - if err := repos.Setting.Set(t.Context(), service.CloudPlaybackSTRMEnabledSettingKey, "true"); err != nil { - t.Fatalf("enable strm playback: %v", err) - } - if err := repos.Setting.Set(t.Context(), service.CloudPlaybackRedirectEnabledSettingKey, "false"); err != nil { - t.Fatalf("disable redirect playback: %v", err) - } - user := model.User{ - Base: model.Base{ID: "user-1"}, - Username: "tester", - PasswordHash: "x", - Role: "admin", - Tier: "plus", - IsActive: true, - } - if err := repos.User.Create(t.Context(), &user); err != nil { - t.Fatalf("create user: %v", err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatalf("create library: %v", err) - } - if err := db.Create(&model.Media{ - Base: model.Base{ID: "cloud-1"}, - LibraryID: lib.ID, - Title: "Cloud Movie", - Path: "cloud://openlist/Movies/Movie.mkv", - STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv", - Container: "mkv", - }).Error; err != nil { - t.Fatalf("create media: %v", err) - } - - const secret = "test-secret" - cfg := &config.Config{Secrets: config.SecretsConfig{JWTSecret: secret}} - svc := &service.Container{ - Repo: repos, - Auth: service.NewAuthService(cfg, zap.NewNop(), repos, nil, nil), - Emby: service.NewEmbyService(cfg, zap.NewNop(), repos), - Stream: service.NewStreamService(cfg, zap.NewNop(), repos, nil), - } - router := gin.New() - router.GET("/videos/:id/stream", func(c *gin.Context) { - c.Set(middleware.CtxUserID, user.ID) - c.Set(middleware.CtxUserRole, user.Role) - embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)(c) - }) - - req := httptest.NewRequest(http.MethodGet, "/videos/cloud-1/stream", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusFound { - t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) - } - loc := w.Header().Get("Location") - if !strings.Contains(loc, "/api/stream/cloud-1") || !strings.Contains(loc, "api_key=") { - t.Fatalf("session fallback redirect should include api_key for /api/stream, got %q", loc) - } -} - -func TestEmbyVideoStreamRedirectKeepsMediaBrowserAuthorizationToken(t *testing.T) { - gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatalf("open db: %v", err) - } - if err := db.AutoMigrate(model.AllModels()...); err != nil { - t.Fatalf("migrate: %v", err) - } - repos := repository.New(db) - if err := repos.User.Create(t.Context(), &model.User{ - Base: model.Base{ID: "user-1"}, - Username: "tester", - PasswordHash: "x", - Role: "admin", - Tier: "plus", - IsActive: true, - }); err != nil { - t.Fatalf("create user: %v", err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatalf("create library: %v", err) - } - if err := db.Create(&model.Media{ - Base: model.Base{ID: "cloud-1"}, - LibraryID: lib.ID, - Title: "Cloud Movie", - Path: "cloud://openlist/Movies/Movie.mkv", - STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv", - Container: "mkv", - }).Error; err != nil { - t.Fatalf("create media: %v", err) - } - - const secret = "test-secret" - router := gin.New() - registerEmbyRoutes(router, secret, &service.Container{ - Repo: repos, - Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos), - Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil), - }) - - token := signedTestToken(t, secret) - req := httptest.NewRequest(http.MethodGet, "/videos/cloud-1/stream", nil) - req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="PC", Token="`+token+`"`) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusFound { - t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) - } - loc := w.Header().Get("Location") - if !strings.Contains(loc, "/api/cloud/play/openlist?") || !strings.Contains(loc, "token=") { - t.Fatalf("redirect Location should target tokenized cloud play endpoint, got %q", loc) - } -} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 57a1b1f..77ddbed 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -20,9 +20,6 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C api.GET("/version", versionInfo) api.GET("/public/ui-config", publicUIConfigHandler(svc)) - // Telegram Bot webhook — called by Telegram servers, no auth. - api.POST("/telegram/webhook", telegramWebhookHandler(svc)) - registerPublicAuthRoutes(api, svc, log) registerAuthenticatedRoutes(api, cfg, svc) @@ -90,42 +87,6 @@ func testApiConfigHandler(svc *service.Container) gin.HandlerFunc { return h.TestApiConfig } -// ─── Download Client Handler 包装 ───────────────────────────────────────────── - -func getDownloadClientHandler(svc *service.Container) gin.HandlerFunc { - h := NewDownloadClientHandler(svc, svc.Log) - return h.Get -} - -// ─── Notify Channel Handler 包装 ────────────────────────────────────────────── - -func getNotifyChannelTypesHandler(svc *service.Container) gin.HandlerFunc { - h := NewNotifyHandler(svc, svc.Log) - return h.GetTypes -} - -func getNotifyChannelHandler(svc *service.Container) gin.HandlerFunc { - h := NewNotifyHandler(svc, svc.Log) - return h.Get -} - -// ─── Scheduler Handler 包装 ────────────────────────────────────────────────── - -func schedulerListTasksHandler(svc *service.Container) gin.HandlerFunc { - h := NewSchedulerHandler(svc, svc.Log) - return h.ListTasks -} - -func schedulerRunTaskHandler(svc *service.Container) gin.HandlerFunc { - h := NewSchedulerHandler(svc, svc.Log) - return h.RunTask -} - -func schedulerGetStatusHandler(svc *service.Container) gin.HandlerFunc { - h := NewSchedulerHandler(svc, svc.Log) - return h.GetStatus -} - // ─── SSE Handler ────────────────────────────────────────────────────────────── func sseHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/license.go b/internal/handler/license.go deleted file mode 100644 index 46a646a..0000000 --- a/internal/handler/license.go +++ /dev/null @@ -1,167 +0,0 @@ -package handler - -import ( - "net/http" - "strings" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -const ( - licenseServerURLSetting = "license.server_url" - licenseHMACSecretSetting = "license.hmac_secret" // #nosec G101 -- setting key name, not the HMAC secret value. - licenseDeviceIDSetting = "license.device_id" - licenseDeviceNameSetting = "license.device_name" -) - -type licenseActivateReq struct { - Key string `json:"key" binding:"required"` - // DeviceID is accepted for wire compatibility with older web clients but is - // intentionally ignored. Licensing binds to this MediaStationGo server - // instance, not to the browser that opened the admin page. - DeviceID string `json:"device_id"` - DeviceName string `json:"device_name"` -} - -type licenseServerSignedResp struct { - Valid bool `json:"valid"` - LicenseType string `json:"license_type"` - ExpiryDate *string `json:"expiry_date"` - MaxDevices int `json:"max_devices"` - MaxUsers *int `json:"max_users"` - DaysRemaining *int `json:"days_remaining"` - NextHeartbeat string `json:"next_heartbeat"` - Signature string `json:"signature"` - SignatureAlg string `json:"signature_alg"` - LegacySignature bool `json:"-"` -} - -type licenseServerStatusResp struct { - Valid bool `json:"valid"` - LicenseType *string `json:"license_type"` - ExpiryDate *string `json:"expiry_date"` - MaxDevices int `json:"max_devices"` - MaxUsers *int `json:"max_users"` - UnlimitedUsers bool `json:"unlimited_users"` - DaysRemaining *int `json:"days_remaining"` - DeviceName string `json:"device_name"` - IsActive bool `json:"is_active"` - HeartbeatRequested bool `json:"heartbeat_requested"` -} - -func licenseActivateHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req licenseActivateReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - client, err := newLicenseClient(c.Request.Context(), svc) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - deviceID, err := ensureLicenseDeviceID(c.Request.Context(), svc, "") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - deviceName := strings.TrimSpace(req.DeviceName) - if deviceName == "" { - deviceName = defaultLicenseDeviceName() - } - _ = svc.Repo.Setting.Set(c.Request.Context(), licenseDeviceNameSetting, deviceName) - - payload := map[string]any{ - "key": strings.TrimSpace(req.Key), - "fingerprint": deviceID, - "device_name": deviceName, - "instance_id": deviceID, - } - var upstream licenseServerSignedResp - if err := client.post(c.Request.Context(), "/api/v1/activate", payload, &upstream); err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - if err := client.verifySigned(&upstream); err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - state := licenseStateFromSigned(upstream, deviceID, deviceName) - state.LicenseKey = strings.TrimSpace(req.Key) - if err := persistLicenseState(c.Request.Context(), svc, state); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, licenseActivationView(state)) - } -} - -func licenseStatusHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - state, _ := loadLicenseState(c.Request.Context(), svc) - client, err := newLicenseClient(c.Request.Context(), svc) - hasLicenseKey := strings.TrimSpace(state.LicenseKey) != "" - if err == nil && hasLicenseKey { - deviceID, idErr := ensureLicenseDeviceID(c.Request.Context(), svc, state.DeviceID) - if idErr == nil { - deviceName, _ := svc.Repo.Setting.Get(c.Request.Context(), licenseDeviceNameSetting) - if strings.TrimSpace(deviceName) == "" { - deviceName = defaultLicenseDeviceName() - _ = svc.Repo.Setting.Set(c.Request.Context(), licenseDeviceNameSetting, deviceName) - } - var signed licenseServerSignedResp - if heartbeatErr := client.post(c.Request.Context(), "/api/v1/heartbeat", licenseHeartbeatPayload(state, deviceID, deviceName), &signed); heartbeatErr == nil && client.verifySigned(&signed) == nil { - nextState := licenseStateFromSigned(signed, deviceID, deviceName) - nextState.LicenseKey = state.LicenseKey - state = nextState - if refreshed, ok, _, refreshErr := refreshLicenseServerStatus(c.Request.Context(), client, state, deviceID); refreshErr == nil && ok { - refreshed.LicenseKey = state.LicenseKey - state = refreshed - } - _ = persistLicenseState(c.Request.Context(), svc, state) - } else { - if refreshed, ok, _, getErr := refreshLicenseServerStatus(c.Request.Context(), client, state, deviceID); getErr == nil && ok { - state = refreshed - _ = persistLicenseState(c.Request.Context(), svc, state) - } else if getErr == nil { - state.Valid = false - _ = persistLicenseState(c.Request.Context(), svc, state) - } - } - } - } else if err == nil && state.Valid { - deviceID, idErr := ensureLicenseDeviceID(c.Request.Context(), svc, state.DeviceID) - if idErr == nil { - if refreshed, ok, _, getErr := refreshLicenseServerStatus(c.Request.Context(), client, state, deviceID); getErr == nil && ok { - state = refreshed - _ = persistLicenseState(c.Request.Context(), svc, state) - } else if getErr == nil { - state.Valid = false - _ = persistLicenseState(c.Request.Context(), svc, state) - } - } - } - active := state.Valid && !licenseStateExpired(state.ExpiryDate) - c.JSON(http.StatusOK, gin.H{ - "active": active, - "message": licenseStatusMessage(active, err), - "max_users": licenseStatusMaxUsers(state), - "unlimited_users": state.Valid && !licenseStateExpired(state.ExpiryDate) && state.UnlimitedUsers, - "activation": licenseActivationView(state), - }) - } -} - -func licenseHeartbeatHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - state, err := sendLicenseHeartbeat(c.Request.Context(), svc) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, licenseActivationView(state)) - } -} diff --git a/internal/handler/license_client.go b/internal/handler/license_client.go deleted file mode 100644 index 64ade37..0000000 --- a/internal/handler/license_client.go +++ /dev/null @@ -1,229 +0,0 @@ -package handler - -import ( - "bytes" - "context" - "crypto/ed25519" - "crypto/hmac" - "crypto/sha256" - "crypto/x509" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -type licenseClient struct { - baseURL string - hmacSecret string - ed25519PublicKey ed25519.PublicKey - httpClient *http.Client -} - -func newLicenseClient(ctx context.Context, svc *service.Container) (*licenseClient, error) { - baseURL, _ := svc.Repo.Setting.Get(ctx, licenseServerURLSetting) - if strings.TrimSpace(baseURL) == "" { - baseURL = svc.Cfg.License.ServerURL - } - secret, _ := svc.Repo.Setting.Get(ctx, licenseHMACSecretSetting) - if strings.TrimSpace(secret) == "" { - secret = svc.Cfg.License.HMACSecret - } - publicKeyRaw, _ := svc.Repo.Setting.Get(ctx, "license.public_key") - if strings.TrimSpace(publicKeyRaw) == "" { - publicKeyRaw = svc.Cfg.License.PublicKey - } - baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") - if baseURL == "" { - return nil, errors.New("license server url not configured") - } - secret = strings.TrimSpace(secret) - publicKey, err := parseLicenseEd25519PublicKey(publicKeyRaw) - if err != nil { - return nil, err - } - if secret == "" && len(publicKey) == 0 { - return nil, errors.New("license public key or hmac secret not configured") - } - return &licenseClient{ - baseURL: baseURL, - hmacSecret: secret, - ed25519PublicKey: publicKey, - httpClient: &http.Client{Timeout: 15 * time.Second}, - }, nil -} - -func (c *licenseClient) post(ctx context.Context, path string, payload any, out any) error { - body, err := json.Marshal(payload) - if err != nil { - return err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - return c.do(req, out) -} - -func (c *licenseClient) get(ctx context.Context, path string, out any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) - if err != nil { - return err - } - return c.do(req, out) -} - -func (c *licenseClient) do(req *http.Request, out any) error { - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - var er struct { - Error string `json:"error"` - Message string `json:"message"` - } - _ = json.Unmarshal(data, &er) - if er.Message != "" { - return fmt.Errorf("license server: %s", er.Message) - } - if er.Error != "" { - return fmt.Errorf("license server: %s", er.Error) - } - return fmt.Errorf("license server http %d", resp.StatusCode) - } - return json.Unmarshal(data, out) -} - -func (c *licenseClient) verifySigned(resp *licenseServerSignedResp) error { - if strings.TrimSpace(resp.Signature) == "" { - return errors.New("license server signature missing") - } - switch strings.ToLower(strings.TrimSpace(resp.SignatureAlg)) { - case "ed25519": - return c.verifyEd25519Signed(*resp) - case "", "hmac", "hmac-sha256": - return c.verifyHMACSigned(resp) - default: - return fmt.Errorf("unsupported license signature algorithm %q", resp.SignatureAlg) - } -} - -func (c *licenseClient) verifyEd25519Signed(resp licenseServerSignedResp) error { - if len(c.ed25519PublicKey) != ed25519.PublicKeySize { - return errors.New("license Ed25519 public key not configured") - } - signature, err := base64.StdEncoding.DecodeString(strings.TrimSpace(resp.Signature)) - if err != nil { - return err - } - payload, err := json.Marshal(licenseSignedPayload(resp)) - if err != nil { - return err - } - if !ed25519.Verify(c.ed25519PublicKey, payload, signature) { - return errors.New("license server signature verification failed") - } - return nil -} - -func (c *licenseClient) verifyHMACSigned(resp *licenseServerSignedResp) error { - if c.hmacSecret == "" { - return errors.New("license hmac secret not configured") - } - payload, err := json.Marshal(licenseSignedPayload(*resp)) - if err != nil { - return err - } - mac := hmac.New(sha256.New, []byte(c.hmacSecret)) - _, _ = mac.Write(payload) - expected := hex.EncodeToString(mac.Sum(nil)) - if !hmac.Equal([]byte(expected), []byte(resp.Signature)) { - if c.verifyLegacySigned(*resp) { - resp.LegacySignature = true - return nil - } - return errors.New("license server signature verification failed") - } - return nil -} - -func (c *licenseClient) verifyLegacySigned(resp licenseServerSignedResp) bool { - unsigned := struct { - Valid bool `json:"valid"` - LicenseType string `json:"license_type"` - ExpiryDate *string `json:"expiry_date"` - MaxDevices int `json:"max_devices"` - DaysRemaining *int `json:"days_remaining"` - NextHeartbeat string `json:"next_heartbeat"` - }{ - Valid: resp.Valid, - LicenseType: resp.LicenseType, - ExpiryDate: resp.ExpiryDate, - MaxDevices: resp.MaxDevices, - DaysRemaining: resp.DaysRemaining, - NextHeartbeat: resp.NextHeartbeat, - } - payload, err := json.Marshal(unsigned) - if err != nil { - return false - } - mac := hmac.New(sha256.New, []byte(c.hmacSecret)) - _, _ = mac.Write(payload) - expected := hex.EncodeToString(mac.Sum(nil)) - return hmac.Equal([]byte(expected), []byte(resp.Signature)) -} - -func parseLicenseEd25519PublicKey(encoded string) (ed25519.PublicKey, error) { - encoded = strings.TrimSpace(encoded) - if encoded == "" { - return nil, nil - } - raw, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return nil, fmt.Errorf("decode license Ed25519 public key: %w", err) - } - if key, err := x509.ParsePKIXPublicKey(raw); err == nil { - if publicKey, ok := key.(ed25519.PublicKey); ok && len(publicKey) == ed25519.PublicKeySize { - return publicKey, nil - } - return nil, errors.New("license public key is not an Ed25519 PKIX public key") - } - if len(raw) == ed25519.PublicKeySize { - return ed25519.PublicKey(raw), nil - } - return nil, errors.New("license public key must be base64 PKIX or raw 32-byte Ed25519 public key") -} - -func licenseSignedPayload(resp licenseServerSignedResp) any { - return struct { - Valid bool `json:"valid"` - LicenseType string `json:"license_type"` - ExpiryDate *string `json:"expiry_date"` - MaxDevices int `json:"max_devices"` - MaxUsers *int `json:"max_users"` - DaysRemaining *int `json:"days_remaining"` - NextHeartbeat string `json:"next_heartbeat"` - }{ - Valid: resp.Valid, - LicenseType: resp.LicenseType, - ExpiryDate: resp.ExpiryDate, - MaxDevices: resp.MaxDevices, - MaxUsers: resp.MaxUsers, - DaysRemaining: resp.DaysRemaining, - NextHeartbeat: resp.NextHeartbeat, - } -} diff --git a/internal/handler/license_heartbeat.go b/internal/handler/license_heartbeat.go deleted file mode 100644 index fc109bc..0000000 --- a/internal/handler/license_heartbeat.go +++ /dev/null @@ -1,172 +0,0 @@ -package handler - -import ( - "context" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -const ( - licenseHeartbeatInterval = 12 * time.Hour - licenseHeartbeatCheckInterval = 30 * time.Minute - licenseHeartbeatStartupDelay = 2 * time.Minute -) - -func refreshLicenseCapacityBestEffort(ctx context.Context, svc *service.Container) { - if svc == nil || svc.Repo == nil || svc.Repo.Setting == nil { - return - } - _, _, _ = maybeSendLicenseHeartbeat(ctx, svc, 0) -} - -// RunLicenseHeartbeatLoop keeps the license server aware of active deployments. -// The loop checks periodically, but only sends when the last stored heartbeat is -// older than licenseHeartbeatInterval. -func RunLicenseHeartbeatLoop(ctx context.Context, svc *service.Container) { - if svc == nil { - return - } - run := func(interval time.Duration) { - state, sent, err := maybeSendLicenseHeartbeat(ctx, svc, interval) - logLicenseHeartbeatResult(svc, state, sent, err) - } - runStartup := func() { - state, sent, err := maybeSendStartupLicenseHeartbeat(ctx, svc) - logLicenseHeartbeatResult(svc, state, sent, err) - } - - timer := time.NewTimer(licenseHeartbeatStartupDelay) - defer timer.Stop() - select { - case <-ctx.Done(): - return - case <-timer.C: - runStartup() - } - - ticker := time.NewTicker(licenseHeartbeatCheckInterval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - run(licenseHeartbeatInterval) - } - } -} - -func logLicenseHeartbeatResult(svc *service.Container, state service.LicenseActivationState, sent bool, err error) { - if svc == nil || svc.Log == nil { - return - } - if err != nil { - svc.Log.Warn("license heartbeat failed", zap.Error(err)) - return - } - if sent { - svc.Log.Info("license heartbeat sent", zap.String("device_id", state.DeviceID)) - } -} - -func maybeSendStartupLicenseHeartbeat(ctx context.Context, svc *service.Container) (service.LicenseActivationState, bool, error) { - state, err := loadLicenseState(ctx, svc) - if err != nil { - return state, false, nil - } - if strings.TrimSpace(state.LicenseKey) == "" { - return state, false, nil - } - return maybeSendLicenseHeartbeat(ctx, svc, 0) -} - -func maybeSendLicenseHeartbeat(ctx context.Context, svc *service.Container, interval time.Duration) (service.LicenseActivationState, bool, error) { - state, err := loadLicenseState(ctx, svc) - if err != nil { - return state, false, nil - } - if !licenseHeartbeatEligible(state) { - return state, false, nil - } - if !licenseHeartbeatDue(state, interval) { - client, clientErr := newLicenseClient(ctx, svc) - if clientErr != nil { - return state, false, nil - } - deviceID, idErr := ensureLicenseDeviceID(ctx, svc, state.DeviceID) - if idErr != nil { - return state, false, idErr - } - refreshed, ok, requested, refreshErr := refreshLicenseServerStatus(ctx, client, state, deviceID) - if refreshErr == nil && ok { - state = refreshed - _ = persistLicenseState(ctx, svc, state) - } - if !requested { - return state, false, nil - } - } - next, err := sendLicenseHeartbeat(ctx, svc) - if err != nil { - return state, false, err - } - return next, true, nil -} - -func licenseHeartbeatEligible(state service.LicenseActivationState) bool { - return strings.TrimSpace(state.LicenseKey) != "" -} - -func licenseHeartbeatDue(state service.LicenseActivationState, interval time.Duration) bool { - if interval <= 0 { - return true - } - updatedAt := strings.TrimSpace(state.UpdatedAt) - if updatedAt == "" { - return true - } - for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} { - if t, err := time.Parse(layout, updatedAt); err == nil { - return time.Since(t) >= interval - } - } - return true -} - -func sendLicenseHeartbeat(ctx context.Context, svc *service.Container) (service.LicenseActivationState, error) { - client, err := newLicenseClient(ctx, svc) - if err != nil { - return service.LicenseActivationState{}, err - } - oldState, _ := loadLicenseState(ctx, svc) - deviceID, err := ensureLicenseDeviceID(ctx, svc, oldState.DeviceID) - if err != nil { - return service.LicenseActivationState{}, err - } - deviceName, _ := svc.Repo.Setting.Get(ctx, licenseDeviceNameSetting) - if strings.TrimSpace(deviceName) == "" { - deviceName = defaultLicenseDeviceName() - _ = svc.Repo.Setting.Set(ctx, licenseDeviceNameSetting, deviceName) - } - var upstream licenseServerSignedResp - if err := client.post(ctx, "/api/v1/heartbeat", licenseHeartbeatPayload(oldState, deviceID, deviceName), &upstream); err != nil { - return service.LicenseActivationState{}, err - } - if err := client.verifySigned(&upstream); err != nil { - return service.LicenseActivationState{}, err - } - state := licenseStateFromSigned(upstream, deviceID, deviceName) - state.LicenseKey = oldState.LicenseKey - if refreshed, ok, _, refreshErr := refreshLicenseServerStatus(ctx, client, state, deviceID); refreshErr == nil && ok { - refreshed.LicenseKey = state.LicenseKey - state = refreshed - } - if err := persistLicenseState(ctx, svc, state); err != nil { - return service.LicenseActivationState{}, err - } - return state, nil -} diff --git a/internal/handler/license_state.go b/internal/handler/license_state.go deleted file mode 100644 index 549d537..0000000 --- a/internal/handler/license_state.go +++ /dev/null @@ -1,211 +0,0 @@ -package handler - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "net/url" - "os" - "strings" - "time" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func refreshLicenseServerStatus(ctx context.Context, client *licenseClient, state service.LicenseActivationState, deviceID string) (service.LicenseActivationState, bool, bool, error) { - var upstream licenseServerStatusResp - if err := client.get(ctx, "/api/v1/status/"+url.PathEscape(deviceID), &upstream); err != nil { - return state, false, false, err - } - if !upstream.Valid { - state.Valid = false - state.UpdatedAt = time.Now().Format(time.RFC3339) - return state, false, upstream.HeartbeatRequested, nil - } - applyLicenseStatus(&state, upstream, deviceID) - return state, true, upstream.HeartbeatRequested, nil -} - -func ensureLicenseDeviceID(ctx context.Context, svc *service.Container, candidate string) (string, error) { - if strings.TrimSpace(candidate) != "" { - return strings.TrimSpace(candidate), svc.Repo.Setting.Set(ctx, licenseDeviceIDSetting, strings.TrimSpace(candidate)) - } - existing, err := svc.Repo.Setting.Get(ctx, licenseDeviceIDSetting) - if err != nil { - return "", err - } - if strings.TrimSpace(existing) != "" { - return strings.TrimSpace(existing), nil - } - var buf [16]byte - if _, err := rand.Read(buf[:]); err != nil { - return "", err - } - id := "msgo-" + hex.EncodeToString(buf[:]) - return id, svc.Repo.Setting.Set(ctx, licenseDeviceIDSetting, id) -} - -func defaultLicenseDeviceName() string { - host, _ := os.Hostname() - if strings.TrimSpace(host) == "" { - return "MediaStationGo Server" - } - return "MediaStationGo - " + host -} - -func licenseStateFromSigned(resp licenseServerSignedResp, deviceID, deviceName string) service.LicenseActivationState { - expiry := "" - if resp.ExpiryDate != nil { - expiry = *resp.ExpiryDate - } - return service.LicenseActivationState{ - Valid: resp.Valid, - LicenseType: resp.LicenseType, - ExpiryDate: expiry, - MaxDevices: resp.MaxDevices, - MaxUsers: resp.MaxUsers, - UnlimitedUsers: !resp.LegacySignature && resp.MaxUsers == nil, - DaysRemaining: resp.DaysRemaining, - NextHeartbeat: resp.NextHeartbeat, - DeviceID: deviceID, - DeviceName: deviceName, - UpdatedAt: time.Now().Format(time.RFC3339), - } -} - -func licenseHeartbeatPayload(state service.LicenseActivationState, deviceID, deviceName string) map[string]any { - payload := map[string]any{ - "fingerprint": deviceID, - "instance_id": deviceID, - "device_name": deviceName, - } - if key := strings.TrimSpace(state.LicenseKey); key != "" { - payload["key"] = key - } - return payload -} - -func licenseStatusMaxUsers(state service.LicenseActivationState) any { - active := state.Valid && !licenseStateExpired(state.ExpiryDate) - if active { - if state.UnlimitedUsers { - return nil - } - if state.MaxUsers != nil && *state.MaxUsers > 0 { - return *state.MaxUsers - } - return service.LicensedUserLimit - } - return service.OpenSourceUserLimit -} - -func applyLicenseStatus(state *service.LicenseActivationState, upstream licenseServerStatusResp, deviceID string) { - state.Valid = upstream.Valid - if upstream.LicenseType != nil { - state.LicenseType = *upstream.LicenseType - } - if upstream.ExpiryDate != nil { - state.ExpiryDate = *upstream.ExpiryDate - } else { - state.ExpiryDate = "" - } - if upstream.MaxDevices > 0 { - state.MaxDevices = upstream.MaxDevices - } - state.MaxUsers = upstream.MaxUsers - state.UnlimitedUsers = upstream.UnlimitedUsers - state.DaysRemaining = upstream.DaysRemaining - if upstream.DeviceName != "" { - state.DeviceName = upstream.DeviceName - } - state.DeviceID = deviceID - state.UpdatedAt = time.Now().Format(time.RFC3339) -} - -func persistLicenseState(ctx context.Context, svc *service.Container, state service.LicenseActivationState) error { - data, err := json.Marshal(state) - if err != nil { - return err - } - if strings.TrimSpace(state.DeviceID) != "" { - _ = svc.Repo.Setting.Set(ctx, licenseDeviceIDSetting, strings.TrimSpace(state.DeviceID)) - } - return svc.Repo.Setting.Set(ctx, service.LicenseSettingActivation, string(data)) -} - -func loadLicenseState(ctx context.Context, svc *service.Container) (service.LicenseActivationState, error) { - raw, err := svc.Repo.Setting.Get(ctx, service.LicenseSettingActivation) - if err != nil || raw == "" { - return service.LicenseActivationState{}, err - } - var state service.LicenseActivationState - if err := json.Unmarshal([]byte(raw), &state); err != nil { - return service.LicenseActivationState{}, err - } - return state, nil -} - -func licenseActivationView(state service.LicenseActivationState) gin.H { - updatedAt := state.UpdatedAt - if strings.TrimSpace(updatedAt) == "" { - updatedAt = time.Now().Format(time.RFC3339) - } - return gin.H{ - "id": state.DeviceID, - "key_id": state.LicenseType, - "key": maskLicenseKey(state.LicenseKey), - "device_id": state.DeviceID, - "device_name": state.DeviceName, - "plan": state.LicenseType, - "max_activations": state.MaxDevices, - "max_users": state.MaxUsers, - "unlimited_users": state.UnlimitedUsers, - "expires_at": emptyAsNil(state.ExpiryDate), - "valid": state.Valid && !licenseStateExpired(state.ExpiryDate), - "heartbeat_at": updatedAt, - "created_at": updatedAt, - } -} - -func maskLicenseKey(key string) string { - key = strings.TrimSpace(key) - if key == "" { - return "" - } - if len(key) <= 8 { - return key - } - return key[:5] + "..." + key[len(key)-4:] -} - -func licenseStatusMessage(active bool, clientErr error) string { - if active { - return "已激活" - } - if clientErr != nil && !strings.Contains(clientErr.Error(), "not configured") { - return clientErr.Error() - } - return "开源版:最多 20 个用户" -} - -func licenseStateExpired(expiry string) bool { - if expiry == "" { - return false - } - for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} { - if t, err := time.Parse(layout, expiry); err == nil { - return time.Now().After(t) - } - } - return false -} - -func emptyAsNil(v string) any { - if strings.TrimSpace(v) == "" { - return nil - } - return v -} diff --git a/internal/handler/license_test.go b/internal/handler/license_test.go deleted file mode 100644 index 658b4e2..0000000 --- a/internal/handler/license_test.go +++ /dev/null @@ -1,445 +0,0 @@ -package handler - -import ( - "crypto/ed25519" - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" - "github.com/ShukeBta/MediaStationGo/internal/service" - "github.com/gin-gonic/gin" - "github.com/glebarez/sqlite" - "gorm.io/gorm" -) - -func TestLicenseStatusMaxUsersUsesLicensedLimit(t *testing.T) { - maxUsers := 25 - state := service.LicenseActivationState{Valid: true, MaxUsers: &maxUsers} - - if got := licenseStatusMaxUsers(state); got != maxUsers { - t.Fatalf("expected licensed max users %d, got %#v", maxUsers, got) - } -} - -func TestLicenseStatusMaxUsersAllowsUnlimited(t *testing.T) { - state := service.LicenseActivationState{Valid: true, UnlimitedUsers: true} - - if got := licenseStatusMaxUsers(state); got != nil { - t.Fatalf("expected unlimited max users to be nil, got %#v", got) - } -} - -func TestLicenseStatusMaxUsersFallsBackToOpenSourceLimit(t *testing.T) { - state := service.LicenseActivationState{} - - if got := licenseStatusMaxUsers(state); got != service.OpenSourceUserLimit { - t.Fatalf("expected open-source max users %d, got %#v", service.OpenSourceUserLimit, got) - } -} - -func TestApplyLicenseStatusReflectsEditedLimitAndClearsExpiry(t *testing.T) { - maxUsers := 60 - licenseType := "subscription" - state := service.LicenseActivationState{ - Valid: true, - LicenseType: "enterprise", - ExpiryDate: "2026-01-01", - MaxDevices: 2, - UnlimitedUsers: true, - } - - applyLicenseStatus(&state, licenseServerStatusResp{ - Valid: true, - LicenseType: &licenseType, - ExpiryDate: nil, - MaxDevices: 5, - MaxUsers: &maxUsers, - UnlimitedUsers: false, - DeviceName: "Edited Device", - }, "device-1") - - if !state.Valid || state.LicenseType != "subscription" || state.ExpiryDate != "" || state.MaxDevices != 5 { - t.Fatalf("status fields were not fully refreshed: %+v", state) - } - if state.MaxUsers == nil || *state.MaxUsers != 60 || state.UnlimitedUsers { - t.Fatalf("user limit was not refreshed from status: %+v", state) - } - if state.DeviceID != "device-1" || state.DeviceName != "Edited Device" { - t.Fatalf("device fields were not refreshed: %+v", state) - } -} - -func TestApplyLicenseStatusReflectsUnlimitedUsers(t *testing.T) { - maxUsers := 30 - state := service.LicenseActivationState{Valid: true, MaxUsers: &maxUsers} - - applyLicenseStatus(&state, licenseServerStatusResp{ - Valid: true, - MaxUsers: nil, - UnlimitedUsers: true, - }, "device-1") - - if state.MaxUsers != nil || !state.UnlimitedUsers { - t.Fatalf("unlimited status should clear previous finite user limit: %+v", state) - } -} - -func TestRefreshLicenseServerStatusReflectsEditedLimitAndHeartbeatRequest(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/status/device-1" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "valid": true, - "license_type": "subscription", - "max_devices": 5, - "max_users": 60, - "unlimited_users": false, - "device_name": "NAS", - "heartbeat_requested": true, - "is_active": true - }`)) - })) - defer upstream.Close() - - state := service.LicenseActivationState{Valid: true, UnlimitedUsers: true} - client := &licenseClient{baseURL: upstream.URL, httpClient: upstream.Client()} - - refreshed, ok, requested, err := refreshLicenseServerStatus(t.Context(), client, state, "device-1") - if err != nil { - t.Fatalf("refresh status: %v", err) - } - if !ok || !requested { - t.Fatalf("expected valid status with requested heartbeat, ok=%v requested=%v", ok, requested) - } - if refreshed.MaxUsers == nil || *refreshed.MaxUsers != 60 || refreshed.UnlimitedUsers { - t.Fatalf("edited user limit was not reflected: %+v", refreshed) - } - if refreshed.MaxDevices != 5 || refreshed.DeviceName != "NAS" { - t.Fatalf("server status fields were not applied: %+v", refreshed) - } -} - -func TestLicenseHeartbeatPayloadIncludesStoredLicenseKey(t *testing.T) { - payload := licenseHeartbeatPayload(service.LicenseActivationState{ - LicenseKey: "MS-ABCD-EFGH-JKLM-NPQR", - }, "device-1", "NAS") - - if payload["fingerprint"] != "device-1" || payload["instance_id"] != "device-1" || payload["device_name"] != "NAS" { - t.Fatalf("heartbeat identity payload is wrong: %#v", payload) - } - if payload["key"] != "MS-ABCD-EFGH-JKLM-NPQR" { - t.Fatalf("heartbeat should include stored license key for server-side backfill: %#v", payload) - } -} - -func TestLicenseClientVerifiesEd25519Signature(t *testing.T) { - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - resp := licenseServerSignedResp{ - Valid: true, - LicenseType: "subscription", - MaxDevices: 2, - NextHeartbeat: time.Now().Add(time.Hour).Format(time.RFC3339), - SignatureAlg: "ed25519", - } - resp.Signature = signLicenseTestPayloadEd25519(privateKey, resp) - - client := &licenseClient{ed25519PublicKey: publicKey} - if err := client.verifySigned(&resp); err != nil { - t.Fatalf("verify ed25519 signature: %v", err) - } -} - -func TestLicenseClientRejectsEd25519WithoutPublicKey(t *testing.T) { - _, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - resp := licenseServerSignedResp{ - Valid: true, - LicenseType: "subscription", - MaxDevices: 2, - NextHeartbeat: time.Now().Add(time.Hour).Format(time.RFC3339), - SignatureAlg: "ed25519", - } - resp.Signature = signLicenseTestPayloadEd25519(privateKey, resp) - - client := &licenseClient{} - if err := client.verifySigned(&resp); err == nil || !strings.Contains(err.Error(), "public key") { - t.Fatalf("expected missing public key error, got %v", err) - } -} - -func TestLicenseHeartbeatDueUsesTwelveHourWindow(t *testing.T) { - state := service.LicenseActivationState{ - Valid: true, - UpdatedAt: time.Now().Add(-11 * time.Hour).Format(time.RFC3339), - } - if licenseHeartbeatDue(state, 12*time.Hour) { - t.Fatalf("heartbeat should not be due before interval") - } - - state.UpdatedAt = time.Now().Add(-13 * time.Hour).Format(time.RFC3339) - if !licenseHeartbeatDue(state, 12*time.Hour) { - t.Fatalf("heartbeat should be due after interval") - } -} - -func TestStartupLicenseHeartbeatIgnoresTwelveHourWindow(t *testing.T) { - heartbeatCount := 0 - maxUsers := 40 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v1/heartbeat": - heartbeatCount++ - resp := licenseServerSignedResp{ - Valid: true, - LicenseType: "subscription", - MaxDevices: 2, - MaxUsers: &maxUsers, - NextHeartbeat: time.Now().Add(time.Hour).Format(time.RFC3339), - } - resp.Signature = signLicenseTestPayload("test-secret", resp) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) - case "/api/v1/status/device-1": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "valid": true, - "license_type": "subscription", - "max_devices": 2, - "max_users": 40, - "unlimited_users": false, - "device_name": "NAS", - "is_active": true - }`)) - default: - t.Fatalf("unexpected upstream path %s", r.URL.Path) - } - })) - defer upstream.Close() - - svc := newLicenseHandlerTestService(t) - if err := svc.Repo.Setting.Set(t.Context(), licenseServerURLSetting, upstream.URL); err != nil { - t.Fatal(err) - } - if err := svc.Repo.Setting.Set(t.Context(), licenseHMACSecretSetting, "test-secret"); err != nil { - t.Fatal(err) - } - state := service.LicenseActivationState{ - Valid: true, - LicenseKey: "MS-ABCD-EFGH-JKLM-NPQR", - DeviceID: "device-1", - DeviceName: "NAS", - UpdatedAt: time.Now().Format(time.RFC3339), - } - if err := persistLicenseState(t.Context(), svc, state); err != nil { - t.Fatal(err) - } - - refreshed, sent, err := maybeSendStartupLicenseHeartbeat(t.Context(), svc) - if err != nil { - t.Fatalf("startup heartbeat: %v", err) - } - if !sent || heartbeatCount != 1 { - t.Fatalf("startup heartbeat should be sent once, sent=%v count=%d", sent, heartbeatCount) - } - if refreshed.MaxUsers == nil || *refreshed.MaxUsers != 40 { - t.Fatalf("startup heartbeat should refresh licensed user capacity, got %+v", refreshed) - } -} - -func TestStartupLicenseHeartbeatSkipsStateWithoutStoredKey(t *testing.T) { - heartbeatCount := 0 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - heartbeatCount++ - w.WriteHeader(http.StatusInternalServerError) - })) - defer upstream.Close() - - svc := newLicenseHandlerTestService(t) - if err := svc.Repo.Setting.Set(t.Context(), licenseServerURLSetting, upstream.URL); err != nil { - t.Fatal(err) - } - if err := svc.Repo.Setting.Set(t.Context(), licenseHMACSecretSetting, "test-secret"); err != nil { - t.Fatal(err) - } - if err := persistLicenseState(t.Context(), svc, service.LicenseActivationState{ - Valid: true, - DeviceID: "device-1", - UpdatedAt: time.Now().Format(time.RFC3339), - }); err != nil { - t.Fatal(err) - } - - _, sent, err := maybeSendStartupLicenseHeartbeat(t.Context(), svc) - if err != nil { - t.Fatalf("startup heartbeat should skip without error, got %v", err) - } - if sent || heartbeatCount != 0 { - t.Fatalf("startup heartbeat without stored license key should be skipped, sent=%v count=%d", sent, heartbeatCount) - } -} - -func TestLicenseHeartbeatEligibleRequiresActivationState(t *testing.T) { - if licenseHeartbeatEligible(service.LicenseActivationState{DeviceID: "device-only"}) { - t.Fatalf("device id alone should not trigger automatic license heartbeat") - } - if !licenseHeartbeatEligible(service.LicenseActivationState{LicenseKey: "MS-KEY"}) { - t.Fatalf("stored license key should trigger automatic license heartbeat") - } - if licenseHeartbeatEligible(service.LicenseActivationState{Valid: true}) { - t.Fatalf("valid state without stored license key should not trigger automatic license heartbeat") - } -} - -func TestLicenseStatusSkipsUnlicensedHeartbeatWithDefaultServer(t *testing.T) { - upstreamCalls := 0 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - upstreamCalls++ - w.WriteHeader(http.StatusInternalServerError) - })) - defer upstream.Close() - - svc := newLicenseHandlerTestService(t) - svc.Cfg.License.ServerURL = upstream.URL - svc.Cfg.License.HMACSecret = "test-secret" - - router := gin.New() - router.GET("/license/status", licenseStatusHandler(svc)) - req := httptest.NewRequest(http.MethodGet, "/license/status", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) - } - if upstreamCalls != 0 { - t.Fatalf("unlicensed status should not contact license server, got %d calls", upstreamCalls) - } -} - -func TestLicenseActivateBindsServerInstanceNotBrowserFingerprint(t *testing.T) { - var upstreamFingerprint string - maxUsers := 60 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var payload map[string]any - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("decode upstream payload: %v", err) - } - upstreamFingerprint, _ = payload["fingerprint"].(string) - resp := licenseServerSignedResp{ - Valid: true, - LicenseType: "subscription", - MaxDevices: 3, - MaxUsers: &maxUsers, - NextHeartbeat: time.Now().Add(time.Hour).Format(time.RFC3339), - } - resp.Signature = signLicenseTestPayload("test-secret", resp) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) - })) - defer upstream.Close() - - svc := newLicenseHandlerTestService(t) - if err := svc.Repo.Setting.Set(t.Context(), licenseServerURLSetting, upstream.URL); err != nil { - t.Fatal(err) - } - if err := svc.Repo.Setting.Set(t.Context(), licenseHMACSecretSetting, "test-secret"); err != nil { - t.Fatal(err) - } - - router := gin.New() - router.POST("/license/activate", licenseActivateHandler(svc)) - req := httptest.NewRequest(http.MethodPost, "/license/activate", strings.NewReader(`{ - "key": "MS-ABCD-EFGH-JKLM-NPQR", - "device_id": "browser-fingerprint", - "device_name": "" - }`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("activate status = %d body=%s", w.Code, w.Body.String()) - } - if upstreamFingerprint == "" || upstreamFingerprint == "browser-fingerprint" || !strings.HasPrefix(upstreamFingerprint, "msgo-") { - t.Fatalf("activation should use server-generated msgo id, got %q", upstreamFingerprint) - } - stored, err := svc.Repo.Setting.Get(t.Context(), licenseDeviceIDSetting) - if err != nil { - t.Fatal(err) - } - if stored != upstreamFingerprint { - t.Fatalf("stored device id = %q, upstream fingerprint = %q", stored, upstreamFingerprint) - } -} - -func TestNewLicenseClientRequiresSignatureVerifier(t *testing.T) { - svc := newLicenseHandlerTestService(t) - if err := svc.Repo.Setting.Set(t.Context(), licenseServerURLSetting, "http://127.0.0.1:8001"); err != nil { - t.Fatal(err) - } - if _, err := newLicenseClient(t.Context(), svc); err == nil || !strings.Contains(err.Error(), "public key or hmac secret") { - t.Fatalf("expected missing signature verifier error, got %v", err) - } -} - -func newLicenseHandlerTestService(t *testing.T) *service.Container { - t.Helper() - 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) - } - return &service.Container{ - Cfg: &config.Config{}, - Repo: repository.New(db), - } -} - -func signLicenseTestPayload(secret string, resp licenseServerSignedResp) string { - unsigned := struct { - Valid bool `json:"valid"` - LicenseType string `json:"license_type"` - ExpiryDate *string `json:"expiry_date"` - MaxDevices int `json:"max_devices"` - MaxUsers *int `json:"max_users"` - DaysRemaining *int `json:"days_remaining"` - NextHeartbeat string `json:"next_heartbeat"` - }{ - Valid: resp.Valid, - LicenseType: resp.LicenseType, - ExpiryDate: resp.ExpiryDate, - MaxDevices: resp.MaxDevices, - MaxUsers: resp.MaxUsers, - DaysRemaining: resp.DaysRemaining, - NextHeartbeat: resp.NextHeartbeat, - } - payload, _ := json.Marshal(unsigned) - mac := hmac.New(sha256.New, []byte(secret)) - _, _ = mac.Write(payload) - return hex.EncodeToString(mac.Sum(nil)) -} - -func signLicenseTestPayloadEd25519(privateKey ed25519.PrivateKey, resp licenseServerSignedResp) string { - payload, _ := json.Marshal(licenseSignedPayload(resp)) - return base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, payload)) -} diff --git a/internal/handler/media.go b/internal/handler/media.go index 81d0fb7..3f1a893 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -31,7 +31,6 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - libs = service.FilterDeprecatedNativeCloudLibraries(libs) role, _ := c.Get(middleware.CtxUserRole) includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1") if !includeHidden { @@ -44,9 +43,6 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { } } libs = filtered - } else { - libs = service.FilterMergedCloudAutoCategoryLibraries(libs) - libs = service.NormalizeCloudLibraryDisplayNames(libs) } c.JSON(http.StatusOK, libs) } @@ -63,23 +59,18 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - libs := service.FilterDeprecatedNativeCloudLibraries([]model.Library{*lib}) - if len(libs) == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return - } role, _ := c.Get(middleware.CtxUserRole) includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1") - if includeHidden { - c.JSON(http.StatusOK, service.NormalizeCloudLibraryDisplayNames(libs)[0]) - return + if !includeHidden { + libs := service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, []model.Library{*lib}) + if len(libs) == 0 || !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, libs[0], mediaVisibilityForRequest(c, svc)) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, libs[0]) + } else { + c.JSON(http.StatusOK, lib) } - libs = service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, libs) - if len(libs) == 0 || !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, libs[0], mediaVisibilityForRequest(c, svc)) { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return - } - c.JSON(http.StatusOK, libs[0]) } } @@ -152,11 +143,6 @@ func updateLibraryHandler(svc *service.Container) gin.HandlerFunc { func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") - if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), id); err == nil && lib != nil { - if _, ok := service.ParseCloudLibraryMount(lib.Path); ok && svc.Scan != nil { - _ = svc.Scan.CancelCloudScan(id) - } - } if err := svc.Media.DeleteLibrary(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/internal/handler/media_scan.go b/internal/handler/media_scan.go index 21fe5d0..9c4aab0 100644 --- a/internal/handler/media_scan.go +++ b/internal/handler/media_scan.go @@ -23,47 +23,8 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc { return } if _, ok := service.ParseCloudLibraryMount(lib.Path); ok { - status, started, startErr := svc.Scan.StartCloudLibraryScan(id, true) - if startErr != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": startErr.Error()}) - return - } - if !started { - c.JSON(http.StatusAccepted, gin.H{ - "library_id": id, - "queued": true, - "cloud": true, - "already_running": true, - "stage": status.Stage, - "state": status.State, - "message": "该云盘媒体库正在后台扫描,请在任务面板查看进度", - "estimate_message": "页面关闭不会中断扫描", - }) - return - } - task := startScanHTTPTask(svc, "云盘扫描队列", lib.Name, lib.Path) - if svc.WSHub != nil { - svc.WSHub.Publish("scan", gin.H{ - "library_id": id, - "cloud": true, - "queued": true, - "stage": "queued", - "message": "云盘扫描已加入后台队列,会递归扫描并自动加入媒体库", - "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", - }) - } - finishHTTPTask(task, nil, "queued", "云盘扫描已加入后台队列", map[string]int64{"queued": 1}, nil) - c.JSON(http.StatusAccepted, gin.H{ - "library_id": id, - "visited": 0, - "added": 0, - "updated": 0, - "probed": 0, - "queued": true, - "cloud": true, - "message": "云盘扫描已在后台运行,发现的媒体会自动加入当前媒体库;若已开启自动刮削,会在扫描后补齐元数据", - "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", - }) + // 云盘扫描已随网盘后端移除;此处保留空分支以兼容既有客户端。 + c.JSON(http.StatusBadRequest, gin.H{"error": "cloud 网盘后端已移除,无法扫描云盘媒体库"}) return } finishScan, ok := svc.Scan.TryBeginLocalScan(id) diff --git a/internal/handler/media_test.go b/internal/handler/media_test.go index 3a8a421..6e2ea16 100644 --- a/internal/handler/media_test.go +++ b/internal/handler/media_test.go @@ -110,86 +110,6 @@ func TestListLibrariesHidesAdultDirectoriesUnlessAdminRequestsAll(t *testing.T) } } -func TestListLibrariesIncludeHiddenNormalizesCloudDisplayNames(t *testing.T) { - gin.SetMode(gin.TestMode) - 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) - } - repos := repository.New(db) - cloud := model.Library{Name: "OpenList · 国产剧", Path: service.BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &cloud); err != nil { - t.Fatal(err) - } - svc := &service.Container{ - Repo: repos, - Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos), - } - - all := requestLibraries(t, svc, "admin", "admin", "/api/libraries?include_hidden=1") - if len(all) != 1 { - t.Fatalf("include_hidden list = %#v, want one library", all) - } - if all[0].Name != "国产剧" { - t.Fatalf("cloud display name = %q, want stripped directory name", all[0].Name) - } -} - -func TestListLibrariesShowsAutoCategoryLibraries(t *testing.T) { - gin.SetMode(gin.TestMode) - 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) - } - repos := repository.New(db) - root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - auto := model.Library{Name: "欧美剧", Path: service.BuildCloudAutoCategoryLibraryPath("openlist", "电视剧/欧美剧"), Type: "tv", Enabled: true} - for _, lib := range []*model.Library{&root, &auto} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - svc := &service.Container{ - Repo: repos, - Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos), - } - - all := requestLibraries(t, svc, "admin", "admin", "/api/libraries?include_hidden=1") - if len(all) != 2 { - t.Fatalf("include_hidden list = %#v, want root plus auto category library", all) - } - ids := map[string]bool{} - for _, lib := range all { - ids[lib.ID] = true - } - if !ids[root.ID] || !ids[auto.ID] { - t.Fatalf("include_hidden list = %#v, want root %s and auto category %s", all, root.ID, auto.ID) - } - - visible := requestLibraries(t, svc, "user-1", "user", "/api/libraries") - if len(visible) != 2 { - t.Fatalf("visible list = %#v, want root plus auto category library", visible) - } - ids = map[string]bool{} - for _, lib := range visible { - ids[lib.ID] = true - } - if !ids[root.ID] || !ids[auto.ID] { - t.Fatalf("visible list = %#v, want root %s and auto category %s", visible, root.ID, auto.ID) - } - - got := requestLibrary(t, svc, "user-1", "user", "/api/libraries/"+auto.ID, auto.ID) - if got.ID != auto.ID || got.Name != auto.Name { - t.Fatalf("auto category detail = %#v, want accessible category library", got) - } -} - func TestGetLibraryAllowsEmptyLibrary(t *testing.T) { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) @@ -336,41 +256,6 @@ func TestListLibrarySeriesDoesNotTruncateLargeEpisodeLibraries(t *testing.T) { } } -func TestScanLibraryHandlerSurfacesCloudQueueStartFailure(t *testing.T) { - gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - lib := model.Library{Name: "旧夸克云盘", Path: service.BuildCloudLibraryPath(service.LegacyQuarkProvider, "archive", "archive"), Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - log := zap.NewNop() - svc := &service.Container{ - Log: log, - Repo: repos, - Scan: service.NewScannerService(&config.Config{}, log, repos, service.NewHub(log), nil, nil), - } - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: lib.ID}} - c.Request = httptest.NewRequest(http.MethodPost, "/api/libraries/"+lib.ID+"/scan", nil) - - scanLibraryHandler(svc)(c) - - if w.Code != http.StatusBadRequest { - t.Fatalf("status=%d body=%s, want bad request", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "deprecated") { - t.Fatalf("body=%s, want cloud queue start error", w.Body.String()) - } -} - func TestScrapeOptionsFromRequestPreservesEpisodeImagesFalse(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() diff --git a/internal/handler/metadata_enrich_download.go b/internal/handler/metadata_enrich_download.go deleted file mode 100644 index f94a571..0000000 --- a/internal/handler/metadata_enrich_download.go +++ /dev/null @@ -1,188 +0,0 @@ -package handler - -import ( - "context" - "net/url" - "path" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func enrichAndPersistDownloadRows(ctx context.Context, svc *service.Container, rows []model.DownloadTask) { - for i := range rows { - before := rows[i] - enrichDownloadRow(ctx, svc, &rows[i]) - updates := downloadTaskMetadataUpdates(before, rows[i]) - if len(updates) == 0 { - continue - } - if err := svc.Repo.DB.WithContext(ctx).Model(&model.DownloadTask{}).Where("id = ?", rows[i].ID).Updates(updates).Error; err != nil { - svc.Log.Debug("download artwork backfill failed", zap.String("id", rows[i].ID), zap.Error(err)) - } - } -} - -func enrichDownloadRow(ctx context.Context, svc *service.Container, row *model.DownloadTask) { - if strings.TrimSpace(row.Title) == "" { - row.Title = downloadDisplayTitle(row.URL) - } - if !downloadTaskNeedsMetadata(*row) { - return - } - meta := enrichDownloadTaskMeta(ctx, svc, service.DownloadTaskMeta{ - Title: row.Title, - PosterURL: row.PosterURL, - BackdropURL: row.BackdropURL, - Overview: row.Overview, - OriginalName: row.OriginalName, - OriginalLanguage: row.OriginalLanguage, - Year: row.Year, - Rating: row.Rating, - Genres: row.Genres, - }, firstNonEmptyString(row.Title, row.URL), row.MediaType) - row.Title = firstNonEmptyString(row.Title, meta.Title) - row.PosterURL = meta.PosterURL - row.BackdropURL = meta.BackdropURL - row.Overview = meta.Overview - row.OriginalName = meta.OriginalName - row.OriginalLanguage = meta.OriginalLanguage - row.Year = meta.Year - row.Rating = meta.Rating - row.Genres = meta.Genres -} - -func downloadTaskNeedsMetadata(row model.DownloadTask) bool { - return strings.TrimSpace(row.PosterURL) == "" || strings.TrimSpace(row.BackdropURL) == "" || - strings.TrimSpace(row.Overview) == "" || row.Year <= 0 || row.Rating <= 0 || - strings.TrimSpace(row.Genres) == "" || strings.TrimSpace(row.OriginalName) == "" || - strings.TrimSpace(row.OriginalLanguage) == "" -} - -func downloadTaskMetadataUpdates(before, after model.DownloadTask) map[string]any { - updates := map[string]any{} - if before.Title != after.Title { - updates["title"] = after.Title - } - if before.PosterURL != after.PosterURL { - updates["poster_url"] = after.PosterURL - } - if before.BackdropURL != after.BackdropURL { - updates["backdrop_url"] = after.BackdropURL - } - if before.Overview != after.Overview { - updates["overview"] = after.Overview - } - if before.OriginalName != after.OriginalName { - updates["original_name"] = after.OriginalName - } - if before.OriginalLanguage != after.OriginalLanguage { - updates["original_language"] = after.OriginalLanguage - } - if before.Year != after.Year { - updates["year"] = after.Year - } - if before.Rating != after.Rating { - updates["rating"] = after.Rating - } - if before.Genres != after.Genres { - updates["genres"] = after.Genres - } - return updates -} - -func enrichDownloadTorrentViews(ctx context.Context, svc *service.Container, views []service.DownloadTorrentView) { - cache := map[string]displayMetadata{} - for i := range views { - if strings.TrimSpace(views[i].PosterURL) != "" && strings.TrimSpace(views[i].BackdropURL) != "" { - continue - } - query := firstNonEmptyString(views[i].Title, views[i].Name) - cacheKey, _ := metadataSearchQuery(query) - meta, ok := cache[cacheKey] - if !ok { - meta = lookupDisplayMetadata(ctx, svc, query, "", "") - cache[cacheKey] = meta - } - if strings.TrimSpace(views[i].PosterURL) == "" { - views[i].PosterURL = meta.PosterURL - } - if strings.TrimSpace(views[i].BackdropURL) == "" { - views[i].BackdropURL = meta.BackdropURL - } - if strings.TrimSpace(views[i].Overview) == "" { - views[i].Overview = meta.Overview - } - if (views[i].Title == "" || views[i].Title == views[i].Name) && meta.Title != "" { - views[i].Title = meta.Title - } - } -} - -func enrichDownloadTaskMeta(ctx context.Context, svc *service.Container, meta service.DownloadTaskMeta, fallbackTitle, mediaType string) service.DownloadTaskMeta { - if strings.TrimSpace(meta.Title) == "" { - meta.Title = strings.TrimSpace(downloadDisplayTitle(fallbackTitle)) - } - // 仅当展示字段都齐时才跳过查询(含媒体富通知所需的年份/评分/类型/原始信息)。 - if strings.TrimSpace(meta.PosterURL) != "" && strings.TrimSpace(meta.BackdropURL) != "" && - strings.TrimSpace(meta.Overview) != "" && meta.Year > 0 && meta.Rating > 0 && - strings.TrimSpace(meta.Genres) != "" && strings.TrimSpace(meta.OriginalName) != "" && - strings.TrimSpace(meta.OriginalLanguage) != "" { - return meta - } - found := lookupDisplayMetadata(ctx, svc, meta.Title, fallbackTitle, mediaType) - if strings.TrimSpace(meta.Title) == "" { - meta.Title = found.Title - } - if strings.TrimSpace(meta.PosterURL) == "" { - meta.PosterURL = found.PosterURL - } - if strings.TrimSpace(meta.BackdropURL) == "" { - meta.BackdropURL = found.BackdropURL - } - if strings.TrimSpace(meta.Overview) == "" { - meta.Overview = found.Overview - } - if strings.TrimSpace(meta.OriginalName) == "" { - meta.OriginalName = found.OriginalName - } - if strings.TrimSpace(meta.OriginalLanguage) == "" { - meta.OriginalLanguage = found.OriginalLanguage - } - if meta.Year <= 0 && found.Year > 0 { - meta.Year = found.Year - } - if meta.Rating <= 0 && found.Rating > 0 { - meta.Rating = found.Rating - } - if strings.TrimSpace(meta.Genres) == "" { - meta.Genres = found.Genres - } - return meta -} - -func downloadDisplayTitle(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if u, err := url.Parse(raw); err == nil { - if dn := strings.TrimSpace(u.Query().Get("dn")); dn != "" { - if decoded, err := url.QueryUnescape(dn); err == nil && strings.TrimSpace(decoded) != "" { - return strings.TrimSpace(decoded) - } - return dn - } - if u.Host != "" { - base := path.Base(u.Path) - if base != "." && base != "/" && base != "" { - base = strings.TrimSuffix(base, path.Ext(base)) - return strings.TrimSpace(base) - } - } - } - return raw -} diff --git a/internal/handler/metadata_enrich_subscription.go b/internal/handler/metadata_enrich_subscription.go deleted file mode 100644 index 40c3b78..0000000 --- a/internal/handler/metadata_enrich_subscription.go +++ /dev/null @@ -1,100 +0,0 @@ -package handler - -import ( - "context" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func enrichSubscriptionArtwork(ctx context.Context, svc *service.Container, sub *model.Subscription) { - if svc == nil || sub == nil { - return - } - // 已有图片且媒体展示字段也齐全时才跳过;否则仍需查一次补 年份/评分/类型 等 - // 富通知字段(老订阅只存了 poster/overview 的情况)。 - if strings.TrimSpace(sub.PosterURL) != "" && strings.TrimSpace(sub.BackdropURL) != "" && - sub.Year > 0 && sub.Rating > 0 && strings.TrimSpace(sub.Genres) != "" { - return - } - meta := lookupDisplayMetadata(ctx, svc, sub.Name, sub.Filter, sub.MediaType) - if meta.Title == "" && meta.PosterURL == "" && meta.BackdropURL == "" && meta.Overview == "" { - return - } - if strings.TrimSpace(sub.Source) == "" { - sub.Source = meta.Source - } - if strings.TrimSpace(sub.PosterURL) == "" { - sub.PosterURL = meta.PosterURL - } - if strings.TrimSpace(sub.BackdropURL) == "" { - sub.BackdropURL = meta.BackdropURL - } - if strings.TrimSpace(sub.Overview) == "" { - sub.Overview = meta.Overview - } - if strings.TrimSpace(sub.OriginalName) == "" { - sub.OriginalName = meta.OriginalName - } - if strings.TrimSpace(sub.OriginalLanguage) == "" { - sub.OriginalLanguage = meta.OriginalLanguage - } - if sub.Year <= 0 && meta.Year > 0 { - sub.Year = meta.Year - } - if sub.Rating <= 0 && meta.Rating > 0 { - sub.Rating = meta.Rating - } - if strings.TrimSpace(sub.Genres) == "" { - sub.Genres = meta.Genres - } -} - -func enrichAndPersistSubscriptions(ctx context.Context, svc *service.Container, items []model.Subscription) { - for i := range items { - before := items[i] - enrichSubscriptionArtwork(ctx, svc, &items[i]) - updates := subscriptionMetadataUpdates(before, items[i]) - if len(updates) == 0 { - continue - } - if err := svc.Repo.DB.WithContext(ctx).Model(&model.Subscription{}).Where("id = ?", items[i].ID).Updates(updates).Error; err != nil { - svc.Log.Debug("subscription artwork backfill failed", zap.String("id", items[i].ID), zap.Error(err)) - } - } -} - -func subscriptionMetadataUpdates(before, after model.Subscription) map[string]any { - updates := map[string]any{} - if before.Source != after.Source { - updates["source"] = after.Source - } - if before.PosterURL != after.PosterURL { - updates["poster_url"] = after.PosterURL - } - if before.BackdropURL != after.BackdropURL { - updates["backdrop_url"] = after.BackdropURL - } - if before.Overview != after.Overview { - updates["overview"] = after.Overview - } - if before.OriginalName != after.OriginalName { - updates["original_name"] = after.OriginalName - } - if before.OriginalLanguage != after.OriginalLanguage { - updates["original_language"] = after.OriginalLanguage - } - if before.Year != after.Year { - updates["year"] = after.Year - } - if before.Rating != after.Rating { - updates["rating"] = after.Rating - } - if before.Genres != after.Genres { - updates["genres"] = after.Genres - } - return updates -} diff --git a/internal/handler/notify.go b/internal/handler/notify.go deleted file mode 100644 index e1928bf..0000000 --- a/internal/handler/notify.go +++ /dev/null @@ -1,27 +0,0 @@ -// Package handler — notification test endpoint. -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -type notifyTestReq struct { - Title string `json:"title" binding:"required"` - Body string `json:"body" binding:"required"` -} - -func notifyTestHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req notifyTestReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - svc.Notifier.Send(c.Request.Context(), req.Title, req.Body, "test") - c.JSON(http.StatusOK, gin.H{"message": "notification dispatched"}) - } -} diff --git a/internal/handler/notify_channels.go b/internal/handler/notify_channels.go deleted file mode 100644 index 1f820fb..0000000 --- a/internal/handler/notify_channels.go +++ /dev/null @@ -1,77 +0,0 @@ -// Package handler — notify channel CRUD + per-channel test endpoint. -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func listNotifyChannelsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - rows, err := svc.NotifyChannels.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if rows == nil { - c.JSON(http.StatusOK, []struct{}{}) - return - } - c.JSON(http.StatusOK, rows) - } -} - -func createNotifyChannelHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var in service.ChannelInput - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - row, err := svc.NotifyChannels.Create(c.Request.Context(), in) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusCreated, row) - } -} - -func updateNotifyChannelHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var in service.ChannelInput - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - row, err := svc.NotifyChannels.Update(c.Request.Context(), c.Param("id"), in) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, row) - } -} - -func deleteNotifyChannelHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.NotifyChannels.Delete(c.Request.Context(), c.Param("id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.Status(http.StatusNoContent) - } -} - -func testNotifyChannelHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.NotifyChannels.Test(c.Request.Context(), c.Param("id")); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "test sent"}) - } -} diff --git a/internal/handler/notify_handler.go b/internal/handler/notify_handler.go deleted file mode 100644 index 037a1c2..0000000 --- a/internal/handler/notify_handler.go +++ /dev/null @@ -1,196 +0,0 @@ -// Package handler — 通知渠道管理 HTTP 端点。 -package handler - -import ( - "encoding/json" - "net/http" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// NotifyHandler 处理通知渠道的 CRUD 操作。 -type NotifyHandler struct { - svc *service.Container - log *zap.Logger -} - -// NewNotifyHandler 创建通知渠道处理器。 -func NewNotifyHandler(svc *service.Container, log *zap.Logger) *NotifyHandler { - return &NotifyHandler{svc: svc, log: log} -} - -// notifyCreateRequest 创建通知渠道请求体。 -type notifyCreateRequest struct { - Name string `json:"name" binding:"required"` - Type string `json:"type" binding:"required,oneof=telegram wechat bark webhook email"` - Enabled bool `json:"enabled"` - Config map[string]string `json:"config" binding:"required"` - Events []string `json:"events"` -} - -// notifyUpdateRequest 更新通知渠道请求体。 -type notifyUpdateRequest struct { - Name string `json:"name"` - Enabled *bool `json:"enabled"` - Config map[string]string `json:"config"` - Events []string `json:"events"` -} - -// Create 创建新的通知渠道。 -func (h *NotifyHandler) Create(c *gin.Context) { - var req notifyCreateRequest - if err := c.ShouldBindJSON(&req); err != nil { - Error(c, http.StatusBadRequest, ErrInvalidParams, err.Error()) - return - } - - ctx := c.Request.Context() - - // 验证配置 - if err := h.svc.Notify.ValidateChannelConfig(req.Type, req.Config); err != nil { - Error(c, http.StatusBadRequest, ErrInvalidParams, "配置验证失败: "+err.Error()) - return - } - - // 加密配置 - configJSON, _ := json.Marshal(req.Config) - configStr := string(configJSON) - if h.svc.Crypto != nil { - configStr = h.svc.Crypto.Encrypt(configStr) - } - - // 序列化事件列表 - eventsJSON, _ := json.Marshal(req.Events) - eventsStr := string(eventsJSON) - - channel := &model.NotifyChannel{ - Name: req.Name, - Type: req.Type, - Enabled: req.Enabled, - Config: configStr, - Events: eventsStr, - } - - if err := h.svc.Repo.NotifyChannel.Create(ctx, channel); err != nil { - Error(c, http.StatusInternalServerError, ErrInternal, "创建失败: "+err.Error()) - return - } - - Success(c, channel) -} - -// List 返回所有通知渠道。 -func (h *NotifyHandler) List(c *gin.Context) { - ctx := c.Request.Context() - channels, err := h.svc.Repo.NotifyChannel.List(ctx) - if err != nil { - Error(c, http.StatusInternalServerError, ErrInternal, "查询失败") - return - } - Success(c, channels) -} - -// Get 返回指定通知渠道详情。 -func (h *NotifyHandler) Get(c *gin.Context) { - id := c.Param("id") - ctx := c.Request.Context() - channel, err := h.svc.Repo.NotifyChannel.FindByID(ctx, id) - if err != nil || channel == nil { - Error(c, http.StatusNotFound, ErrNotFound, "通知渠道不存在") - return - } - Success(c, channel) -} - -// Update 更新通知渠道。 -func (h *NotifyHandler) Update(c *gin.Context) { - id := c.Param("id") - var req notifyUpdateRequest - if err := c.ShouldBindJSON(&req); err != nil { - Error(c, http.StatusBadRequest, ErrInvalidParams, err.Error()) - return - } - - ctx := c.Request.Context() - channel, err := h.svc.Repo.NotifyChannel.FindByID(ctx, id) - if err != nil || channel == nil { - Error(c, http.StatusNotFound, ErrNotFound, "通知渠道不存在") - return - } - - if req.Name != "" { - channel.Name = req.Name - } - if req.Enabled != nil { - channel.Enabled = *req.Enabled - } - - // 更新配置 - if len(req.Config) > 0 { - if err := h.svc.Notify.ValidateChannelConfig(channel.Type, req.Config); err != nil { - Error(c, http.StatusBadRequest, ErrInvalidParams, "配置验证失败: "+err.Error()) - return - } - configJSON, _ := json.Marshal(req.Config) - configStr := string(configJSON) - if h.svc.Crypto != nil { - configStr = h.svc.Crypto.Encrypt(configStr) - } - channel.Config = configStr - } - - // 更新事件列表 - if req.Events != nil { - eventsJSON, _ := json.Marshal(req.Events) - channel.Events = string(eventsJSON) - } - - if err := h.svc.Repo.NotifyChannel.Update(ctx, channel); err != nil { - Error(c, http.StatusInternalServerError, ErrInternal, "更新失败") - return - } - - Success(c, channel) -} - -// Delete 删除通知渠道。 -func (h *NotifyHandler) Delete(c *gin.Context) { - id := c.Param("id") - ctx := c.Request.Context() - - _, err := h.svc.Repo.NotifyChannel.FindByID(ctx, id) - if err != nil { - Error(c, http.StatusNotFound, ErrNotFound, "通知渠道不存在") - return - } - - if delErr := h.svc.Repo.NotifyChannel.Delete(ctx, id); delErr != nil { - Error(c, http.StatusInternalServerError, ErrInternal, "删除失败") - return - } - - SuccessWithMessage(c, "已删除", nil) -} - -// Test 发送测试通知。 -func (h *NotifyHandler) Test(c *gin.Context) { - id := c.Param("id") - ctx := c.Request.Context() - - if err := h.svc.Notify.SendTest(ctx, id); err != nil { - Error(c, http.StatusBadRequest, ErrExternal, "测试通知发送失败: "+err.Error()) - return - } - - SuccessWithMessage(c, "测试通知已发送", nil) -} - -// GetTypes 返回支持的通知渠道类型列表。 -func (h *NotifyHandler) GetTypes(c *gin.Context) { - types := h.svc.Notify.GetProviderTypes() - Success(c, types) -} diff --git a/internal/handler/playback_extra_test.go b/internal/handler/playback_extra_test.go index ac5fe3b..eb5d846 100644 --- a/internal/handler/playback_extra_test.go +++ b/internal/handler/playback_extra_test.go @@ -275,63 +275,6 @@ func TestScopedPlaybackTokenCannotStreamAnotherMedia(t *testing.T) { } } -func TestScopedPlaybackTokenCanFollowCloudRedirectForSameMedia(t *testing.T) { - router, svc, _ := newPlaybackScopeTestRouter(t) - user, err := svc.Repo.User.FindByID(t.Context(), "user-1") - if err != nil || user == nil { - t.Fatalf("find user: %v", err) - } - playToken, err := svc.Auth.IssueExternalPlaybackToken(user, "media-1", 2*60*60) - if err != nil { - t.Fatalf("issue playback token: %v", err) - } - - req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/stream/media-1?token="+url.QueryEscape(playToken), nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusFound { - t.Fatalf("stream status = %d body=%s, want 302", w.Code, w.Body.String()) - } - loc := w.Header().Get("Location") - if !strings.Contains(loc, "/api/cloud/play/openlist?") || - !strings.Contains(loc, "media_id=media-1") || - !strings.Contains(loc, "token="+url.QueryEscape(playToken)) { - t.Fatalf("redirect Location should carry scoped token and media_id, got %q", loc) - } - - req = httptest.NewRequest(http.MethodGet, loc, nil) - w = httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code == http.StatusForbidden || w.Code == http.StatusUnauthorized { - t.Fatalf("cloud redirect rejected scoped token: status=%d body=%s", w.Code, w.Body.String()) - } - if w.Code != http.StatusServiceUnavailable { - t.Fatalf("cloud redirect status = %d body=%s, want storage service fallback 503", w.Code, w.Body.String()) - } -} - -func TestScopedPlaybackTokenCannotRetargetCloudRef(t *testing.T) { - router, svc, _ := newPlaybackScopeTestRouter(t) - user, err := svc.Repo.User.FindByID(t.Context(), "user-1") - if err != nil || user == nil { - t.Fatalf("find user: %v", err) - } - playToken, err := svc.Auth.IssueExternalPlaybackToken(user, "media-1", 2*60*60) - if err != nil { - t.Fatalf("issue playback token: %v", err) - } - - req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/cloud/play/openlist?ref=other&media_id=media-1&token="+url.QueryEscape(playToken), nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusForbidden { - t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) - } -} - func TestScopedPlaybackTokenCannotCallRegularAPI(t *testing.T) { router, svc, _ := newPlaybackScopeTestRouter(t) api := router.Group("/api") @@ -464,6 +407,5 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container, api.GET("/playback/:id/external-url", externalURLHandler(svc)) api.GET("/playback/:id/external-players", externalPlayersHandler(svc)) api.GET("/stream/:id", streamHandler(svc)) - api.GET("/cloud/play/:type", cloudPlayHandler(svc)) return router, svc, cfg.Secrets.JWTSecret } diff --git a/internal/handler/recycle.go b/internal/handler/recycle.go index e88e369..cc9d352 100644 --- a/internal/handler/recycle.go +++ b/internal/handler/recycle.go @@ -2,19 +2,13 @@ package handler import ( - "context" "net/http" - "strings" "github.com/gin-gonic/gin" "github.com/ShukeBta/MediaStationGo/internal/service" ) -type recycleBatchReq struct { - MediaIDs []string `json:"media_ids"` -} - func deleteMediaHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { if err := svc.Media.SoftDelete(c.Request.Context(), c.Param("id")); err != nil { @@ -25,17 +19,6 @@ func deleteMediaHandler(svc *service.Container) gin.HandlerFunc { } } -func listRecycleHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - items, err := svc.Media.ListRecycleBin(c.Request.Context(), 200) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": items}) - } -} - func restoreMediaHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { if err := svc.Media.RestoreDeleted(c.Request.Context(), c.Param("id")); err != nil { @@ -46,22 +29,6 @@ func restoreMediaHandler(svc *service.Container) gin.HandlerFunc { } } -func restoreMediaBatchHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req recycleBatchReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - applied, errorsOut := runRecycleBatch(c, compactManualScrapeIDs(req.MediaIDs), svc.Media.RestoreDeleted) - if applied == 0 && len(errorsOut) > 0 { - c.JSON(http.StatusInternalServerError, gin.H{"error": strings.Join(errorsOut, "\n")}) - return - } - c.JSON(http.StatusOK, gin.H{"applied": applied, "errors": errorsOut}) - } -} - func purgeMediaHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { if err := svc.Media.PurgeDeleted(c.Request.Context(), c.Param("id")); err != nil { @@ -71,35 +38,3 @@ func purgeMediaHandler(svc *service.Container) gin.HandlerFunc { c.Status(http.StatusNoContent) } } - -func purgeMediaBatchHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req recycleBatchReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - applied, errorsOut := runRecycleBatch(c, compactManualScrapeIDs(req.MediaIDs), svc.Media.PurgeDeleted) - if applied == 0 && len(errorsOut) > 0 { - c.JSON(http.StatusInternalServerError, gin.H{"error": strings.Join(errorsOut, "\n")}) - return - } - c.JSON(http.StatusOK, gin.H{"applied": applied, "errors": errorsOut}) - } -} - -func runRecycleBatch(c *gin.Context, ids []string, action func(context.Context, string) error) (int, []string) { - if len(ids) == 0 { - return 0, []string{"media_ids required"} - } - applied := 0 - errorsOut := make([]string, 0) - for _, id := range ids { - if err := action(c.Request.Context(), id); err != nil { - errorsOut = append(errorsOut, id+": "+err.Error()) - continue - } - applied++ - } - return applied, errorsOut -} diff --git a/internal/handler/repair_rescrape.go b/internal/handler/repair_rescrape.go deleted file mode 100644 index 017d876..0000000 --- a/internal/handler/repair_rescrape.go +++ /dev/null @@ -1,84 +0,0 @@ -// Package handler — batch repair+rescrape endpoint. -package handler - -import ( - "context" - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// repairAndRescrapeAllHandler 触发"全库修复+重刮"流程:先从媒体路径中的 -// {tmdb-N}/{bangumi-N} 等占位符回填缺失的外部 ID, 再对所有媒体库重刮一遍。 -// -// 路由: POST /api/admin/media/repair-rescrape (需 admin) -// 异步执行, 立即返回 202;通过 WS hub "scrape" topic 推送进度。 -func repairAndRescrapeAllHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - options, err := scrapeOptionsFromRequest(c, true) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scrape options"}) - return - } - task := startScrapeHTTPTask(svc, "全库修复并重刮", "", "") - go func(options service.ScrapeOptions) { - result, err := svc.RepairAndRescrapeAllLibraries(context.Background(), options) - metrics := map[string]int64{ - "repaired": int64(result.Repaired), - "reclassified": int64(result.Reclassified), - "libraries": int64(result.Libraries), - "matched": int64(result.Matched), - "processed": int64(result.Processed), - "errors": int64(result.Errors), - "reset": int64(result.Reset), - } - stage := "completed" - message := "全库修复并重刮完成" - if err != nil { - stage = "scrape" - message = "全库修复并重刮失败" - } - finishHTTPTask(task, err, stage, message, metrics, nil) - }(options) - c.JSON(http.StatusAccepted, gin.H{"status": "started"}) - } -} - -// repairAndRescrapeLibraryHandler 触发"单库修复+重刮":只对路径参数指定的 -// 媒体库回填占位符外部 ID 并重刮, 不影响其它库。 -// -// 路由: POST /api/admin/libraries/:id/repair-rescrape (需 admin) -// 异步执行, 立即返回 202;通过 WS hub "scrape" topic 推送进度。 -func repairAndRescrapeLibraryHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - libraryID := c.Param("id") - options, err := scrapeOptionsFromRequest(c, true) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scrape options"}) - return - } - task := startScrapeHTTPTask(svc, "媒体库修复并重刮", "", "") - go func(options service.ScrapeOptions) { - result, err := svc.RepairAndRescrapeLibrary(context.Background(), libraryID, options) - metrics := map[string]int64{ - "repaired": int64(result.Repaired), - "reclassified": int64(result.Reclassified), - "libraries": int64(result.Libraries), - "matched": int64(result.Matched), - "processed": int64(result.Processed), - "errors": int64(result.Errors), - "reset": int64(result.Reset), - } - stage := "completed" - message := "媒体库修复并重刮完成" - if err != nil { - stage = "scrape" - message = "媒体库修复并重刮失败" - } - finishHTTPTask(task, err, stage, message, metrics, nil) - }(options) - c.JSON(http.StatusAccepted, gin.H{"status": "started"}) - } -} diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index 3651963..bd1f5e8 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -14,17 +14,10 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service. admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired()) registerAdminUserRoutes(admin, svc) registerAdminPermissionRoutes(admin, svc) - registerAdminStorageRoutes(admin, svc) - registerAdminCloudRoutes(admin, svc) - registerAdminDownloadClientRoutes(admin, svc) registerAdminSystemRoutes(admin, svc) registerAdminBackupRoutes(admin, svc) - registerAdminNotificationRoutes(admin, svc) - registerAdminTelegramRoutes(admin, svc) registerAdminOrganizerRoutes(admin, svc) - registerAdminRepairRoutes(admin, svc) registerAdminAPIConfigRoutes(admin, svc) - registerAdminSchedulerRoutes(admin, svc) registerAdminRecognitionWordRoutes(admin, svc) } @@ -48,39 +41,7 @@ func registerAdminPermissionRoutes(admin *gin.RouterGroup, svc *service.Containe admin.POST("/users/:id/permissions/reset", resetUserPermissionsHandler(svc)) } -func registerAdminStorageRoutes(admin *gin.RouterGroup, svc *service.Container) { - admin.GET("/storage/status", listStorageConfigsHandler(svc)) - admin.GET("/storage/:type", getStorageConfigHandler(svc)) - admin.PUT("/storage/:type", saveStorageConfigHandler(svc)) - admin.POST("/storage/:type/test", testStorageConfigHandler(svc)) - admin.POST("/storage/:type/logout", logoutStorageConfigHandler(svc)) - admin.POST("/storage/:type/upload-local", storageUploadLocalHandler(svc)) -} - -func registerAdminCloudRoutes(admin *gin.RouterGroup, svc *service.Container) { - admin.POST("/cloud/scan-all", cloudScanAllHandler(svc)) - admin.POST("/cloud/scan/cancel", cloudScanCancelHandler(svc)) - admin.GET("/cloud/scan/status", cloudScanStatusHandler(svc)) - admin.GET("/cloud/:type/list", cloudListHandler(svc)) - admin.POST("/cloud/:type/mkdir", cloudMkdirHandler(svc)) - admin.PUT("/cloud/:type/rename", cloudRenameHandler(svc)) - admin.POST("/cloud/:type/import", cloudImportHandler(svc)) - admin.POST("/cloud/:type/mount", cloudMountHandler(svc)) - admin.POST("/cloud/:type/qr/start", cloud115QRStartHandler(svc)) - admin.POST("/cloud/:type/qr/poll", cloud115QRPollHandler(svc)) -} - -func registerAdminDownloadClientRoutes(admin *gin.RouterGroup, svc *service.Container) { - admin.GET("/download/clients", listDownloadClientsHandler(svc)) - admin.POST("/download/clients", createDownloadClientHandler(svc)) - admin.PUT("/download/clients/:id", updateDownloadClientHandler(svc)) - admin.DELETE("/download/clients/:id", deleteDownloadClientHandler(svc)) - admin.POST("/download/clients/:id/test", testDownloadClientHandler(svc)) - admin.GET("/download/aria2/stats", aria2StatsHandler(svc)) -} - func registerAdminSystemRoutes(admin *gin.RouterGroup, svc *service.Container) { - admin.POST("/system/scheduler/:name/trigger", schedulerTriggerHandler(svc)) admin.GET("/system/update", systemUpdateStatusHandler(svc)) admin.POST("/system/update/check", systemUpdateCheckHandler(svc)) admin.POST("/system/update/apply", systemUpdateApplyHandler(svc)) @@ -93,22 +54,6 @@ func registerAdminBackupRoutes(admin *gin.RouterGroup, svc *service.Container) { admin.POST("/backups/restore", restoreBackupHandler(svc)) } -func registerAdminNotificationRoutes(admin *gin.RouterGroup, svc *service.Container) { - admin.POST("/notify/test", notifyTestHandler(svc)) - admin.GET("/notify/channels", listNotifyChannelsHandler(svc)) - admin.POST("/notify/channels", createNotifyChannelHandler(svc)) - admin.PUT("/notify/channels/:id", updateNotifyChannelHandler(svc)) - admin.DELETE("/notify/channels/:id", deleteNotifyChannelHandler(svc)) - admin.POST("/notify/channels/:id/test", testNotifyChannelHandler(svc)) -} - -func registerAdminTelegramRoutes(admin *gin.RouterGroup, svc *service.Container) { - admin.GET("/telegram/webhook", telegramGetWebhookHandler(svc)) - admin.POST("/telegram/webhook", telegramSetWebhookHandler(svc)) - admin.POST("/telegram/polling/start", telegramStartPollingHandler(svc)) - admin.POST("/telegram/polling/stop", telegramStopPollingHandler(svc)) -} - func registerAdminOrganizerRoutes(admin *gin.RouterGroup, svc *service.Container) { admin.POST("/media/:id/organize", organizeMediaHandler(svc)) admin.POST("/libraries/:id/organize", organizeLibraryHandler(svc)) @@ -116,11 +61,6 @@ func registerAdminOrganizerRoutes(admin *gin.RouterGroup, svc *service.Container admin.POST("/organize/source", organizeDirectoryHandler(svc)) } -func registerAdminRepairRoutes(admin *gin.RouterGroup, svc *service.Container) { - admin.POST("/media/repair-rescrape", repairAndRescrapeAllHandler(svc)) - admin.POST("/libraries/:id/repair-rescrape", repairAndRescrapeLibraryHandler(svc)) -} - func registerAdminAPIConfigRoutes(admin *gin.RouterGroup, svc *service.Container) { admin.GET("/api-configs", listAPIConfigsHandler(svc)) admin.GET("/api-configs/:provider", getAPIConfigHandler(svc)) @@ -129,11 +69,6 @@ func registerAdminAPIConfigRoutes(admin *gin.RouterGroup, svc *service.Container admin.POST("/api-configs/:provider/test", testAPIConfigHandler(svc)) } -func registerAdminSchedulerRoutes(admin *gin.RouterGroup, svc *service.Container) { - admin.GET("/scheduler", schedulerStatusHandler(svc)) - admin.POST("/scheduler/:name/run", schedulerRunHandler(svc)) -} - func registerAdminRecognitionWordRoutes(admin *gin.RouterGroup, svc *service.Container) { admin.GET("/recognition-words", getRecognitionWordsHandler(svc)) admin.PUT("/recognition-words", saveRecognitionWordsHandler(svc)) diff --git a/internal/handler/routes_admin_test.go b/internal/handler/routes_admin_test.go index 5f54e73..fd5d2a3 100644 --- a/internal/handler/routes_admin_test.go +++ b/internal/handler/routes_admin_test.go @@ -26,17 +26,9 @@ func TestAdminRouteSurfacesAreRegistered(t *testing.T) { for _, want := range []string{ "GET /api/admin/users", "GET /api/admin/users/:id/permissions", - "GET /api/admin/storage/status", - "GET /api/admin/cloud/:type/list", - "GET /api/admin/download/clients", - "POST /api/admin/system/scheduler/:name/trigger", "POST /api/admin/backups", - "GET /api/admin/notify/channels", - "GET /api/admin/telegram/webhook", "GET /api/admin/organize/sources", - "POST /api/admin/media/repair-rescrape", "GET /api/admin/api-configs", - "POST /api/admin/scheduler/:name/run", } { if !routes[want] { t.Fatalf("%s route is not registered", want) diff --git a/internal/handler/routes_authenticated.go b/internal/handler/routes_authenticated.go index e810cea..b7e4992 100644 --- a/internal/handler/routes_authenticated.go +++ b/internal/handler/routes_authenticated.go @@ -19,26 +19,15 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc * registerAuthedMediaRoutes(authed, svc) registerAuthedPlaybackAndProxyRoutes(authed, svc) registerAuthedCollectionRoutes(authed, svc) - registerAuthedDownloadRoutes(authed, svc) - registerAuthedSubscriptionRoutes(authed, svc) - registerAuthedStatsDiscoveryAndAIRoutes(authed, svc) registerAuthedFileRoutes(authed, svc) registerAuthedDLNARoutes(authed, svc) - registerAuthedSTRMRoutes(authed, svc) - registerAuthedDuplicateRoutes(authed, svc) - registerAuthedSiteRoutes(authed, svc) registerAuthedRecycleAndRealtimeRoutes(authed, svc) - registerAuthedSchedulerRoutes(authed, svc) registerAuthedUISurfaceRoutes(authed, svc) registerAuthedSearchRoutes(authed, svc) registerAuthedSystemExtraRoutes(authed, svc) registerAuthedStatsExtraRoutes(authed, svc) - registerAuthedSitesExtraRoutes(authed, svc) - registerAuthedSubscriptionExtraRoutes(authed, svc) registerAuthedPlaylistExtraRoutes(authed, svc) registerAuthedDLNAControlRoutes(authed, svc) registerAuthedFavoriteAndMediaActionRoutes(authed, svc) registerAuthedPlaybackExtraRoutes(authed, svc) - registerAuthedDownloadOpsRoutes(authed, svc) - registerAuthedAssistantRoutes(authed, svc) } diff --git a/internal/handler/routes_authenticated_core.go b/internal/handler/routes_authenticated_core.go index 4a0caa1..ecfc179 100644 --- a/internal/handler/routes_authenticated_core.go +++ b/internal/handler/routes_authenticated_core.go @@ -14,10 +14,6 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co authed.POST("/me/logout", logoutHandler(svc)) authed.GET("/auth/permissions", getMyPermissionsHandler(svc)) - - authed.GET("/license/status", middleware.AdminRequired(), licenseStatusHandler(svc)) - authed.POST("/license/activate", middleware.AdminRequired(), licenseActivateHandler(svc)) - authed.POST("/license/heartbeat", middleware.AdminRequired(), licenseHeartbeatHandler(svc)) } func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container) { @@ -65,11 +61,6 @@ func registerAuthedPlaybackAndProxyRoutes(authed *gin.RouterGroup, svc *service. authed.GET("/hls/:id/:seg", hlsSegmentHandler(svc)) authed.DELETE("/hls/:id", stopTranscodeHandler(svc)) - authed.GET("/cloud/play/:type", cloudPlayHandler(svc)) - authed.HEAD("/cloud/play/:type", cloudPlayHandler(svc)) - - authed.GET("/img/cloud/:type", cloudArtworkProxyHandler(svc)) - authed.HEAD("/img/cloud/:type", cloudArtworkProxyHandler(svc)) authed.GET("/img", imageProxyHandler(svc)) } diff --git a/internal/handler/routes_authenticated_extras.go b/internal/handler/routes_authenticated_extras.go index 7e4b256..bc712a2 100644 --- a/internal/handler/routes_authenticated_extras.go +++ b/internal/handler/routes_authenticated_extras.go @@ -16,18 +16,8 @@ func registerAuthedUISurfaceRoutes(authed *gin.RouterGroup, svc *service.Contain authed.DELETE("/watch-history", historyDeleteHandler(svc)) authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc)) - authed.GET("/discover/sections", requirePermission(svc, "can_view_discover"), discoverSectionsHandler(svc)) - authed.GET("/discover/feed", requirePermission(svc, "can_view_discover"), discoverFeedHandler(svc)) - authed.GET("/system/info", systemInfoHandler(svc)) authed.GET("/system/status", systemStatusHandler(svc)) - authed.GET("/system/scheduler", systemSchedulerHandler(svc)) - - authed.GET("/stats/overview", statsOverviewHandler(svc)) - authed.GET("/stats/trend", statsTrendHandler(svc)) - authed.GET("/stats/top-content", statsTopContentHandler(svc)) - authed.GET("/stats/libraries", statsLibrariesHandler(svc)) - authed.GET("/stats/monitor", statsMonitorHandler(svc)) authed.GET("/play-profiles", listPlayProfilesHandler(svc)) authed.POST("/play-profiles", createPlayProfileHandler(svc)) @@ -40,7 +30,6 @@ func registerAuthedSearchRoutes(authed *gin.RouterGroup, svc *service.Container) authed.GET("/search", searchUnifiedHandler(svc)) authed.GET("/search/advanced", searchAdvancedHandler(svc)) authed.GET("/search/tmdb", searchTMDbHandler(svc)) - authed.GET("/search/sites", searchSitesHandler(svc)) } func registerAuthedSystemExtraRoutes(authed *gin.RouterGroup, svc *service.Container) { @@ -55,16 +44,6 @@ func registerAuthedStatsExtraRoutes(authed *gin.RouterGroup, svc *service.Contai authed.POST("/stats/play", statsPlayHandler(svc)) } -func registerAuthedSitesExtraRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.GET("/sites/:id/resource", requirePermission(svc, "can_manage_sites"), siteResourceHandler(svc)) - authed.GET("/sites/:id/userdata", requirePermission(svc, "can_manage_sites"), siteUserdataHandler(svc)) -} - -func registerAuthedSubscriptionExtraRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.PUT("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), updateSubscriptionHandler(svc)) - authed.POST("/subscriptions/:id/search", requirePermission(svc, "can_manage_subscriptions"), searchSubscriptionHandler(svc)) -} - func registerAuthedPlaylistExtraRoutes(authed *gin.RouterGroup, svc *service.Container) { authed.POST("/playlists/:id/reorder", reorderPlaylistHandler(svc)) authed.DELETE("/playlists/:id/items/by-id/:item_id", deletePlaylistItemByIDHandler(svc)) @@ -94,24 +73,3 @@ func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Con authed.GET("/playback/:id/external-url", externalURLHandler(svc)) authed.GET("/playback/transcode/:job_id/status", transcodeStatusHandler(svc)) } - -func registerAuthedDownloadOpsRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.POST("/download/:id/pause", requirePermission(svc, "can_manage_downloads"), downloadPauseHandler(svc)) - authed.POST("/download/:id/resume", requirePermission(svc, "can_manage_downloads"), downloadResumeHandler(svc)) - authed.POST("/download/:id/organize", requirePermission(svc, "can_manage_files"), downloadOrganizeOneHandler(svc)) - authed.POST("/download/organize", requirePermission(svc, "can_manage_files"), downloadOrganizeAllHandler(svc)) - authed.POST("/download/sync", requirePermission(svc, "can_manage_downloads"), downloadSyncHandler(svc)) - authed.POST("/download/start-auto-sync", requirePermission(svc, "can_manage_downloads"), downloadAutoSyncHandler(svc)) - authed.GET("/download/tasks", requirePermission(svc, "can_manage_downloads"), downloadTasksAliasHandler(svc)) -} - -func registerAuthedAssistantRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.GET("/admin/assistant/sessions", listAssistantSessionsHandler(svc)) - authed.POST("/admin/assistant/sessions", createAssistantSessionHandler(svc)) - authed.GET("/admin/assistant/session/:id", getAssistantSessionHandler(svc)) - authed.DELETE("/admin/assistant/session/:id", deleteAssistantSessionHandler(svc)) - authed.POST("/admin/assistant/chat", assistantChatHandler(svc)) - authed.POST("/admin/assistant/execute", assistantExecuteHandler(svc)) - authed.POST("/admin/assistant/undo/:op_id", assistantUndoHandler(svc)) - authed.GET("/admin/assistant/history", assistantHistoryHandler(svc)) -} diff --git a/internal/handler/routes_authenticated_features.go b/internal/handler/routes_authenticated_features.go index b902768..3ca7e16 100644 --- a/internal/handler/routes_authenticated_features.go +++ b/internal/handler/routes_authenticated_features.go @@ -7,35 +7,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/service" ) -func registerAuthedDownloadRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.GET("/downloads", requirePermission(svc, "can_manage_downloads"), listDownloadsHandler(svc)) - authed.POST("/downloads", requirePermission(svc, "can_manage_downloads"), addDownloadHandler(svc)) - authed.DELETE("/downloads/:hash", requirePermission(svc, "can_manage_downloads"), deleteDownloadHandler(svc)) - authed.POST("/downloads/relocate", requirePermission(svc, "can_manage_downloads"), relocateDownloadHandler(svc)) - authed.POST("/downloads/reload", requirePermission(svc, "can_manage_downloads"), reloadDownloadConfigHandler(svc)) -} - -func registerAuthedSubscriptionRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.GET("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionsHandler(svc)) - authed.GET("/subscriptions/history", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionHistoryHandler(svc)) - authed.POST("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), createSubscriptionHandler(svc)) - authed.DELETE("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), deleteSubscriptionHandler(svc)) - authed.POST("/subscriptions/:id/restore", requirePermission(svc, "can_manage_subscriptions"), restoreSubscriptionHandler(svc)) - authed.POST("/subscriptions/:id/run", requirePermission(svc, "can_manage_subscriptions"), runSubscriptionHandler(svc)) -} - -func registerAuthedStatsDiscoveryAndAIRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.GET("/stats", statsHandler(svc)) - authed.GET("/tasks", middleware.AdminRequired(), tasksHandler(svc)) - - authed.GET("/discover/trending", requirePermission(svc, "can_view_discover"), trendingHandler(svc)) - authed.GET("/discover/popular", requirePermission(svc, "can_view_discover"), popularHandler(svc)) - - authed.GET("/ai/status", requirePermission(svc, "can_use_ai"), aiStatusHandler(svc)) - authed.POST("/ai/search", requirePermission(svc, "can_use_ai"), smartSearchHandler(svc)) - authed.GET("/ai/recommend", requirePermission(svc, "can_use_ai"), aiRecommendHandler(svc)) -} - func registerAuthedFileRoutes(authed *gin.RouterGroup, svc *service.Container) { authed.GET("/files", middleware.AdminRequired(), browseFilesHandler(svc)) authed.POST("/files/folders", middleware.AdminRequired(), createFolderHandler(svc)) @@ -49,46 +20,7 @@ func registerAuthedDLNARoutes(authed *gin.RouterGroup, svc *service.Container) { authed.POST("/dlna/cast", dlnaCastHandler(svc)) } -func registerAuthedSTRMRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.PUT("/media/:id/strm", middleware.AdminRequired(), setSTRMHandler(svc)) - authed.DELETE("/media/:id/strm", middleware.AdminRequired(), clearSTRMHandler(svc)) - authed.GET("/strm/output-presets", middleware.AdminRequired(), listSTRMOutputPresetsHandler(svc)) - authed.POST("/strm/import", middleware.AdminRequired(), importSTRMHandler(svc)) - authed.POST("/strm/generate", middleware.AdminRequired(), generateSTRMHandler(svc)) - authed.POST("/strm/generate-from-tree", middleware.AdminRequired(), generateSTRMFromTreeHandler(svc)) - authed.POST("/strm/repair", middleware.AdminRequired(), repairSTRMHandler(svc)) -} - -func registerAuthedDuplicateRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.GET("/duplicates", middleware.AdminRequired(), listDuplicatesHandler(svc)) - authed.POST("/duplicates/scan", middleware.AdminRequired(), detectDuplicatesHandler(svc)) - authed.POST("/duplicates/unmark", middleware.AdminRequired(), unmarkDuplicatesHandler(svc)) -} - -func registerAuthedSiteRoutes(authed *gin.RouterGroup, svc *service.Container) { - siteHandler := NewSiteHandler(svc) - authed.GET("/sites", requirePermission(svc, "can_manage_sites"), siteHandler.ListSites) - authed.GET("/sites/types", requirePermission(svc, "can_manage_sites"), siteHandler.GetSiteTypes) - authed.GET("/sites/auth-types", requirePermission(svc, "can_manage_sites"), siteHandler.GetAuthTypes) - authed.POST("/sites", requirePermission(svc, "can_manage_sites"), siteHandler.CreateSite) - authed.GET("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.GetSite) - authed.PUT("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.UpdateSite) - authed.DELETE("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.DeleteSite) - authed.POST("/sites/:id/test", requirePermission(svc, "can_manage_sites"), siteHandler.TestSite) - authed.GET("/sites/search", requirePermission(svc, "can_manage_sites"), siteSearchHandler(svc)) -} - func registerAuthedRecycleAndRealtimeRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc)) - authed.POST("/recycle/restore", middleware.AdminRequired(), restoreMediaBatchHandler(svc)) - authed.POST("/recycle/purge", middleware.AdminRequired(), purgeMediaBatchHandler(svc)) - authed.GET("/ws", wsHandler(svc)) authed.GET("/events", sseHandler(svc)) } - -func registerAuthedSchedulerRoutes(authed *gin.RouterGroup, svc *service.Container) { - authed.GET("/scheduler/tasks", schedulerListTasksHandler(svc)) - authed.POST("/scheduler/tasks/:id/run", middleware.AdminRequired(), schedulerRunTaskHandler(svc)) - authed.GET("/scheduler/status", schedulerGetStatusHandler(svc)) -} diff --git a/internal/handler/routes_authenticated_test.go b/internal/handler/routes_authenticated_test.go index 0b459e1..aad47b1 100644 --- a/internal/handler/routes_authenticated_test.go +++ b/internal/handler/routes_authenticated_test.go @@ -23,22 +23,16 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) { routes[route.Method+" "+route.Path] = true } - for _, want := range []string{ - "GET /api/me", - "GET /api/auth/permissions", - "GET /api/libraries", - "GET /api/media", - "GET /api/stream/:id", - "GET /api/storage", - "GET /api/downloads", - "GET /api/subscriptions", - "GET /api/sites/search", - "GET /api/watch-history", - "GET /api/discover/feed", - "GET /api/playback/:id/info", - "GET /api/download/tasks", - "GET /api/admin/assistant/history", - } { + for _, want := range []string{ + "GET /api/me", + "GET /api/auth/permissions", + "GET /api/libraries", + "GET /api/media", + "GET /api/stream/:id", + "GET /api/storage", + "GET /api/watch-history", + "GET /api/playback/:id/info", + } { if !routes[want] { t.Fatalf("%s route is not registered", want) } diff --git a/internal/handler/scheduler.go b/internal/handler/scheduler.go deleted file mode 100644 index 77ca3d8..0000000 --- a/internal/handler/scheduler.go +++ /dev/null @@ -1,42 +0,0 @@ -// Package handler — scheduled jobs admin page. -package handler - -import ( - "errors" - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func schedulerStatusHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"jobs": svc.Scheduler.Status()}) - } -} - -func schedulerRunHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - name := c.Param("name") - if !triggerSchedulerJob(c, svc, name) { - return - } - c.JSON(http.StatusAccepted, gin.H{"ok": true, "message": "任务已在后台触发"}) - } -} - -func triggerSchedulerJob(c *gin.Context, svc *service.Container, name string) bool { - if err := svc.Scheduler.RunNowAsync(c.Request.Context(), name); err != nil { - switch { - case errors.Is(err, service.ErrSchedulerJobAlreadyRunning): - c.JSON(http.StatusConflict, gin.H{"error": "任务正在运行,请稍后到实时任务查看进度"}) - case errors.Is(err, service.ErrSchedulerJobNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": "任务不存在"}) - default: - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - } - return false - } - return true -} diff --git a/internal/handler/scheduler_handler.go b/internal/handler/scheduler_handler.go deleted file mode 100644 index 75ec673..0000000 --- a/internal/handler/scheduler_handler.go +++ /dev/null @@ -1,42 +0,0 @@ -// Package handler — 定时任务管理 HTTP 端点。 -package handler - -import ( - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// SchedulerHandler 处理定时任务的查询和管理操作。 -type SchedulerHandler struct { - svc *service.Container - log *zap.Logger -} - -// NewSchedulerHandler 创建定时任务处理器。 -func NewSchedulerHandler(svc *service.Container, log *zap.Logger) *SchedulerHandler { - return &SchedulerHandler{svc: svc, log: log} -} - -// ListTasks 返回所有定时任务列表。 -func (h *SchedulerHandler) ListTasks(c *gin.Context) { - tasks := h.svc.Scheduler.Status() - Success(c, tasks) -} - -// RunTask 手动触发指定任务。 -func (h *SchedulerHandler) RunTask(c *gin.Context) { - name := c.Param("id") - if !triggerSchedulerJob(c, h.svc, name) { - return - } - - SuccessWithMessage(c, "任务已触发后台执行", nil) -} - -// GetStatus 返回调度器运行状态。 -func (h *SchedulerHandler) GetStatus(c *gin.Context) { - status := h.svc.Scheduler.Status() - Success(c, status) -} diff --git a/internal/handler/search_extra.go b/internal/handler/search_extra.go index 0375f4f..20fea64 100644 --- a/internal/handler/search_extra.go +++ b/internal/handler/search_extra.go @@ -83,24 +83,3 @@ func searchTMDbHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusOK, gin.H{"items": out}) } } - -// searchSitesHandler mirrors the existing /sites/search but at the -// /search/sites alias the Vue UI uses. -func searchSitesHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - keyword := c.Query("keyword") - if keyword == "" { - keyword = c.Query("q") - } - if keyword == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "keyword required"}) - return - } - results, err := svc.Site.Search(c.Request.Context(), keyword) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": results}) - } -} diff --git a/internal/handler/site_handler.go b/internal/handler/site_handler.go deleted file mode 100644 index 117a6ba..0000000 --- a/internal/handler/site_handler.go +++ /dev/null @@ -1,170 +0,0 @@ -// Package handler — PT 站点管理 HTTP 处理。 -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// SiteHandler 站点管理 CRUD。 -type SiteHandler struct { - svc *service.Container -} - -// NewSiteHandler 创建站点管理 Handler。 -func NewSiteHandler(svc *service.Container) *SiteHandler { - return &SiteHandler{svc: svc} -} - -// ListSites 列出所有站点。 -func (h *SiteHandler) ListSites(c *gin.Context) { - sites, err := h.svc.Site.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "message": err.Error()}) - return - } - if sites == nil { - sites = []model.Site{} - } - c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": sites}) -} - -// GetSite 获取单个站点详情。 -func (h *SiteHandler) GetSite(c *gin.Context) { - site, err := h.svc.Site.FindByID(c.Request.Context(), c.Param("id")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "message": err.Error()}) - return - } - if site == nil { - c.JSON(http.StatusNotFound, gin.H{"code": 1, "message": "site not found"}) - return - } - c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": site}) -} - -// CreateSite 创建站点。 -func (h *SiteHandler) CreateSite(c *gin.Context) { - var body map[string]any - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()}) - return - } - - var site model.Site - // 手动映射字段(因为敏感字段有 json:"-" 标签,不会自动反序列化) - if v, ok := body["name"].(string); ok { - site.Name = v - } - if v, ok := body["url"].(string); ok { - site.URL = v - } - if v, ok := body["type"].(string); ok { - site.Type = v - } - if v, ok := body["auth_type"].(string); ok { - site.AuthType = v - } - if v, ok := body["api_key"].(string); ok { - site.APIKey = v - } - if v, ok := body["cookie"].(string); ok { - site.Cookie = v - } - if v, ok := body["auth_header"].(string); ok { - site.AuthHeader = v - } - if v, ok := body["enabled"].(bool); ok { - site.Enabled = v - } - if v, ok := body["is_default"].(bool); ok { - site.IsDefault = v - } - if v, ok := body["extra"].(string); ok { - site.Extra = v - } - // 高级设置字段 - if v, ok := body["user_agent"].(string); ok { - site.UserAgent = v - } - if v, ok := body["rss_url"].(string); ok { - site.RSSURL = v - } - if v, ok := body["timeout"].(float64); ok { - site.Timeout = int(v) - } - if v, ok := body["priority"].(float64); ok { - site.Priority = int(v) - } - if v, ok := body["use_proxy"].(bool); ok { - site.UseProxy = v - } - if v, ok := body["rate_limit"].(bool); ok { - site.RateLimit = v - } - if v, ok := body["browser_emulation"].(bool); ok { - site.BrowserEmulation = v - } - if v, ok := body["downloader"].(string); ok { - site.Downloader = v - } - - if err := h.svc.Site.Create(c.Request.Context(), &site); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()}) - return - } - c.JSON(http.StatusCreated, gin.H{"code": 0, "message": "ok", "data": site}) -} - -// UpdateSite 更新站点。 -func (h *SiteHandler) UpdateSite(c *gin.Context) { - patch := make(map[string]any) - if err := c.ShouldBindJSON(&patch); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()}) - return - } - // 字段名映射:前端使用蛇形命名,GORM 会正确映射到列名 - // 无需额外处理,Updates 直接使用 patch 中的 key-value - if err := h.svc.Site.Update(c.Request.Context(), c.Param("id"), patch); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok"}) -} - -// DeleteSite 删除站点。 -func (h *SiteHandler) DeleteSite(c *gin.Context) { - if err := h.svc.Site.Delete(c.Request.Context(), c.Param("id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "message": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok"}) -} - -// TestSite 测试站点连通性。 -func (h *SiteHandler) TestSite(c *gin.Context) { - ok, msg, err := h.svc.Site.TestConnection(c.Request.Context(), c.Param("id")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "message": err.Error()}) - return - } - if !ok { - c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": msg}) - return - } - c.JSON(http.StatusOK, gin.H{"code": 0, "message": msg}) -} - -// GetSiteTypes 返回支持的站点类型列表。 -func (h *SiteHandler) GetSiteTypes(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": model.SiteTypes()}) -} - -// GetAuthTypes 返回支持的认证方式列表。 -func (h *SiteHandler) GetAuthTypes(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": model.AuthTypes()}) -} diff --git a/internal/handler/sites.go b/internal/handler/sites.go deleted file mode 100644 index 9813ef0..0000000 --- a/internal/handler/sites.go +++ /dev/null @@ -1,164 +0,0 @@ -// Package handler — site management (PT/BT tracker CRUD + cross-site search). -package handler - -import ( - "encoding/json" - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// ─── CRUD ──────────────────────────────────────────────────────────────────── - -func listSitesHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - sites, err := svc.Site.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": sites}) - } -} - -func getSiteHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - site, err := svc.Site.FindByID(c.Request.Context(), c.Param("id")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if site == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return - } - c.JSON(http.StatusOK, site) - } -} - -type createSiteReq struct { - Name string `json:"name" binding:"required"` - BaseURL string `json:"base_url" binding:"required"` - SiteType string `json:"site_type"` - AuthType string `json:"auth_type"` - Cookie string `json:"cookie"` - APIKey string `json:"api_key"` - AuthHeader string `json:"auth_header"` - UserAgent string `json:"user_agent"` - RSSURL string `json:"rss_url"` - Timeout int `json:"timeout"` - Priority int `json:"priority"` - UseProxy bool `json:"use_proxy"` - Enabled *bool `json:"enabled"` - Downloader string `json:"downloader"` -} - -func createSiteHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req createSiteReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - enabled := true - if req.Enabled != nil { - enabled = *req.Enabled - } - // Pack fields not in the core model into Extra JSON. - extraMap := map[string]any{} - if req.UserAgent != "" { - extraMap["user_agent"] = req.UserAgent - } - if req.RSSURL != "" { - extraMap["rss_url"] = req.RSSURL - } - if req.Timeout > 0 { - extraMap["timeout"] = req.Timeout - } - if req.Priority > 0 { - extraMap["priority"] = req.Priority - } - extraMap["use_proxy"] = req.UseProxy - if req.Downloader != "" { - extraMap["downloader"] = req.Downloader - } - extraJSON, _ := json.Marshal(extraMap) - - site := &model.Site{ - Name: req.Name, - URL: req.BaseURL, - Type: req.SiteType, - AuthType: req.AuthType, - Cookie: req.Cookie, - APIKey: req.APIKey, - AuthHeader: req.AuthHeader, - Extra: string(extraJSON), - Enabled: enabled, - } - if err := svc.Site.Create(c.Request.Context(), site); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusCreated, site) - } -} - -func updateSiteHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var patch map[string]any - if err := c.ShouldBindJSON(&patch); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := svc.Site.Update(c.Request.Context(), c.Param("id"), patch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - // Return the updated row. - site, _ := svc.Site.FindByID(c.Request.Context(), c.Param("id")) - c.JSON(http.StatusOK, site) - } -} - -func deleteSiteHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.Site.Delete(c.Request.Context(), c.Param("id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.Status(http.StatusNoContent) - } -} - -// ─── Connection test ───────────────────────────────────────────────────────── - -func testSiteHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - ok, msg, err := svc.Site.TestConnection(c.Request.Context(), c.Param("id")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"success": ok, "message": msg}) - } -} - -// ─── Cross-site search ─────────────────────────────────────────────────────── - -func siteSearchHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - keyword := c.Query("keyword") - if keyword == "" { - keyword = c.Query("q") - } - results, err := svc.Site.Search(c.Request.Context(), keyword) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": results, "total": len(results)}) - } -} diff --git a/internal/handler/sites_extra.go b/internal/handler/sites_extra.go deleted file mode 100644 index f9385ac..0000000 --- a/internal/handler/sites_extra.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package handler — extra site endpoints used by the Vue UI: -// -// GET /sites/:id/resource → keyword search scoped to one site -// GET /sites/:id/userdata → cookie-derived user info (stubbed) -package handler - -import ( - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// siteResourceHandler runs a search restricted to a single site. -// -// We reuse the full SiteService.Search() and post-filter by site_id; -// it's not the hottest path so we trade simplicity for speed here. -func siteResourceHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - keyword := c.Query("keyword") - if keyword == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "keyword required"}) - return - } - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - items, err := svc.Site.SearchSite(c.Request.Context(), c.Param("id"), keyword, page) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)}) - } -} - -// siteUserdataHandler returns whatever the site exposes about the -// authenticated user (upload/download stats, ratio, etc.). This is a -// stub: we report the cookie length so the UI can confirm a login is -// present, but full per-site parsing is out of scope here. -func siteUserdataHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - s, err := svc.Site.FindByID(c.Request.Context(), c.Param("id")) - if err != nil || s == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "site not found"}) - return - } - loginStatus := "unknown" - if s.LastError == "ok" { - loginStatus = "ok" - } else if s.LastError != "" { - loginStatus = "fail" - } - c.JSON(http.StatusOK, gin.H{ - "site_id": s.ID, - "name": s.Name, - "cookie_set": len(s.Cookie) > 0, - "login_status": loginStatus, - "note": "userdata parsing not implemented; stub", - }) - } -} diff --git a/internal/handler/stats.go b/internal/handler/stats.go deleted file mode 100644 index 932fd80..0000000 --- a/internal/handler/stats.go +++ /dev/null @@ -1,154 +0,0 @@ -// Package handler — stats / dashboard endpoints. -package handler - -import ( - "crypto/sha1" - "encoding/hex" - "net/http" - "sort" - "strings" - "time" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func statsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - snap, err := svc.Stats.Compute(c.Request.Context(), svc.Cfg.App.DataDir) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if err := applyStatsVisibility(c, svc, snap); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, snap) - } -} - -func applyStatsVisibility(c *gin.Context, svc *service.Container, snap *service.Snapshot) error { - visibility := mediaVisibilityForRequest(c, svc) - libs, err := svc.Repo.Library.List(c.Request.Context()) - if err != nil { - return err - } - libs = service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, libs) - var visibleLibraries int64 - activeLibraryIDs := make([]string, 0, len(libs)) - for _, lib := range libs { - if !lib.Enabled { - continue - } - activeLibraryIDs = append(activeLibraryIDs, lib.ID) - if service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, lib, visibility) { - visibleLibraries++ - } - } - snap.Libraries = visibleLibraries - cacheKey := visibleStatsCacheKey(visibility, activeLibraryIDs) - if svc.Cache != nil { - var cached visibleStatsCacheValue - if svc.Cache.GetJSON(c.Request.Context(), cacheKey, &cached) { - snap.Libraries = cached.Libraries - snap.MediaCount = cached.MediaCount - snap.TotalSizeBytes = cached.TotalSizeBytes - snap.TotalSeconds = cached.TotalSeconds - snap.RecentlyAdded = cached.RecentlyAdded - return nil - } - } - - q := applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility) - q = applyActiveLibraryQuery(q, activeLibraryIDs) - if err := q.Count(&snap.MediaCount).Error; err != nil { - return err - } - type sumRow struct { - Size int64 - Seconds int64 - } - var sum sumRow - if err := applyActiveLibraryQuery(applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility), activeLibraryIDs). - Select("COALESCE(SUM(size_bytes),0) as size, COALESCE(SUM(duration_sec),0) as seconds"). - Scan(&sum).Error; err != nil { - return err - } - snap.TotalSizeBytes = sum.Size - snap.TotalSeconds = sum.Seconds - - var recent []model.Media - if err := applyActiveLibraryQuery(applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility), activeLibraryIDs). - Order("created_at desc"). - Limit(12). - Find(&recent).Error; err != nil { - return err - } - snap.RecentlyAdded = recent - if svc.Cache != nil { - svc.Cache.SetJSON(c.Request.Context(), cacheKey, visibleStatsCacheValue{ - Libraries: snap.Libraries, - MediaCount: snap.MediaCount, - TotalSizeBytes: snap.TotalSizeBytes, - TotalSeconds: snap.TotalSeconds, - RecentlyAdded: snap.RecentlyAdded, - }, 10*time.Second) - } - return nil -} - -type visibleStatsCacheValue struct { - Libraries int64 `json:"libraries"` - MediaCount int64 `json:"media_count"` - TotalSizeBytes int64 `json:"total_size_bytes"` - TotalSeconds int64 `json:"total_seconds"` - RecentlyAdded []model.Media `json:"recently_added"` -} - -func visibleStatsCacheKey(visibility service.MediaVisibility, activeLibraryIDs []string) string { - allowed := append([]string(nil), visibility.AllowedLibraryIDs...) - hidden := append([]string(nil), visibility.HiddenLibraryIDs...) - active := append([]string(nil), activeLibraryIDs...) - sort.Strings(allowed) - sort.Strings(hidden) - sort.Strings(active) - sum := sha1.Sum([]byte(strings.Join([]string{ - "visible", - strings.Join(active, ","), - strings.Join(allowed, ","), - strings.Join(hidden, ","), - boolString(visibility.IncludeNSFW), - }, "|"))) - return "stats:visible:" + hex.EncodeToString(sum[:]) -} - -func boolString(value bool) string { - if value { - return "1" - } - return "0" -} - -func applyMediaVisibilityQuery(q *gorm.DB, visibility service.MediaVisibility) *gorm.DB { - if !visibility.IncludeNSFW { - q = q.Where("nsfw = ?", false) - } - if len(visibility.HiddenLibraryIDs) > 0 { - q = q.Where("library_id NOT IN ?", visibility.HiddenLibraryIDs) - } - if len(visibility.AllowedLibraryIDs) > 0 { - q = q.Where("library_id IN ?", visibility.AllowedLibraryIDs) - } - return q -} - -func applyActiveLibraryQuery(q *gorm.DB, libraryIDs []string) *gorm.DB { - if len(libraryIDs) == 0 { - return q.Where("1 = 0") - } - return q.Where("library_id IN ?", libraryIDs) -} diff --git a/internal/handler/stats_extra.go b/internal/handler/stats_extra.go deleted file mode 100644 index 788d09c..0000000 --- a/internal/handler/stats_extra.go +++ /dev/null @@ -1,180 +0,0 @@ -// Package handler — richer dashboard statistics endpoints. -// -// /api/stats already returns the basic snapshot. The Vue admin -// dashboard also uses: -// -// /api/stats/overview — counts + total size + total seconds -// /api/stats/trend — daily play count over last N days -// /api/stats/top-content — top played media (by play count) -// /api/stats/libraries — per-library item count + size -// /api/stats/monitor — live CPU/mem/disk -package handler - -import ( - "net/http" - "strconv" - "time" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func statsOverviewHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - snap, err := svc.Stats.Compute(c.Request.Context(), svc.Cfg.App.DataDir) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if err := applyStatsVisibility(c, svc, snap); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "libraries": snap.Libraries, - "media_count": snap.MediaCount, - "users_count": snap.UsersCount, - "total_size": snap.TotalSizeBytes, - "total_seconds": snap.TotalSeconds, - "generated_at": snap.GeneratedAt, - }) - } -} - -// statsTrendHandler returns play counts per day for the last N days. -func statsTrendHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - days, _ := strconv.Atoi(c.DefaultQuery("days", "14")) - if days <= 0 || days > 90 { - days = 14 - } - // Use the playback_history table; one row per (user, media) - // per day if we group by date(watched_at). - type bucket struct { - Day string `json:"day"` - Count int64 `json:"count"` - } - out := make([]bucket, 0, days) - now := time.Now().UTC() - for i := days - 1; i >= 0; i-- { - start := now.AddDate(0, 0, -i).Truncate(24 * time.Hour) - end := start.Add(24 * time.Hour) - var n int64 - _ = svc.Repo.DB.Model(&model.PlaybackHistory{}). - Where("watched_at >= ? AND watched_at < ?", start, end). - Count(&n).Error - out = append(out, bucket{ - Day: start.Format("2006-01-02"), - Count: n, - }) - } - c.JSON(http.StatusOK, gin.H{"trend": out, "days": days}) - } -} - -// statsTopContentHandler returns the most-watched media items. -func statsTopContentHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) - if limit <= 0 || limit > 50 { - limit = 10 - } - type row struct { - MediaID string `json:"media_id"` - PlayCount int64 `json:"play_count"` - LastPlayed time.Time `json:"last_played"` - } - var rows []row - _ = svc.Repo.DB.Table("playback_histories"). - Select("media_id, COUNT(*) as play_count, MAX(watched_at) as last_played"). - Group("media_id"). - Order("play_count desc"). - Limit(limit). - Scan(&rows).Error - // Hydrate media titles in a single query. - ids := make([]string, 0, len(rows)) - for _, r := range rows { - ids = append(ids, r.MediaID) - } - mIdx := map[string]model.Media{} - if len(ids) > 0 { - var media []model.Media - _ = svc.Repo.DB.Where("id IN ?", ids).Find(&media).Error - visibility := mediaVisibilityForRequest(c, svc) - for _, m := range media { - if !visibility.Allows(&m) { - continue - } - mIdx[m.ID] = m - } - } - out := make([]gin.H, 0, len(rows)) - for _, r := range rows { - media, ok := mIdx[r.MediaID] - if !ok { - continue - } - out = append(out, gin.H{ - "media": media, - "play_count": r.PlayCount, - "last_played": r.LastPlayed, - }) - } - c.JSON(http.StatusOK, gin.H{"items": out}) - } -} - -// statsLibrariesHandler returns per-library counts + size. -func statsLibrariesHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - libs, err := svc.Repo.Library.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - libs = service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, libs) - out := make([]gin.H, 0, len(libs)) - visibility := mediaVisibilityForRequest(c, svc) - for _, l := range libs { - if !l.Enabled { - continue - } - if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, l, visibility) { - continue - } - libraryIDs, err := service.MergedLibraryIDsForLibrary(c.Request.Context(), svc.Repo, l.ID) - if err != nil || len(libraryIDs) == 0 { - libraryIDs = []string{l.ID} - } - var count int64 - var size int64 - _ = applyMediaVisibilityQuery(svc.Repo.DB.Model(&model.Media{}), visibility). - Where("library_id IN ?", libraryIDs). - Count(&count).Error - _ = applyMediaVisibilityQuery(svc.Repo.DB.Model(&model.Media{}), visibility). - Where("library_id IN ?", libraryIDs). - Select("COALESCE(SUM(size_bytes),0)").Row().Scan(&size) - out = append(out, gin.H{ - "library": l, - "item_count": count, - "total_size": size, - }) - } - c.JSON(http.StatusOK, gin.H{"libraries": out}) - } -} - -// statsMonitorHandler returns live system resource usage; this is just -// the Hardware portion of the snapshot but with a snappy schema. -func statsMonitorHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - snap, err := svc.Stats.Compute(c.Request.Context(), svc.Cfg.App.DataDir) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, snap.Hardware) - } -} diff --git a/internal/handler/stats_test.go b/internal/handler/stats_test.go deleted file mode 100644 index 35107d1..0000000 --- a/internal/handler/stats_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package handler - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/glebarez/sqlite" - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/middleware" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func TestStatsSnapshotHidesAdultRecentlyAddedForUser(t *testing.T) { - gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - viewer := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user", HideAdult: true} - if err := repos.User.Create(t.Context(), viewer); err != nil { - t.Fatal(err) - } - safe := model.Library{Name: "电影", Path: "/media/movie", Type: "movie", Enabled: true} - adult := model.Library{Name: "9KG", Path: "/media/9KG", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &safe); err != nil { - t.Fatal(err) - } - if err := repos.Library.Create(t.Context(), &adult); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), service.AdultLibraryIDsSettingKey, `["`+adult.ID+`"]`); err != nil { - t.Fatal(err) - } - if err := db.Create(&model.Media{LibraryID: safe.ID, Title: "普通电影", Path: "/media/movie/a.mkv", SizeBytes: 100, DurationSec: 10}).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&model.Media{LibraryID: adult.ID, Title: "成人影片", Path: "/media/9KG/a.mkv", SizeBytes: 200, DurationSec: 20}).Error; err != nil { - t.Fatal(err) - } - svc := &service.Container{Repo: repos} - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Set(middleware.CtxUserID, viewer.ID) - c.Request = httptest.NewRequest("GET", "/api/stats", nil) - - snap := &service.Snapshot{} - if err := applyStatsVisibility(c, svc, snap); err != nil { - t.Fatalf("applyStatsVisibility: %v", err) - } - if snap.MediaCount != 1 || snap.TotalSizeBytes != 100 || snap.TotalSeconds != 10 { - t.Fatalf("stats should only include visible media, got count=%d size=%d seconds=%d", snap.MediaCount, snap.TotalSizeBytes, snap.TotalSeconds) - } - if len(snap.RecentlyAdded) != 1 || snap.RecentlyAdded[0].LibraryID != safe.ID { - t.Fatalf("recently added should hide adult library, got %#v", snap.RecentlyAdded) - } -} - -func TestStatsLibrariesCountsMergedCloudLibraryItems(t *testing.T) { - gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - local := model.Library{Name: "国产电影", Path: "/media/国产电影", Type: "movie", Enabled: true} - cloud := model.Library{Name: "OpenList · 国产电影", Path: service.BuildCloudLibraryPath("openlist", "/国产电影", "/国产电影"), Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - if err := db.Create(&[]model.Media{ - {LibraryID: local.ID, Title: "本地版本", Path: "/media/国产电影/local.mkv", SizeBytes: 100}, - {LibraryID: cloud.ID, Title: "云盘版本", Path: "cloud://openlist/国产电影/cloud.mkv", SizeBytes: 200}, - }).Error; err != nil { - t.Fatal(err) - } - svc := &service.Container{Repo: repos} - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest("GET", "/api/stats/libraries", nil) - - statsLibrariesHandler(svc)(c) - if w.Code != http.StatusOK { - t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) - } - var payload struct { - Libraries []struct { - ItemCount int64 `json:"item_count"` - TotalSize int64 `json:"total_size"` - } `json:"libraries"` - } - if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { - t.Fatal(err) - } - if len(payload.Libraries) != 1 { - t.Fatalf("libraries = %#v, want one merged display library", payload.Libraries) - } - if payload.Libraries[0].ItemCount != 2 || payload.Libraries[0].TotalSize != 300 { - t.Fatalf("merged stats = %#v, want count=2 size=300", payload.Libraries[0]) - } -} diff --git a/internal/handler/storage_config.go b/internal/handler/storage_config.go deleted file mode 100644 index 032ed54..0000000 --- a/internal/handler/storage_config.go +++ /dev/null @@ -1,145 +0,0 @@ -// Package handler — external storage config endpoints. -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// listStorageConfigsHandler returns the status overview used by the -// admin storage panel: every persisted backend with secrets redacted. -func listStorageConfigsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - rows, err := svc.StorageCfg.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": rows}) - } -} - -// getStorageConfigHandler returns one config (with the decrypted body). -func getStorageConfigHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if !service.IsAdminStorageConfigurable(c.Param("type")) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported storage type"}) - return - } - row, err := svc.StorageCfg.Get(c.Request.Context(), c.Param("type")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if row == nil { - c.JSON(http.StatusOK, gin.H{"type": c.Param("type"), "config": gin.H{}}) - return - } - c.JSON(http.StatusOK, row) - } -} - -// saveStorageConfigHandler upserts the config row; the caller passes -// the type via URL and the body as a JSON object. -func saveStorageConfigHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if !service.IsAdminStorageConfigurable(c.Param("type")) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported storage type"}) - return - } - var in service.StorageInput - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - in.Type = c.Param("type") - row, err := svc.StorageCfg.Save(c.Request.Context(), in) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if in.Enabled != nil && !*in.Enabled && svc.Scan != nil { - _ = svc.Scan.CancelCloudScansForProvider(in.Type) - } - c.JSON(http.StatusOK, row) - } -} - -// testStorageConfigHandler probes an unsaved config. -func testStorageConfigHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if !service.IsAdminStorageConfigurable(c.Param("type")) { - c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "unsupported storage type"}) - return - } - var in service.StorageInput - if err := c.ShouldBindJSON(&in); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - in.Type = c.Param("type") - if err := svc.StorageCfg.Test(c.Request.Context(), in); err != nil { - c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) - return - } - if service.IsAdminCloudConfigurable(in.Type) { - enabled := true - if _, err := svc.StorageCfg.Save(c.Request.Context(), service.StorageInput{ - Type: in.Type, - Config: in.Config, - Enabled: &enabled, - }); err != nil { - c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()}) - return - } - } - c.JSON(http.StatusOK, gin.H{"ok": true}) - } -} - -func logoutStorageConfigHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - typ := c.Param("type") - if !service.IsAdminStorageConfigurable(typ) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported storage type"}) - return - } - row, err := svc.StorageCfg.Logout(c.Request.Context(), typ) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if svc.Scan != nil { - _ = svc.Scan.CancelCloudScansForProvider(typ) - } - c.JSON(http.StatusOK, row) - } -} - -func storageUploadLocalHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if !service.IsAdminStorageConfigurable(c.Param("type")) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported storage type"}) - return - } - var req service.CloudUploadInput - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - req.Type = c.Param("type") - res, err := svc.StorageCfg.UploadLocal(c.Request.Context(), req) - if err != nil && res == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err != nil { - c.JSON(http.StatusOK, gin.H{"result": res, "error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"result": res}) - } -} diff --git a/internal/handler/storage_config_test.go b/internal/handler/storage_config_test.go deleted file mode 100644 index dffe2b8..0000000 --- a/internal/handler/storage_config_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package handler - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "github.com/glebarez/sqlite" - "go.uber.org/zap" - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func TestStorageConfigHandlersRejectQuark(t *testing.T) { - gin.SetMode(gin.TestMode) - router := gin.New() - router.PUT("/admin/storage/:type", saveStorageConfigHandler(nil)) - - req := httptest.NewRequest(http.MethodPut, "/admin/storage/quark", strings.NewReader(`{"type":"quark","config":{"cookie":"x"}}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("status = %d body=%s, want 400", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "unsupported storage type") { - t.Fatalf("body = %s, want unsupported storage type", w.Body.String()) - } -} - -func TestStorageConfigCloudTestSuccessSavesAndEnablesProvider(t *testing.T) { - gin.SetMode(gin.TestMode) - openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/fs/list" { - t.Fatalf("unexpected openlist path %s", r.URL.Path) - } - if r.Header.Get("Authorization") != "openlist-token" { - t.Fatalf("authorization = %q, want openlist-token", r.Header.Get("Authorization")) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "code": 200, - "data": map[string]any{"content": []any{}, "total": 0}, - }) - })) - defer openlist.Close() - - db, err := gorm.Open(sqlite.Open("file:storage_config_cloud_test?mode=memory&cache=shared"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.StorageConfig{}, &model.Setting{}, &model.Library{}, &model.Media{}); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - log := zap.NewNop() - storage := service.NewStorageConfigService(log, repos, service.NewCryptoService("", log)) - enabled := false - if _, err := storage.Save(t.Context(), service.StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": openlist.URL, - "token": "openlist-token", - }, - Enabled: &enabled, - }); err != nil { - t.Fatal(err) - } - - router := gin.New() - router.POST("/admin/storage/:type/test", testStorageConfigHandler(&service.Container{StorageCfg: storage})) - body := `{"type":"openlist","config":{"server":"` + openlist.URL + `","token":"openlist-token"}}` - req := httptest.NewRequest(http.MethodPost, "/admin/storage/openlist/test", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d body=%s, want 200", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), `"ok":true`) { - t.Fatalf("body = %s, want ok true", w.Body.String()) - } - view, err := storage.Get(t.Context(), "openlist") - if err != nil { - t.Fatal(err) - } - if view == nil || !view.Enabled { - t.Fatalf("openlist enabled = %#v, want enabled after successful test", view) - } - if _, err := storage.CloudProvider(t.Context(), "openlist"); err != nil { - t.Fatalf("cloud provider after test: %v", err) - } -} diff --git a/internal/handler/streaming.go b/internal/handler/streaming.go index 303b95f..d0e4bef 100644 --- a/internal/handler/streaming.go +++ b/internal/handler/streaming.go @@ -85,42 +85,6 @@ func imageProxyHandler(svc *service.Container) gin.HandlerFunc { } } -func cloudArtworkProxyHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - typ := c.Param("type") - ref := c.Query("ref") - if !service.IsAdminCloudConfigurable(typ) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported cloud provider"}) - return - } - if ref == "" || !isCloudImageRef(ref) { - c.JSON(http.StatusBadRequest, gin.H{"error": "image ref required"}) - return - } - if svc == nil || svc.ImageProxy == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "image proxy unavailable"}) - return - } - stableKey := typ + ":" + ref - if svc.ImageProxy.ServeCloudCached(c.Writer, c.Request, stableKey) { - return - } - if svc.StorageCfg == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud storage service unavailable"}) - return - } - link, err := svc.StorageCfg.CloudResolve(c.Request.Context(), typ, ref, c.Request.UserAgent()) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - if err := svc.ImageProxy.ServeCloudResolved(c.Request.Context(), c.Writer, c.Request, stableKey, link); err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - } -} - type scrapeRequest struct { EpisodeArtwork *bool `json:"episode_artwork"` EpisodeImages *bool `json:"episode_images"` diff --git a/internal/handler/strm.go b/internal/handler/strm.go deleted file mode 100644 index bcac96f..0000000 --- a/internal/handler/strm.go +++ /dev/null @@ -1,437 +0,0 @@ -// Package handler — STRM (URL-as-file) admin endpoints. -// -// Setting a media row's strm_url makes the stream handler issue a 302 -// redirect to that URL instead of opening a local file. This lets the -// operator expose WebDAV / Alist / S3 / HTTP direct links as ordinary -// MediaStationGo entries. -package handler - -import ( - "context" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/middleware" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -type strmReq struct { - URL string `json:"url" binding:"required"` -} - -func setSTRMHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req strmReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - url := strings.TrimSpace(req.URL) - if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { - c.JSON(http.StatusBadRequest, gin.H{"error": "url must start with http:// or https://"}) - return - } - mediaID := c.Param("id") - m, err := svc.Repo.Media.FindByID(c.Request.Context(), mediaID) - if err != nil || m == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "media not found"}) - return - } - if err := svc.Repo.DB.WithContext(c.Request.Context()). - Model(&model.Media{}). - Where("id = ?", mediaID). - Update("strm_url", url).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"strm_url": url}) - } -} - -func clearSTRMHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.Repo.DB.WithContext(c.Request.Context()). - Model(&model.Media{}). - Where("id = ?", c.Param("id")). - Update("strm_url", "").Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.Status(http.StatusNoContent) - } -} - -// importSTRMHandler creates a media row directly from a (library_id, title, url) -// tuple — useful for adding a streaming-only entry without an on-disk file. -type importSTRMReq struct { - LibraryID string `json:"library_id" binding:"required"` - Title string `json:"title" binding:"required"` - URL string `json:"url" binding:"required"` -} - -func importSTRMHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req importSTRMReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - url := strings.TrimSpace(req.URL) - if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { - c.JSON(http.StatusBadRequest, gin.H{"error": "url must start with http:// or https://"}) - return - } - m := &model.Media{ - LibraryID: req.LibraryID, - Title: req.Title, - Path: url, - STRMURL: url, - Container: "strm", - } - if err := svc.Repo.Media.Upsert(c.Request.Context(), m); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusCreated, m) - } -} - -type generateSTRMReq struct { - LibraryID string `json:"library_id"` - OutputDir string `json:"output_dir"` - BaseURL string `json:"base_url"` - Enabled bool `json:"enabled"` - Overwrite bool `json:"overwrite"` - IncludeLocal *bool `json:"include_local"` - PreserveTree bool `json:"preserve_tree"` - Refresh bool `json:"refresh_library"` - ScrapeAfter bool `json:"scrape_after"` -} - -type generateSTRMTreeReq struct { - Provider string `json:"provider"` - TreeText string `json:"tree_text"` - Paths []string `json:"paths"` - SourceRoot string `json:"source_root"` - OutputPrefix string `json:"output_prefix"` - OutputDir string `json:"output_dir"` - BaseURL string `json:"base_url"` - Overwrite bool `json:"overwrite"` - Cleanup bool `json:"cleanup"` - DryRun bool `json:"dry_run"` - BatchLimit int `json:"batch_limit"` - RecognizeRename bool `json:"recognize_rename"` - TransferSubtitles bool `json:"transfer_subtitles"` - MissingOnly bool `json:"missing_only"` - RefreshLibrary bool `json:"refresh_library"` - ScrapeAfter bool `json:"scrape_after"` -} - -type repairSTRMReq struct { - OutputDir string `json:"output_dir" binding:"required"` - BaseURL string `json:"base_url"` - DryRun bool `json:"dry_run"` - RefreshLibrary bool `json:"refresh_library"` - ScrapeAfter bool `json:"scrape_after"` -} - -func listSTRMOutputPresetsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - items, err := service.STRMOutputPresets(c.Request.Context(), svc.Repo) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": items}) - } -} - -func generateSTRMHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req generateSTRMReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - strmSvc := svc.STRM - if strmSvc == nil { - strmSvc = service.NewSTRMService(svc.Log, svc.Repo, svc.Cfg) - } - baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") - if baseURL == "" { - baseURL = strings.TrimRight(absoluteRequestURL(c, "/"), "/") - } - includeLocal := true - if req.IncludeLocal != nil { - includeLocal = *req.IncludeLocal - } - options := service.GenerateSTRMOptions{ - LibraryID: req.LibraryID, - OutputDir: req.OutputDir, - BaseURL: baseURL, - Enabled: req.Enabled, - Overwrite: req.Overwrite, - IncludeLocal: includeLocal, - PreserveTree: req.PreserveTree, - PlaybackToken: strmPlaybackTokenForRequest(c, svc), - } - var res *service.GenerateSTRMResult - var err error - if strings.TrimSpace(req.LibraryID) == "*" { - res, err = strmSvc.GenerateForAllLibraries(c.Request.Context(), options) - } else { - res, err = strmSvc.GenerateForLibrary(c.Request.Context(), options) - } - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if req.Refresh { - res.Refresh = queueSTRMRefreshAfterChanges(c.Request.Context(), svc, res.OutputDir, strmRefreshQueueOptions{ - TaskName: "STRM 生成后刷新媒体库", - Changed: strmGenerationChanged(res), - ScrapeAfter: req.ScrapeAfter, - }) - } - c.JSON(http.StatusOK, res) - } -} - -func generateSTRMFromTreeHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req generateSTRMTreeReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - strmSvc := svc.STRM - if strmSvc == nil { - strmSvc = service.NewSTRMService(svc.Log, svc.Repo, svc.Cfg) - } - baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") - if baseURL == "" { - baseURL = strings.TrimRight(absoluteRequestURL(c, "/"), "/") - } - res, err := strmSvc.GenerateFromTree(c.Request.Context(), service.GenerateSTRMTreeOptions{ - Provider: req.Provider, - TreeText: req.TreeText, - Paths: req.Paths, - SourceRoot: req.SourceRoot, - OutputPrefix: req.OutputPrefix, - OutputDir: req.OutputDir, - BaseURL: baseURL, - Overwrite: req.Overwrite, - Cleanup: req.Cleanup, - DryRun: req.DryRun, - BatchLimit: req.BatchLimit, - RecognizeRename: req.RecognizeRename, - TransferSubtitles: req.TransferSubtitles, - MissingOnly: req.MissingOnly, - }) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if req.RefreshLibrary && !req.DryRun { - res.Refresh = queueSTRMRefreshAfterChanges(c.Request.Context(), svc, res.OutputDir, strmRefreshQueueOptions{ - TaskName: "STRM 目录树生成后刷新媒体库", - Changed: strmGenerationChanged(res), - ScrapeAfter: req.ScrapeAfter, - }) - } - c.JSON(http.StatusOK, res) - } -} - -func repairSTRMHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req repairSTRMReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - strmSvc := svc.STRM - if strmSvc == nil { - strmSvc = service.NewSTRMService(svc.Log, svc.Repo, svc.Cfg) - } - baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") - if baseURL == "" { - baseURL = strings.TrimRight(absoluteRequestURL(c, "/"), "/") - } - res, err := strmSvc.RepairFiles(c.Request.Context(), service.RepairSTRMOptions{ - OutputDir: req.OutputDir, - BaseURL: baseURL, - DryRun: req.DryRun, - }) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if req.RefreshLibrary && !req.DryRun { - res.Refresh = queueSTRMRefreshAfterChanges(c.Request.Context(), svc, res.OutputDir, strmRefreshQueueOptions{ - TaskName: "STRM 修复后刷新媒体库", - Changed: res.Repaired > 0, - ScrapeAfter: req.ScrapeAfter, - }) - } - c.JSON(http.StatusOK, res) - } -} - -func strmGenerationChanged(res *service.GenerateSTRMResult) bool { - return res != nil && (res.Generated > 0 || res.Updated > 0 || res.Cleaned > 0) -} - -type strmRefreshQueueOptions struct { - TaskName string - Changed bool - ScrapeAfter bool -} - -type strmRefreshRunOptions struct { - ScrapeAfter bool -} - -func queueSTRMRefreshAfterChanges(ctx context.Context, svc *service.Container, outputDir string, options strmRefreshQueueOptions) *service.STRMRefreshResult { - refresh := &service.STRMRefreshResult{Requested: true, ScrapeRequested: options.ScrapeAfter} - if !options.Changed { - refresh.Reason = "no strm changes" - refresh.ScrapeReason = strmRefreshScrapeSkipReason(refresh) - return refresh - } - if svc == nil || svc.Scan == nil { - refresh.Reason = "scanner unavailable" - refresh.ScrapeReason = strmRefreshScrapeSkipReason(refresh) - return refresh - } - targets, err := service.FindSTRMRefreshTargets(ctx, svc.Repo, outputDir) - if err != nil { - refresh.Reason = err.Error() - refresh.ScrapeReason = strmRefreshScrapeSkipReason(refresh) - return refresh - } - if len(targets) == 0 { - refresh.Reason = "no matching local library" - refresh.ScrapeReason = strmRefreshScrapeSkipReason(refresh) - return refresh - } - refresh.Targets = targets - if options.ScrapeAfter && svc.Scraper == nil { - refresh.ScrapeReason = "scraper unavailable" - } - for _, target := range targets { - key := target.LibraryID - if target.RootID != "" { - key += ":" + target.RootID - } - finishScan, ok := svc.Scan.TryBeginLocalScan(key) - if !ok { - continue - } - refresh.Queued = true - runOptions := strmRefreshRunOptions{ScrapeAfter: options.ScrapeAfter && svc.Scraper != nil} - if runOptions.ScrapeAfter { - refresh.ScrapeQueued = true - } - task := startScanHTTPTask(svc, options.TaskName, target.Name, target.Path) - go runSTRMRefreshScan(svc, target, task, finishScan, runOptions) - } - if !refresh.Queued { - refresh.Reason = "matching library already scanning" - refresh.ScrapeReason = strmRefreshScrapeSkipReason(refresh) - } - return refresh -} - -func runSTRMRefreshScan(svc *service.Container, target service.STRMRefreshTarget, task *service.TaskHandle, finish func(), options strmRefreshRunOptions) { - defer finish() - var ( - res *service.ScanResult - err error - ) - if target.RootID != "" { - res, err = svc.Scan.ScanLibraryRoot(context.Background(), target.LibraryID, target.RootID) - } else { - res, err = svc.Scan.ScanLibrary(context.Background(), target.LibraryID) - } - if err != nil { - finishHTTPTask(task, err, "scan", "STRM 刷新媒体库失败", scanTaskMetrics(res), scanTaskDetails(res, 20)) - return - } - if !options.ScrapeAfter { - finishHTTPTask(task, nil, "completed", "STRM 刷新媒体库结束", scanTaskMetrics(res), scanTaskDetails(res, 20)) - return - } - scrape, reclassified, scrapeErr := runSTRMRefreshScrape(svc, target, task) - metrics := strmRefreshTaskMetrics(res, scrape, reclassified) - if scrapeErr != nil { - finishHTTPTask(task, scrapeErr, "scrape", "STRM 刷新媒体库完成,刮削失败", metrics, scanTaskDetails(res, 20)) - return - } - finishHTTPTask(task, nil, "completed", "STRM 刷新媒体库和刮削结束", metrics, scanTaskDetails(res, 20)) -} - -func runSTRMRefreshScrape(svc *service.Container, target service.STRMRefreshTarget, task *service.TaskHandle) (service.EnrichLibraryResult, int, error) { - if task != nil { - task.Update(service.TaskUpdate{ - Stage: "scrape", - SourcePath: target.Path, - Message: "正在刮削 STRM 媒体库", - }) - } - result, err := svc.Scraper.EnrichLibraryDetailedWithOptions(context.Background(), target.LibraryID, service.ScrapeOptions{RetryNoMatch: true}) - reclassified := 0 - if result.Processed > 0 { - reclassified = reclassifyLibraryAfterScrape(context.Background(), svc, target.LibraryID) - } - return result, reclassified, err -} - -func strmRefreshTaskMetrics(scan *service.ScanResult, scrape service.EnrichLibraryResult, reclassified int) map[string]int64 { - metrics := scanTaskMetrics(scan) - if metrics == nil { - metrics = map[string]int64{} - } - metrics["scrape_matched"] = int64(scrape.Matched) - metrics["scrape_processed"] = int64(scrape.Processed) - metrics["scrape_candidates"] = int64(scrape.Candidates) - if scrape.Failed > 0 { - metrics["scrape_failed"] = int64(scrape.Failed) - } - if reclassified > 0 { - metrics["scrape_reclassified"] = int64(reclassified) - } - return metrics -} - -func strmRefreshScrapeSkipReason(refresh *service.STRMRefreshResult) string { - if refresh == nil || !refresh.ScrapeRequested { - return "" - } - if refresh.Reason != "" { - return "refresh not queued: " + refresh.Reason - } - return "refresh not queued" -} - -func strmPlaybackTokenForRequest(c *gin.Context, svc *service.Container) string { - if svc == nil || svc.Auth == nil || svc.Repo == nil || svc.Repo.User == nil { - return "" - } - uid := middleware.GetUserID(c) - if uid == "" { - return "" - } - u, err := svc.Repo.User.FindByID(c.Request.Context(), uid) - if err != nil || u == nil { - return "" - } - token, err := svc.Auth.IssueEmbyToken(u) - if err != nil { - return "" - } - return token -} diff --git a/internal/handler/strm_test.go b/internal/handler/strm_test.go deleted file mode 100644 index ae276f5..0000000 --- a/internal/handler/strm_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package handler - -import ( - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func TestSTRMRefreshTaskMetricsIncludesScanAndScrape(t *testing.T) { - metrics := strmRefreshTaskMetrics( - &service.ScanResult{Visited: 3, Added: 2, Updated: 1, ErrorCount: 1}, - service.EnrichLibraryResult{Matched: 4, Processed: 5, Candidates: 6, Failed: 1}, - 2, - ) - - want := map[string]int64{ - "visited": 3, - "added": 2, - "updated": 1, - "errors": 1, - "scrape_matched": 4, - "scrape_processed": 5, - "scrape_candidates": 6, - "scrape_failed": 1, - "scrape_reclassified": 2, - } - for key, value := range want { - if metrics[key] != value { - t.Fatalf("metrics[%q] = %d, want %d in %#v", key, metrics[key], value, metrics) - } - } -} - -func TestSTRMRefreshScrapeSkipReasonRequiresScrapeRequest(t *testing.T) { - if got := strmRefreshScrapeSkipReason(&service.STRMRefreshResult{Requested: true, Reason: "no strm changes"}); got != "" { - t.Fatalf("skip reason without scrape request = %q, want empty", got) - } - - got := strmRefreshScrapeSkipReason(&service.STRMRefreshResult{ - Requested: true, - ScrapeRequested: true, - Reason: "no matching local library", - }) - if got != "refresh not queued: no matching local library" { - t.Fatalf("skip reason = %q", got) - } -} diff --git a/internal/handler/subscription_errors.go b/internal/handler/subscription_errors.go deleted file mode 100644 index 8973e93..0000000 --- a/internal/handler/subscription_errors.go +++ /dev/null @@ -1,22 +0,0 @@ -package handler - -import ( - "errors" - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func writeSubscriptionConflict(c *gin.Context, err error) bool { - if !errors.Is(err, service.ErrSubscriptionAlreadyExists) { - return false - } - body := gin.H{"error": "subscription already exists"} - if existingID := service.SubscriptionAlreadyExistsID(err); existingID != "" { - body["existing_id"] = existingID - } - c.JSON(http.StatusConflict, body) - return true -} diff --git a/internal/handler/subscription_errors_test.go b/internal/handler/subscription_errors_test.go deleted file mode 100644 index 29aff2d..0000000 --- a/internal/handler/subscription_errors_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package handler - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func TestWriteSubscriptionConflictReturns409WithExistingID(t *testing.T) { - gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - err := &service.SubscriptionAlreadyExistsError{ExistingID: "existing-sub"} - if !writeSubscriptionConflict(c, err) { - t.Fatal("expected conflict to be handled") - } - if w.Code != http.StatusConflict { - t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) - } - var body map[string]any - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { - t.Fatal(err) - } - if body["existing_id"] != "existing-sub" { - t.Fatalf("body = %#v", body) - } -} diff --git a/internal/handler/subscription_extra.go b/internal/handler/subscription_extra.go deleted file mode 100644 index b61bfa7..0000000 --- a/internal/handler/subscription_extra.go +++ /dev/null @@ -1,210 +0,0 @@ -// Package handler — subscription update + per-subscription site search. -package handler - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -type subscriptionPatchReq struct { - Name *string `json:"name"` - FeedURL *string `json:"feed_url"` - Filter *string `json:"filter"` - MediaType *string `json:"media_type"` - MediaCategory *string `json:"media_category"` - SavePath *string `json:"save_path"` - SearchMode *string `json:"search_mode"` - IMDBID *string `json:"imdb_id"` - Source *string `json:"source"` - PosterURL *string `json:"poster_url"` - BackdropURL *string `json:"backdrop_url"` - Overview *string `json:"overview"` - OriginalName *string `json:"original_name"` - Year *int `json:"year"` - Rating *float32 `json:"rating"` - Genres *string `json:"genres"` - Resolution *string `json:"resolution"` - Quality *string `json:"quality"` - Effects *string `json:"effects"` - ReleaseGroups *string `json:"release_groups"` - ExcludeWords *string `json:"exclude_words"` - MinSeeders *int `json:"min_seeders"` - MaxSeeders *int `json:"max_seeders"` - MinSizeGB *float64 `json:"min_size_gb"` - MaxSizeGB *float64 `json:"max_size_gb"` - FreeOnly *bool `json:"free_only"` - WashEnabled *bool `json:"wash_enabled"` - WashPriority *string `json:"wash_priority"` - TotalEpisodes *int `json:"total_episodes"` - Priority *int `json:"priority"` - Enabled *bool `json:"enabled"` -} - -// updateSubscriptionHandler patches a subscription row. -func updateSubscriptionHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var patch subscriptionPatchReq - if err := c.ShouldBindJSON(&patch); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - updates := subscriptionPatchUpdates(patch) - if len(updates) == 0 { - c.Status(http.StatusNoContent) - return - } - if err := svc.Subscription.Update(c.Request.Context(), c.Param("id"), updates); err != nil { - logSubscriptionWarn(svc, "subscription update failed", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.String("subscription_id", c.Param("id")), - zap.Error(err)) - if writeSubscriptionConflict(c, err) { - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - logSubscriptionInfo(svc, "subscription updated", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.String("subscription_id", c.Param("id")), - zap.Strings("fields", subscriptionUpdateFieldNames(updates))) - c.Status(http.StatusNoContent) - } -} - -func subscriptionPatchUpdates(patch subscriptionPatchReq) map[string]any { - updates := map[string]any{} - if patch.Name != nil { - updates["name"] = *patch.Name - } - if patch.FeedURL != nil { - updates["feed_url"] = *patch.FeedURL - } - if patch.Filter != nil { - updates["filter"] = *patch.Filter - } - if patch.MediaType != nil { - updates["media_type"] = *patch.MediaType - } - if patch.MediaCategory != nil { - updates["media_category"] = *patch.MediaCategory - } - if patch.SavePath != nil { - updates["save_path"] = *patch.SavePath - } - if patch.SearchMode != nil { - updates["search_mode"] = *patch.SearchMode - } - if patch.IMDBID != nil { - updates["imdb_id"] = *patch.IMDBID - } - if patch.Source != nil { - updates["source"] = *patch.Source - } - if patch.PosterURL != nil { - updates["poster_url"] = *patch.PosterURL - } - if patch.BackdropURL != nil { - updates["backdrop_url"] = *patch.BackdropURL - } - if patch.Overview != nil { - updates["overview"] = *patch.Overview - } - if patch.OriginalName != nil { - updates["original_name"] = *patch.OriginalName - } - if patch.Year != nil { - updates["year"] = *patch.Year - } - if patch.Rating != nil { - updates["rating"] = *patch.Rating - } - if patch.Genres != nil { - updates["genres"] = *patch.Genres - } - if patch.Resolution != nil { - updates["resolution"] = *patch.Resolution - } - if patch.Quality != nil { - updates["quality"] = *patch.Quality - } - if patch.Effects != nil { - updates["effects"] = *patch.Effects - } - if patch.ReleaseGroups != nil { - updates["release_groups"] = *patch.ReleaseGroups - } - if patch.ExcludeWords != nil { - updates["exclude_words"] = *patch.ExcludeWords - } - if patch.MinSeeders != nil { - updates["min_seeders"] = *patch.MinSeeders - } - if patch.MaxSeeders != nil { - updates["max_seeders"] = *patch.MaxSeeders - } - if patch.MinSizeGB != nil { - updates["min_size_gb"] = *patch.MinSizeGB - } - if patch.MaxSizeGB != nil { - updates["max_size_gb"] = *patch.MaxSizeGB - } - if patch.FreeOnly != nil { - updates["free_only"] = *patch.FreeOnly - } - if patch.WashEnabled != nil { - updates["wash_enabled"] = *patch.WashEnabled - } - if patch.WashPriority != nil { - updates["wash_priority"] = *patch.WashPriority - } - if patch.TotalEpisodes != nil { - updates["total_episodes"] = *patch.TotalEpisodes - } - if patch.Priority != nil { - updates["priority"] = *patch.Priority - } - if patch.Enabled != nil { - updates["enabled"] = *patch.Enabled - } - return updates -} - -func subscriptionUpdateFieldNames(updates map[string]any) []string { - names := make([]string, 0, len(updates)) - for name := range updates { - names = append(names, name) - } - return names -} - -// searchSubscriptionHandler runs a one-off keyword search against the -// configured tracker sites for the given subscription. We treat the -// subscription's filter as the search term; this lets the UI preview -// what would be queued without actually downloading anything. -func searchSubscriptionHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var sub model.Subscription - err := svc.Repo.DB.WithContext(c.Request.Context()). - Where("id = ?", c.Param("id")).First(&sub).Error - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "subscription not found"}) - return - } - keyword := sub.Filter - if keyword == "" { - keyword = sub.Name - } - results, err := svc.Site.Search(c.Request.Context(), keyword) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": results, "subscription": sub}) - } -} diff --git a/internal/handler/subscription_logging.go b/internal/handler/subscription_logging.go deleted file mode 100644 index cbbf70e..0000000 --- a/internal/handler/subscription_logging.go +++ /dev/null @@ -1,54 +0,0 @@ -package handler - -import ( - "net/url" - "strings" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/middleware" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -func subscriptionRequestUserID(c *gin.Context) string { - if c == nil { - return "" - } - if uid, ok := c.Get(middleware.CtxUserID); ok { - if userID, ok := uid.(string); ok { - return userID - } - } - return "" -} - -func subscriptionFeedKind(feedURL string) string { - raw := strings.TrimSpace(feedURL) - if raw == "" { - return "empty" - } - lower := strings.ToLower(raw) - if strings.HasPrefix(lower, "site-search://") { - return "site-search" - } - parsed, err := url.Parse(raw) - if err == nil && parsed.Scheme != "" { - return parsed.Scheme - } - return "unknown" -} - -func logSubscriptionInfo(svc *service.Container, msg string, fields ...zap.Field) { - if svc == nil || svc.Log == nil { - return - } - svc.Log.Info(msg, fields...) -} - -func logSubscriptionWarn(svc *service.Container, msg string, fields ...zap.Field) { - if svc == nil || svc.Log == nil { - return - } - svc.Log.Warn(msg, fields...) -} diff --git a/internal/handler/subscriptions.go b/internal/handler/subscriptions.go deleted file mode 100644 index a3e74e0..0000000 --- a/internal/handler/subscriptions.go +++ /dev/null @@ -1,219 +0,0 @@ -// Package handler — RSS subscription endpoints. -package handler - -import ( - "context" - "net/http" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/middleware" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -type subscriptionReq struct { - Name string `json:"name" binding:"required"` - FeedURL string `json:"feed_url" binding:"required"` - Filter string `json:"filter"` - MediaType string `json:"media_type"` - MediaCategory string `json:"media_category"` - SavePath string `json:"save_path"` - SearchMode string `json:"search_mode"` - IMDBID string `json:"imdb_id"` - Source string `json:"source"` - PosterURL string `json:"poster_url"` - BackdropURL string `json:"backdrop_url"` - Overview string `json:"overview"` - OriginalName string `json:"original_name"` - Year int `json:"year"` - Resolution string `json:"resolution"` - Quality string `json:"quality"` - Effects string `json:"effects"` - ReleaseGroups string `json:"release_groups"` - ExcludeWords string `json:"exclude_words"` - MinSeeders int `json:"min_seeders"` - MaxSeeders int `json:"max_seeders"` - MinSizeGB float64 `json:"min_size_gb"` - MaxSizeGB float64 `json:"max_size_gb"` - FreeOnly bool `json:"free_only"` - WashEnabled bool `json:"wash_enabled"` - WashPriority string `json:"wash_priority"` - TotalEpisodes int `json:"total_episodes"` - Priority int `json:"priority"` - Enabled *bool `json:"enabled"` -} - -func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req subscriptionReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - uid, _ := c.Get(middleware.CtxUserID) - enabled := true - if req.Enabled != nil { - enabled = *req.Enabled - } - s := &model.Subscription{ - UserID: uid.(string), - Name: req.Name, - FeedURL: req.FeedURL, - Filter: req.Filter, - MediaType: req.MediaType, - MediaCategory: req.MediaCategory, - SavePath: req.SavePath, - SearchMode: req.SearchMode, - IMDBID: req.IMDBID, - Source: req.Source, - PosterURL: req.PosterURL, - BackdropURL: req.BackdropURL, - Overview: req.Overview, - OriginalName: req.OriginalName, - Year: req.Year, - Resolution: req.Resolution, - Quality: req.Quality, - Effects: req.Effects, - ReleaseGroups: req.ReleaseGroups, - ExcludeWords: req.ExcludeWords, - MinSeeders: req.MinSeeders, - MaxSeeders: req.MaxSeeders, - MinSizeGB: req.MinSizeGB, - MaxSizeGB: req.MaxSizeGB, - FreeOnly: req.FreeOnly, - WashEnabled: req.WashEnabled, - WashPriority: req.WashPriority, - TotalEpisodes: req.TotalEpisodes, - Priority: req.Priority, - Enabled: enabled, - } - enrichSubscriptionArtwork(c.Request.Context(), svc, s) - if err := svc.Subscription.Create(c.Request.Context(), s); err != nil { - logSubscriptionWarn(svc, "subscription create failed", - zap.String("user_id", s.UserID), - zap.String("name", req.Name), - zap.String("feed_kind", subscriptionFeedKind(req.FeedURL)), - zap.Bool("enabled", enabled), - zap.Error(err)) - if writeSubscriptionConflict(c, err) { - return - } - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - logSubscriptionInfo(svc, "subscription created", - zap.String("user_id", s.UserID), - zap.String("subscription_id", s.ID), - zap.String("name", s.Name), - zap.String("feed_kind", subscriptionFeedKind(s.FeedURL)), - zap.String("media_type", s.MediaType), - zap.String("media_category", s.MediaCategory), - zap.Bool("enabled", s.Enabled), - zap.Bool("wash_enabled", s.WashEnabled)) - enriched := []model.Subscription{*s} - svc.Subscription.EnrichManagementProgress(c.Request.Context(), enriched) - *s = enriched[0] - c.JSON(http.StatusCreated, s) - } -} - -func listSubscriptionsHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - items, err := svc.Subscription.List(c.Request.Context()) - if err != nil { - logSubscriptionWarn(svc, "subscription list failed", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - svc.Subscription.EnrichManagementProgress(c.Request.Context(), items) - go enrichAndPersistSubscriptions(context.Background(), svc, append([]model.Subscription(nil), items...)) - logSubscriptionInfo(svc, "subscription list returned", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.Int("count", len(items)), - zap.Bool("history", false)) - c.JSON(http.StatusOK, gin.H{"items": items}) - } -} - -func listSubscriptionHistoryHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - items, err := svc.Subscription.History(c.Request.Context()) - if err != nil { - logSubscriptionWarn(svc, "subscription history list failed", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - svc.Subscription.EnrichManagementProgress(c.Request.Context(), items) - logSubscriptionInfo(svc, "subscription list returned", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.Int("count", len(items)), - zap.Bool("history", true)) - c.JSON(http.StatusOK, gin.H{"items": items}) - } -} - -func deleteSubscriptionHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if err := svc.Subscription.Delete(c.Request.Context(), c.Param("id")); err != nil { - logSubscriptionWarn(svc, "subscription delete failed", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.String("subscription_id", c.Param("id")), - zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - logSubscriptionInfo(svc, "subscription deleted", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.String("subscription_id", c.Param("id"))) - c.Status(http.StatusNoContent) - } -} - -func runSubscriptionHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - n, err := svc.Subscription.RunNow(c.Request.Context(), c.Param("id")) - if err != nil { - logSubscriptionWarn(svc, "subscription run now failed", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.String("subscription_id", c.Param("id")), - zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - logSubscriptionInfo(svc, "subscription run now completed", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.String("subscription_id", c.Param("id")), - zap.Int("queued", n)) - c.JSON(http.StatusOK, gin.H{"queued": n}) - } -} - -func restoreSubscriptionHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - sub, err := svc.Subscription.Restore(c.Request.Context(), c.Param("id")) - if err != nil { - logSubscriptionWarn(svc, "subscription restore failed", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.String("subscription_id", c.Param("id")), - zap.Error(err)) - if writeSubscriptionConflict(c, err) { - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - logSubscriptionInfo(svc, "subscription restored", - zap.String("user_id", subscriptionRequestUserID(c)), - zap.String("subscription_id", sub.ID), - zap.String("name", sub.Name)) - enriched := []model.Subscription{*sub} - svc.Subscription.EnrichManagementProgress(c.Request.Context(), enriched) - c.JSON(http.StatusOK, enriched[0]) - } -} diff --git a/internal/handler/system_extra.go b/internal/handler/system_extra.go index 6576d4b..b574a7c 100644 --- a/internal/handler/system_extra.go +++ b/internal/handler/system_extra.go @@ -58,13 +58,12 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc { return func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "groups": []gin.H{ - { - "key": "general", - "label": "常规", - "items": []gin.H{ - {"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"}, - {"key": "app.server_url", "type": "text", "label": "公开访问域名 / STRM 域名"}, - {"key": "transcode.enabled", "type": "toggle", "label": "启用转码"}, + { + "key": "general", + "label": "常规", + "items": []gin.H{ + {"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"}, + {"key": "transcode.enabled", "type": "toggle", "label": "启用转码"}, {"key": "transcode.hw_accel", "type": "select", "label": "硬件编码器"}, {"key": "transcode.hw_enabled", "type": "toggle", "label": "启用硬件加速"}, {"key": "transcode.max_jobs", "type": "number", "label": "最大并发"}, @@ -121,19 +120,10 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc { {"key": "qbittorrent.password", "type": "text"}, {"key": "qbittorrent.savepath", "type": "text"}, }, - }, - { - "key": "license", - "label": "授权服务", - "items": []gin.H{ - {"key": "license.server_url", "type": "text", "label": "License Server 地址"}, - {"key": "license.public_key", "type": "text", "label": "Ed25519 验签公钥"}, - {"key": "license.hmac_secret", "type": "text", "label": "HMAC 签名密钥(旧版兼容)"}, - }, - }, - { - "key": "system-update", - "label": "系统更新", +}, + { + "key": "system-update", + "label": "系统更新", "items": []gin.H{ {"key": "system.update.image", "type": "text", "label": "应用镜像"}, {"key": "system.update.compose_dir", "type": "text", "label": "Docker Compose 安装目录"}, @@ -145,16 +135,6 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc { } } -// schedulerTriggerHandler is the alternate path for /admin/scheduler/:name/run. -func schedulerTriggerHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - if !triggerSchedulerJob(c, svc, c.Param("name")) { - return - } - c.JSON(http.StatusAccepted, gin.H{"ok": true, "message": "任务已在后台触发"}) - } -} - // ─── SSE ticket store ─────────────────────────────────────────────────────── // // The Vue UI's SSE event stream wants a one-time signed ticket so the diff --git a/internal/handler/system_meta.go b/internal/handler/system_meta.go index 9f97157..8e1b4d3 100644 --- a/internal/handler/system_meta.go +++ b/internal/handler/system_meta.go @@ -63,11 +63,3 @@ func systemStatusHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusOK, out) } } - -// systemSchedulerHandler is the read-only (non-admin) variant of -// /admin/scheduler — handy on user-facing dashboards. -func systemSchedulerHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"jobs": svc.Scheduler.Status()}) - } -} diff --git a/internal/handler/task_helpers.go b/internal/handler/task_helpers.go index 33eed7b..caeed3e 100644 --- a/internal/handler/task_helpers.go +++ b/internal/handler/task_helpers.go @@ -7,4 +7,4 @@ func finishHTTPTask(task *service.TaskHandle, err error, stage, message string, return } task.Finish(err, service.TaskUpdate{Stage: stage, Message: message, Metrics: metrics, Details: details}) -} +} \ No newline at end of file diff --git a/internal/handler/tasks.go b/internal/handler/tasks.go deleted file mode 100644 index 3d4468d..0000000 --- a/internal/handler/tasks.go +++ /dev/null @@ -1,40 +0,0 @@ -// Package handler — live tasks board. -// -// /api/tasks aggregates running ffmpeg transcodes, qBittorrent torrents -// and recent scrape progress into a single snapshot suitable for the -// React Tasks panel. The panel can layer this REST snapshot on top of -// live WS events for instant updates. -package handler - -import ( - "net/http" - "time" - - "github.com/gin-gonic/gin" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -const tasksLiveTorrentSnapshotMaxAge = 30 * time.Second - -func tasksHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var transcodes []service.ActiveJob - if svc.Transcoder != nil { - transcodes = svc.Transcoder.Active() - } - var torrents []service.QBitTorrent - if svc.Downloads != nil { - torrents = svc.Downloads.LiveTorrentSnapshot(tasksLiveTorrentSnapshotMaxAge) - } - background := service.TaskSnapshot{} - if svc.Tasks != nil { - background = svc.Tasks.Snapshot() - } - c.JSON(http.StatusOK, gin.H{ - "transcodes": transcodes, - "torrents": torrents, - "background_tasks": background, - }) - } -} diff --git a/internal/handler/telegram_webhook.go b/internal/handler/telegram_webhook.go deleted file mode 100644 index e1abae8..0000000 --- a/internal/handler/telegram_webhook.go +++ /dev/null @@ -1,104 +0,0 @@ -// Package handler — Telegram Bot Webhook 端点。 -// -// 接收 Telegram Bot API 推送的 update 消息,路由到 TelegramBotService 处理。 -package handler - -import ( - "context" - "io" - "net/http" - "time" - - "github.com/gin-gonic/gin" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/service" -) - -// telegramWebhookHandler 处理 Telegram Bot 的 Webhook 回调。 -// -// 路由:POST /api/telegram/webhook (无需认证,由 Telegram 服务器调用) -func telegramWebhookHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - body, err := io.ReadAll(c.Request.Body) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "cannot read body"}) - return - } - - if err := svc.TelegramBot.HandleWebhook(c.Request.Context(), body); err != nil { - svc.Log.Error("telegram webhook error", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"ok": true}) - } -} - -// telegramSetWebhookHandler 管理员手动设置/更新 Webhook URL。 -// -// 路由:POST /api/admin/telegram/webhook (需 admin 认证) -func telegramSetWebhookHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req struct { - BotToken string `json:"bot_token" binding:"required"` - WebhookURL string `json:"webhook_url" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := svc.TelegramBot.SetWebhook(c.Request.Context(), req.BotToken, req.WebhookURL); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "webhook set successfully", "url": req.WebhookURL}) - } -} - -// telegramGetWebhookHandler 获取当前 Webhook 配置信息。 -// -// 路由:GET /api/admin/telegram/webhook (需 admin 认证) -func telegramGetWebhookHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - botToken := c.Query("bot_token") - if botToken == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "bot_token is required"}) - return - } - - info, err := svc.TelegramBot.GetWebhookInfo(c.Request.Context(), botToken) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, info) - } -} - -// telegramStartPollingHandler 启动 Telegram 长轮询。 -// -// 路由:POST /api/admin/telegram/polling/start (需 admin 认证) -func telegramStartPollingHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - setupCtx, cancel := context.WithTimeout(svc.Context(), 30*time.Second) - defer cancel() - - result := svc.TelegramBot.StartPolling(setupCtx) - c.JSON(http.StatusOK, result) - } -} - -// telegramStopPollingHandler 停止 Telegram 长轮询。 -// -// 路由:POST /api/admin/telegram/polling/stop (需 admin 认证) -func telegramStopPollingHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - stopped := svc.TelegramBot.StopPolling() - c.JSON(http.StatusOK, gin.H{"message": "polling stopped", "stopped": stopped}) - } -} diff --git a/internal/helper/flaresolverr.go b/internal/helper/flaresolverr.go deleted file mode 100644 index d001c24..0000000 --- a/internal/helper/flaresolverr.go +++ /dev/null @@ -1,128 +0,0 @@ -package helper - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" - - "go.uber.org/zap" -) - -// FlareSolverrRequest represents a request to FlareSolverr. -type FlareSolverrRequest struct { - Cmd string `json:"cmd"` - URL string `json:"url"` - Session string `json:"session,omitempty"` - MaxTimeout int `json:"maxTimeout,omitempty"` - Proxy *FlareSolverrProxy `json:"proxy,omitempty"` - Cookies []FlareSolverrCookie `json:"cookies,omitempty"` -} - -// FlareSolverrProxy represents proxy config for FlareSolverr. -type FlareSolverrProxy struct { - URL string `json:"url"` - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` -} - -// FlareSolverrCookie represents a cookie for FlareSolverr. -type FlareSolverrCookie struct { - Name string `json:"name"` - Value string `json:"value"` - Domain string `json:"domain,omitempty"` - Path string `json:"path,omitempty"` -} - -// FlareSolverrResponse represents FlareSolverr's response. -type FlareSolverrResponse struct { - Status string `json:"status"` - Message string `json:"message"` - Solution *FlareSolverrSolution `json:"solution,omitempty"` -} - -// FlareSolverrSolution contains the solved challenge result. -type FlareSolverrSolution struct { - URL string `json:"url"` - Status int `json:"status"` - Headers map[string]string `json:"headers"` - Cookies []FlareSolverrCookie `json:"cookies"` - UserAgent string `json:"userAgent"` - Response string `json:"response"` -} - -// FetchURLWithFlareSolverr uses FlareSolverr to fetch a URL, -// bypassing Cloudflare/WAF challenges. -func FetchURLWithFlareSolverr(flareSolverrURL string, targetURL string, cookieStr string, timeout int, proxyURL string, log *zap.Logger) (string, error) { - if flareSolverrURL == "" { - return "", fmt.Errorf("FlareSolverr URL not configured") - } - if timeout <= 0 { - timeout = 60 - } - - var cookies []FlareSolverrCookie - if cookieStr != "" { - cookies = parseCookiesForFlareSolverr(cookieStr) - } - - reqBody := FlareSolverrRequest{ - Cmd: "request.get", - URL: targetURL, - MaxTimeout: timeout * 1000, - Cookies: cookies, - } - if proxyURL != "" { - reqBody.Proxy = &FlareSolverrProxy{URL: proxyURL} - } - - jsonBody, err := json.Marshal(reqBody) - if err != nil { - return "", fmt.Errorf("failed to marshal FlareSolverr request: %w", err) - } - - client := &http.Client{Timeout: time.Duration(timeout+10) * time.Second} - resp, err := client.Post(flareSolverrURL, "application/json", strings.NewReader(string(jsonBody))) - if err != nil { - return "", fmt.Errorf("FlareSolverr request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read FlareSolverr response: %w", err) - } - - var fsResp FlareSolverrResponse - if err := json.Unmarshal(body, &fsResp); err != nil { - return "", fmt.Errorf("failed to parse FlareSolverr response: %w", err) - } - - if fsResp.Status != "ok" { - return "", fmt.Errorf("FlareSolverr error: %s", fsResp.Message) - } - - if fsResp.Solution != nil { - return fsResp.Solution.Response, nil - } - return "", fmt.Errorf("FlareSolverr returned no solution") -} - -// parseCookiesForFlareSolverr converts a cookie header string to FlareSolverr format. -func parseCookiesForFlareSolverr(cookieStr string) []FlareSolverrCookie { - var cookies []FlareSolverrCookie - parts := strings.Split(cookieStr, ";") - for _, part := range parts { - part = strings.TrimSpace(part) - kv := strings.SplitN(part, "=", 2) - if len(kv) == 2 { - cookies = append(cookies, FlareSolverrCookie{ - Name: kv[0], - Value: kv[1], - }) - } - } - return cookies -} diff --git a/internal/helper/http.go b/internal/helper/http.go index 4d85d41..bbafdf3 100644 --- a/internal/helper/http.go +++ b/internal/helper/http.go @@ -6,10 +6,11 @@ import ( "net/http" "net/url" "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" ) +// defaultUserAgent 是默认浏览器 User-Agent(用于 HTTP 请求头)。 +const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" + // NewSiteHTTPClient builds an http.Client honoring per-site policies: // - timeout (seconds, defaults to 15) // - proxy via HTTP(S)_PROXY environment variables when site.UseProxy is on @@ -42,7 +43,7 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client { // These mimic a real Chrome browser to avoid WAF/bot detection. func HTTPHeaderPresets() map[string]string { return map[string]string{ - "User-Agent": model.DefaultUserAgent, + "User-Agent": defaultUserAgent, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept-Encoding": "gzip, deflate, br", diff --git a/internal/helper/page_source.go b/internal/helper/page_source.go deleted file mode 100644 index 7adb265..0000000 --- a/internal/helper/page_source.go +++ /dev/null @@ -1,48 +0,0 @@ -package helper - -import ( - "io" - "net/http" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "go.uber.org/zap" -) - -// GetPageSource fetches a page with browser-like headers. -// Returns (pageSource, cookies, error). -func GetPageSource(url string, site *model.Site, timeout int, log *zap.Logger) (string, string, error) { - client := NewSiteHTTPClient(timeout, site.UseProxy) - - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return "", "", err - } - - headers := HTTPHeaderPresets() - for k, v := range headers { - req.Header.Set(k, v) - } - - ApplySiteAuthHeaders(req, site) - - resp, err := client.Do(req) - if err != nil { - return "", "", err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", "", err - } - - cookies := "" - for _, c := range resp.Cookies() { - if cookies != "" { - cookies += "; " - } - cookies += c.Name + "=" + c.Value - } - - return string(body), cookies, nil -} diff --git a/internal/helper/site_auth.go b/internal/helper/site_auth.go deleted file mode 100644 index bb7c96f..0000000 --- a/internal/helper/site_auth.go +++ /dev/null @@ -1,50 +0,0 @@ -package helper - -import ( - "net/http" - "net/url" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// ApplySiteAuthHeaders applies authentication headers based on site config. -func ApplySiteAuthHeaders(req *http.Request, site *model.Site) { - switch site.AuthType { - case "cookie": - if site.Cookie != "" { - req.Header.Set("Cookie", site.Cookie) - } - case "api_key": - if site.APIKey != "" { - if isYemaPTSite(site) { - req.Header.Set("Authorization", site.APIKey) - } else { - req.Header.Set("x-api-key", site.APIKey) - } - } - case "auth_header": - if site.AuthHeader != "" { - req.Header.Set("Authorization", site.AuthHeader) - } - } - - if site.UserAgent != "" { - req.Header.Set("User-Agent", site.UserAgent) - } -} - -func isYemaPTSite(site *model.Site) bool { - if site == nil { - return false - } - if strings.EqualFold(strings.TrimSpace(site.Type), "yemapt") { - return true - } - u, err := url.Parse(strings.TrimSpace(site.URL)) - if err != nil { - return false - } - host := strings.ToLower(u.Hostname()) - return host == "yemapt.org" || strings.HasSuffix(host, ".yemapt.org") -} diff --git a/internal/helper/site_connectivity.go b/internal/helper/site_connectivity.go deleted file mode 100644 index 0626ffe..0000000 --- a/internal/helper/site_connectivity.go +++ /dev/null @@ -1,96 +0,0 @@ -package helper - -import ( - "fmt" - "io" - "net/http" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "go.uber.org/zap" -) - -// TestSiteConnectivity performs a site connectivity test with browser-like headers. -// If flareSolverrURL is non-empty AND the site has BrowserEmulation turned on, -// it will attempt to use FlareSolverr first. -// Returns (ok, message, error). -func TestSiteConnectivity(site *model.Site, flareSolverrURL string, timeout int, log *zap.Logger) (bool, string, error) { - // Try FlareSolverr first when (a) globally enabled and (b) the site - // asked for browser emulation. This matches the contract used by the - // search path (see service.SiteService.siteModelToConfig). - useFlare := flareSolverrURL != "" && site.BrowserEmulation - if useFlare { - log.Info("Trying FlareSolverr for site test", zap.String("url", site.URL)) - body, err := FetchURLWithFlareSolverr(flareSolverrURL, site.URL, site.Cookie, timeout, "", log) - if err == nil { - // Successfully got page via FlareSolverr - if IsCloudflareChallenge(body) { - return false, "站点被 Cloudflare/WAF 拦截,但 FlareSolverr 未能完全解决", nil - } - return true, "连接成功 (via FlareSolverr)", nil - } - log.Warn("FlareSolverr failed, falling back to direct request", zap.Error(err)) - // Fall through to direct request - } - - // Direct HTTP request with browser-like headers. Honors HTTP(S)_PROXY - // when the site has UseProxy enabled — this makes the "use proxy" - // checkbox in the UI actually do something. - client := NewSiteHTTPClient(timeout, site.UseProxy) - client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - if len(via) >= 10 { - return fmt.Errorf("too many redirects") - } - return nil - } - - req, err := http.NewRequest("GET", site.URL, nil) - if err != nil { - return false, err.Error(), nil - } - - headers := HTTPHeaderPresets() - for k, v := range headers { - req.Header.Set(k, v) - } - - ApplySiteAuthHeaders(req, site) - - resp, err := client.Do(req) - if err != nil { - return false, err.Error(), nil - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - bodyStr := string(body) - - if IsCloudflareChallenge(bodyStr) { - log.Warn("Cloudflare challenge detected", zap.String("url", site.URL)) - return false, "站点被 Cloudflare/WAF 拦截,请配置 FlareSolverr 或浏览器模拟", nil - } - - // Evaluate status code (mirror the reference Python project's semantics: - // 200 → success, 3xx → success/redirect-to-self, 401/403 → failure with - // hint to check credentials, 4xx/5xx → failure with raw status text). - switch { - case resp.StatusCode >= 200 && resp.StatusCode < 300: - return true, fmt.Sprintf("连接成功 (%s)", resp.Status), nil - case resp.StatusCode == 301 || resp.StatusCode == 302 || resp.StatusCode == 307 || resp.StatusCode == 308: - loc := resp.Header.Get("Location") - if loc == "" { - loc = "(unknown)" - } - // Most PT sites redirect logged-out users to login; treat as failure. - return false, fmt.Sprintf("未登录或 Cookie 失效(重定向至 %s)", loc), nil - case resp.StatusCode == 401: - return false, "未授权(HTTP 401),请检查 API Key / Cookie", nil - case resp.StatusCode == 403: - return false, "认证失败(HTTP 403),请检查 Cookie / API Key 或站点是否需要浏览器模拟", nil - case resp.StatusCode == 429: - return false, "请求被限流(HTTP 429),请稍后再试", nil - case resp.StatusCode == 503: - return false, "服务暂时不可用(HTTP 503)", nil - default: - return false, resp.Status, nil - } -} diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go index 33c99a1..ad684de 100644 --- a/internal/middleware/middleware_test.go +++ b/internal/middleware/middleware_test.go @@ -106,13 +106,13 @@ func TestAuthRequiredSyncsAccessTokenCookieFromBearer(t *testing.T) { }, }) - router := gin.New() - router.Use(AuthRequired(secret)) - router.GET("/api/discover/feed", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"ok": true}) - }) + router := gin.New() + router.Use(AuthRequired(secret)) + router.GET("/api/test-auth-cookie", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) - req := httptest.NewRequest(http.MethodGet, "/api/discover/feed", nil) + req := httptest.NewRequest(http.MethodGet, "/api/test-auth-cookie", nil) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() router.ServeHTTP(w, req) diff --git a/internal/model/download_client.go b/internal/model/download_client.go deleted file mode 100644 index 45fca74..0000000 --- a/internal/model/download_client.go +++ /dev/null @@ -1,16 +0,0 @@ -// Package model 定义下载客户端配置数据模型。 -package model - -// DownloadClient 下载客户端配置(qBittorrent / Transmission / Aria2)。 -// 支持多客户端并行运行,一个标记为默认客户端。 -type DownloadClient struct { - Base - Name string `gorm:"size:128;not null" json:"name"` - Type string `gorm:"size:32;not null" json:"type"` // qbittorrent / transmission / aria2 - Host string `gorm:"size:512;not null" json:"host"` // http://host:port - Username string `gorm:"size:256" json:"username"` - Password string `gorm:"size:1024" json:"-"` // AES加密存储 - IsDefault bool `gorm:"default:false" json:"is_default"` - Enabled bool `gorm:"default:true" json:"enabled"` - Extra string `gorm:"type:text" json:"-"` // JSON配置, AES加密 -} diff --git a/internal/model/download_subscription.go b/internal/model/download_subscription.go deleted file mode 100644 index 50ad885..0000000 --- a/internal/model/download_subscription.go +++ /dev/null @@ -1,81 +0,0 @@ -package model - -import "time" - -// DownloadTask 是待处理(或已完成)的 torrent / HTTP 下载。 -type DownloadTask struct { - Base - UserID string `gorm:"index;size:36" json:"user_id"` - SubscriptionID string `gorm:"index;size:36" json:"subscription_id,omitempty"` - DownloadClientID string `gorm:"index;size:36" json:"download_client_id,omitempty"` - ExternalID string `gorm:"index;size:128" json:"external_id,omitempty"` - Source string `gorm:"size:32;not null" json:"source"` // qbittorrent / transmission / aria2 / http - URL string `gorm:"size:2048;not null" json:"-"` - Title string `gorm:"size:512" json:"title,omitempty"` - PosterURL string `gorm:"size:2048" json:"poster_url,omitempty"` - BackdropURL string `gorm:"size:2048" json:"backdrop_url,omitempty"` - Overview string `gorm:"type:text" json:"overview,omitempty"` - SavePath string `gorm:"size:1024" json:"save_path"` - MediaType string `gorm:"size:16" json:"media_type,omitempty"` - MediaCategory string `gorm:"size:128" json:"media_category,omitempty"` - // 媒体展示元数据(用于 Telegram 富通知模板等):原始片名/语言/年份/评分/类型。 - OriginalName string `gorm:"size:512" json:"original_name,omitempty"` - OriginalLanguage string `gorm:"size:32" json:"original_language,omitempty"` - Year int `json:"year,omitempty"` - Rating float32 `json:"rating,omitempty"` - Genres string `gorm:"size:255" json:"genres,omitempty"` // comma separated - Status string `gorm:"size:32;default:queued" json:"status"` - Progress float32 `json:"progress"` - - // AllowExistingLibrary is true for subscription wash/upgrade tasks that are - // allowed to replace an existing library item after download completion. - AllowExistingLibrary bool `gorm:"default:false" json:"allow_existing_library,omitempty"` -} - -// Subscription 是自动化规则,轮询 RSS 源并将匹配种子排队到配置的下载客户端。 -type Subscription struct { - Base - UserID string `gorm:"index;size:36" json:"user_id"` - IdentityKey string `gorm:"size:64" json:"-"` - Name string `gorm:"size:128;not null" json:"name"` - FeedURL string `gorm:"size:2048;not null" json:"feed_url"` - Filter string `gorm:"size:512" json:"filter"` - MediaType string `gorm:"size:16" json:"media_type,omitempty"` - MediaCategory string `gorm:"size:128" json:"media_category,omitempty"` - SavePath string `gorm:"size:1024" json:"save_path,omitempty"` - SearchMode string `gorm:"size:16;default:keyword" json:"search_mode,omitempty"` // keyword / imdb - IMDBID string `gorm:"size:32" json:"imdb_id,omitempty"` - Source string `gorm:"size:32" json:"source,omitempty"` - PosterURL string `gorm:"size:2048" json:"poster_url,omitempty"` - BackdropURL string `gorm:"size:2048" json:"backdrop_url,omitempty"` - Overview string `gorm:"type:text" json:"overview,omitempty"` - // 媒体展示元数据(用于 Telegram 富通知模板等):原始片名/语言/年份/评分/类型。 - OriginalName string `gorm:"size:512" json:"original_name,omitempty"` - OriginalLanguage string `gorm:"size:32" json:"original_language,omitempty"` - Year int `json:"year,omitempty"` - Rating float32 `json:"rating,omitempty"` - Genres string `gorm:"size:255" json:"genres,omitempty"` // comma separated - Resolution string `gorm:"size:32" json:"resolution,omitempty"` // 2160p / 1080p / 720p / best - Quality string `gorm:"size:64" json:"quality,omitempty"` // remux / bluray / web-dl / hdtv - Effects string `gorm:"size:128" json:"effects,omitempty"` // hdr,dolby-vision,atmos - ReleaseGroups string `gorm:"size:255" json:"release_groups,omitempty"` // comma separated - ExcludeWords string `gorm:"size:255" json:"exclude_words,omitempty"` // comma separated - MinSeeders int `gorm:"default:0" json:"min_seeders,omitempty"` - MaxSeeders int `gorm:"default:0" json:"max_seeders,omitempty"` - MinSizeGB float64 `gorm:"default:0" json:"min_size_gb,omitempty"` - MaxSizeGB float64 `gorm:"default:0" json:"max_size_gb,omitempty"` - FreeOnly bool `gorm:"default:false" json:"free_only,omitempty"` - WashEnabled bool `gorm:"default:false" json:"wash_enabled"` - WashPriority string `gorm:"size:32" json:"wash_priority,omitempty"` // balanced / resolution / quality / effects / seeders - TotalEpisodes int `gorm:"default:0" json:"total_episodes,omitempty"` - Priority int `gorm:"default:50" json:"priority,omitempty"` // lower is earlier when schedulers sort later - Enabled bool `gorm:"default:true" json:"enabled"` - LastRunAt *time.Time `json:"last_run_at,omitempty"` - ArchivedAt *time.Time `gorm:"index" json:"archived_at,omitempty"` - ArchiveReason string `gorm:"size:255" json:"archive_reason,omitempty"` - - DownloadedEpisodes int `gorm:"-" json:"downloaded_episodes,omitempty"` - LocalMediaCount int `gorm:"-" json:"local_media_count,omitempty"` - MissingEpisodes []int `gorm:"-" json:"missing_episodes,omitempty"` - InLibrary bool `gorm:"-" json:"in_library"` -} diff --git a/internal/model/model.go b/internal/model/model.go index 28df1bd..794bc19 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -41,23 +41,13 @@ func AllModels() []interface{} { &Favorite{}, &Playlist{}, &PlaylistItem{}, - &DownloadTask{}, - &Subscription{}, &Setting{}, - &Site{}, &AccessLog{}, &APIConfig{}, &UserPermission{}, &RefreshToken{}, &ApiConfig{}, - &DownloadClient{}, - &NotifyChannel{}, - &TelegramBinding{}, - &STRMRecord{}, &PlayProfile{}, - &StorageConfig{}, - &AssistantSession{}, - &AssistantMessage{}, &RegistrationCode{}, &SignIn{}, &UserDevice{}, diff --git a/internal/model/model_test.go b/internal/model/model_test.go index 922cda3..e64a403 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -19,7 +19,6 @@ func TestMediaReferenceFieldsAllowVirtualEmbyIDs(t *testing.T) { {name: "playback_history_media_id", model: &PlaybackHistory{}, fieldName: "MediaID"}, {name: "favorite_media_id", model: &Favorite{}, fieldName: "MediaID"}, {name: "playlist_item_media_id", model: &PlaylistItem{}, fieldName: "MediaID"}, - {name: "strm_record_media_id", model: &STRMRecord{}, fieldName: "MediaID"}, } for _, tc := range cases { diff --git a/internal/model/notify_channel.go b/internal/model/notify_channel.go deleted file mode 100644 index a31f27c..0000000 --- a/internal/model/notify_channel.go +++ /dev/null @@ -1,14 +0,0 @@ -// Package model 定义通知渠道配置数据模型。 -package model - -// NotifyChannel 通知渠道配置。 -// 支持多种通知渠道:telegram / wechat / bark / webhook / email。 -// Events 字段存储 JSON array,表示该渠道订阅的事件类型。 -type NotifyChannel struct { - Base - Name string `gorm:"size:128;not null" json:"name"` - Type string `gorm:"size:32;not null" json:"type"` // telegram / wechat / bark / webhook / email - Enabled bool `gorm:"default:true" json:"enabled"` - Config string `gorm:"type:text" json:"-"` // JSON配置, AES加密 - Events string `gorm:"type:text" json:"events"` // 订阅的事件列表, JSON array -} diff --git a/internal/model/permission.go b/internal/model/permission.go index 45ebc6b..99bc59b 100644 --- a/internal/model/permission.go +++ b/internal/model/permission.go @@ -28,9 +28,8 @@ type UserPermission struct { CanRescrape bool `gorm:"default:false" json:"can_rescrape"` CanUseAI bool `gorm:"default:false" json:"can_use_ai"` CanCaptureFrames bool `gorm:"default:false" json:"can_capture_frames"` - CanManageDownloads bool `gorm:"default:false" json:"can_manage_downloads"` - CanViewDiscover bool `gorm:"default:false" json:"can_view_discover"` - CanManageSubscriptions bool `gorm:"default:false" json:"can_manage_subscriptions"` + CanManageDownloads bool `gorm:"default:false" json:"can_manage_downloads"` + CanManageSubscriptions bool `gorm:"default:false" json:"can_manage_subscriptions"` CanManageSites bool `gorm:"default:false" json:"can_manage_sites"` CanUseAIAssistant bool `gorm:"default:false" json:"can_use_ai_assistant"` CanManageUsers bool `gorm:"default:false" json:"can_manage_users"` @@ -65,9 +64,8 @@ func NewDefaultPermission(userID string) *UserPermission { CanRescrape: false, CanUseAI: false, CanCaptureFrames: false, - CanManageDownloads: false, - CanViewDiscover: false, - CanManageSubscriptions: false, + CanManageDownloads: false, + CanManageSubscriptions: false, CanManageSites: false, CanUseAIAssistant: false, CanManageUsers: false, @@ -90,9 +88,8 @@ func (p *UserPermission) PermissionMap() map[string]bool { "can_rescrape": p.CanRescrape, "can_use_ai": p.CanUseAI, "can_capture_frames": p.CanCaptureFrames, - "can_manage_downloads": p.CanManageDownloads, - "can_view_discover": p.CanViewDiscover, - "can_manage_subscriptions": p.CanManageSubscriptions, + "can_manage_downloads": p.CanManageDownloads, + "can_manage_subscriptions": p.CanManageSubscriptions, "can_manage_sites": p.CanManageSites, "can_use_ai_assistant": p.CanUseAIAssistant, "can_manage_users": p.CanManageUsers, diff --git a/internal/model/site.go b/internal/model/site.go deleted file mode 100644 index d6d7303..0000000 --- a/internal/model/site.go +++ /dev/null @@ -1,57 +0,0 @@ -// Package model — PT 站点配置数据模型。 -package model - -import ( - "time" -) - -// Site PT 站点配置。 -type Site struct { - Base - Name string `gorm:"size:128;not null" json:"name"` - Type string `gorm:"size:32;not null" json:"type"` // nexusphp / gazelle / unit3d / mteam / yemapt / discuz / custom_rss - URL string `gorm:"size:512;not null" json:"url"` - AuthType string `gorm:"size:32;not null" json:"auth_type"` // cookie / api_key / auth_header - Cookie string `gorm:"type:text" json:"-"` // AES 加密 - APIKey string `gorm:"type:text" json:"-"` // AES 加密 - AuthHeader string `gorm:"type:text" json:"-"` // AES 加密 - - // ── 高级设置 ────────────────────────────────────────────────────────── - UserAgent string `gorm:"size:500" json:"user_agent"` // 自定义 User-Agent - RSSURL string `gorm:"size:1000" json:"rss_url"` // RSS 订阅地址 - Timeout int `gorm:"default:15" json:"timeout"` // 请求超时(秒), 0=不限制 - Priority int `gorm:"default:50" json:"priority"` // 优先级, 越小越优先 - UseProxy bool `gorm:"default:false" json:"use_proxy"` // 是否使用代理 - RateLimit bool `gorm:"default:false" json:"rate_limit"` // 是否限制访问频率 - BrowserEmulation bool `gorm:"default:false" json:"browser_emulation"` // 浏览器仿真(防爬) - - // ── 状态与统计 ──────────────────────────────────────────────────────── - LoginStatus string `gorm:"size:20;default:unknown" json:"login_status"` // unknown / ok / fail - UploadBytes int64 `gorm:"default:0" json:"upload_bytes"` // 上传字节统计 - DownloadBytes int64 `gorm:"default:0" json:"download_bytes"` // 下载字节统计 - - // ── 关联下载器 ──────────────────────────────────────────────────────── - Downloader string `gorm:"size:50" json:"downloader"` // qbittorrent / transmission / aria2 - - Enabled bool `gorm:"default:true" json:"enabled"` - IsDefault bool `gorm:"default:false" json:"is_default"` - Extra string `gorm:"type:text" json:"-"` // JSON 扩展配置, AES 加密 - LastError string `gorm:"size:1024" json:"last_error"` - LastCheckAt *time.Time `json:"last_check_at"` -} - -// SiteType 返回支持的站点类型列表。 -func SiteTypes() []string { - return []string{"nexusphp", "gazelle", "unit3d", "mteam", "yemapt", "discuz", "custom_rss"} -} - -// AuthTypes 返回支持的认证方式列表。 -func AuthTypes() []string { - return []string{"cookie", "api_key", "auth_header"} -} - -// DefaultUserAgent 是默认浏览器 User-Agent(用于 HTTP 请求头)。 -const DefaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" - -// SiteTypes 返回支持的站点类型列表(用于前端下拉)。 -// 注意:此函数供 API 返回类型列表使用,不在此处添加新类型。 diff --git a/internal/model/storage_assistant.go b/internal/model/storage_assistant.go index 79b323b..501c770 100644 --- a/internal/model/storage_assistant.go +++ b/internal/model/storage_assistant.go @@ -14,23 +14,3 @@ type StorageConfig struct { Enabled bool `gorm:"default:true" json:"enabled"` LastError string `gorm:"size:512" json:"last_error,omitempty"` } - -// AssistantSession groups a multi-turn chat with the AI assistant. -type AssistantSession struct { - Base - UserID string `gorm:"index;size:36;not null" json:"user_id"` - Title string `gorm:"size:255" json:"title,omitempty"` -} - -// AssistantMessage is one entry in an AssistantSession transcript. -// -// Role is "user" | "assistant" | "system". The optional OperationID -// links a message to an action the assistant proposed (so the UI can -// offer Undo). -type AssistantMessage struct { - Base - SessionID string `gorm:"index;size:36;not null" json:"session_id"` - Role string `gorm:"size:16;not null" json:"role"` - Content string `gorm:"type:text;not null" json:"content"` - OperationID string `gorm:"size:36" json:"operation_id,omitempty"` -} diff --git a/internal/model/strm.go b/internal/model/strm.go deleted file mode 100644 index 9629cc2..0000000 --- a/internal/model/strm.go +++ /dev/null @@ -1,36 +0,0 @@ -// Package model — STRM 文件记录数据模型。 -package model - -// STRMRecord STRM 文件记录。 -// 外部存储以"文件"形式入库,URL 指向实际资源。 -type STRMRecord struct { - Base - Title string `gorm:"size:512;not null;index" json:"title"` - URL string `gorm:"size:2048;not null" json:"url"` // STRM 文件指向的 URL - FilePath string `gorm:"size:1024;not null" json:"file_path"` // 本地 STRM 文件路径 - Protocol string `gorm:"size:32;not null" json:"protocol"` // webdav / alist / s3 / http / https - FileSize int64 `json:"file_size"` - MediaID string `gorm:"size:128;index" json:"media_id"` // 关联媒体 ID - MediaType string `gorm:"size:16" json:"media_type"` // movie / series - SeasonNum int `json:"season_num"` - EpisodeNum int `json:"episode_num"` -} - -// AllowedSTRMProtocols 协议白名单。 -var AllowedSTRMProtocols = []string{ - "webdav", "davs", - "alist", "alists", - "openlist", "openlists", - "s3", - "http", "https", -} - -// IsAllowedProtocol 检查协议是否在白名单中。 -func IsAllowedProtocol(protocol string) bool { - for _, p := range AllowedSTRMProtocols { - if p == protocol { - return true - } - } - return false -} diff --git a/internal/model/subscription_identity.go b/internal/model/subscription_identity.go deleted file mode 100644 index 1cad050..0000000 --- a/internal/model/subscription_identity.go +++ /dev/null @@ -1,90 +0,0 @@ -package model - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "strings" -) - -type subscriptionIdentityPayload struct { - Name string `json:"name"` - FeedURL string `json:"feed_url"` - Filter string `json:"filter"` - MediaType string `json:"media_type"` - MediaCategory string `json:"media_category"` - SavePath string `json:"save_path"` - SearchMode string `json:"search_mode"` - IMDBID string `json:"imdb_id"` - Resolution string `json:"resolution"` - Quality string `json:"quality"` - Effects string `json:"effects"` - ReleaseGroups string `json:"release_groups"` - ExcludeWords string `json:"exclude_words"` - MinSeeders int `json:"min_seeders"` - MaxSeeders int `json:"max_seeders"` - MinSizeGB float64 `json:"min_size_gb"` - MaxSizeGB float64 `json:"max_size_gb"` - FreeOnly bool `json:"free_only"` - WashEnabled bool `json:"wash_enabled"` - WashPriority string `json:"wash_priority"` - Priority int `json:"priority"` -} - -// SubscriptionIdentityKey identifies one functional subscription rule. It -// intentionally excludes display metadata and runtime/archive state because -// those fields are backfilled or changed automatically after creation. -func SubscriptionIdentityKey(sub *Subscription) string { - if sub == nil { - return "" - } - payload := subscriptionIdentityPayload{ - Name: subscriptionIdentityFold(sub.Name), - FeedURL: strings.TrimSpace(sub.FeedURL), - Filter: strings.TrimSpace(sub.Filter), - MediaType: subscriptionIdentityFold(sub.MediaType), - MediaCategory: subscriptionIdentityFold(sub.MediaCategory), - SavePath: strings.TrimSpace(sub.SavePath), - SearchMode: subscriptionIdentityFold(sub.SearchMode), - IMDBID: subscriptionIdentityFold(sub.IMDBID), - Resolution: subscriptionIdentityFold(sub.Resolution), - Quality: subscriptionIdentityFold(sub.Quality), - Effects: subscriptionIdentityList(sub.Effects), - ReleaseGroups: subscriptionIdentityList(sub.ReleaseGroups), - ExcludeWords: subscriptionIdentityList(sub.ExcludeWords), - MinSeeders: sub.MinSeeders, - MaxSeeders: sub.MaxSeeders, - MinSizeGB: sub.MinSizeGB, - MaxSizeGB: sub.MaxSizeGB, - FreeOnly: sub.FreeOnly, - WashEnabled: sub.WashEnabled, - WashPriority: subscriptionIdentityFold(sub.WashPriority), - Priority: sub.Priority, - } - raw, _ := json.Marshal(payload) - sum := sha256.Sum256(raw) - return hex.EncodeToString(sum[:]) -} - -func RefreshSubscriptionIdentity(sub *Subscription) string { - key := SubscriptionIdentityKey(sub) - if sub != nil { - sub.IdentityKey = key - } - return key -} - -func subscriptionIdentityFold(value string) string { - return strings.ToLower(strings.TrimSpace(value)) -} - -func subscriptionIdentityList(value string) string { - parts := strings.Split(value, ",") - out := make([]string, 0, len(parts)) - for _, part := range parts { - if part = subscriptionIdentityFold(part); part != "" { - out = append(out, part) - } - } - return strings.Join(out, ",") -} diff --git a/internal/model/subscription_identity_test.go b/internal/model/subscription_identity_test.go deleted file mode 100644 index 361f1c0..0000000 --- a/internal/model/subscription_identity_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package model - -import "testing" - -func TestSubscriptionIdentityKeyNormalizesEquivalentRules(t *testing.T) { - base := Subscription{ - Name: " Example Show ", - FeedURL: "site-search://search?keyword=Example", - Filter: " Example.*Show ", - MediaType: "TV", - MediaCategory: " 欧美剧 ", - SearchMode: "KEYWORD", - Resolution: "1080P", - Effects: " HDR, Atmos ", - ExcludeWords: " CAM, TS ", - WashPriority: "Balanced", - Priority: 50, - } - equivalent := base - equivalent.Name = "example show" - equivalent.MediaType = "tv" - equivalent.MediaCategory = "欧美剧" - equivalent.SearchMode = "keyword" - equivalent.Resolution = "1080p" - equivalent.Effects = "hdr,atmos" - equivalent.ExcludeWords = "cam,ts" - equivalent.WashPriority = "balanced" - if got, want := SubscriptionIdentityKey(&equivalent), SubscriptionIdentityKey(&base); got != want { - t.Fatalf("equivalent keys differ: %s != %s", got, want) - } - - differentRule := base - differentRule.Resolution = "2160p" - if SubscriptionIdentityKey(&differentRule) == SubscriptionIdentityKey(&base) { - t.Fatal("different resolution should produce a different identity") - } - - displayOnly := base - displayOnly.PosterURL = "https://example.test/poster.jpg" - displayOnly.Overview = "new overview" - displayOnly.TotalEpisodes = 24 - if SubscriptionIdentityKey(&displayOnly) != SubscriptionIdentityKey(&base) { - t.Fatal("display/runtime metadata should not change identity") - } -} diff --git a/internal/model/telegram_binding.go b/internal/model/telegram_binding.go deleted file mode 100644 index 68135c9..0000000 --- a/internal/model/telegram_binding.go +++ /dev/null @@ -1,12 +0,0 @@ -package model - -// TelegramBinding links a Telegram account to a local MediaStationGo user. -// The binding is password-verified when /start is used, then reused for -// low-risk self-service actions such as toggling adult-library visibility. -type TelegramBinding struct { - Base - TelegramUserID int64 `gorm:"uniqueIndex;not null" json:"telegram_user_id"` - TelegramName string `gorm:"size:128" json:"telegram_name,omitempty"` - ChatID int64 `gorm:"index" json:"chat_id"` - UserID string `gorm:"index;size:36;not null" json:"user_id"` -} diff --git a/internal/repository/assistant_repo.go b/internal/repository/assistant_repo.go deleted file mode 100644 index b2bcd6c..0000000 --- a/internal/repository/assistant_repo.go +++ /dev/null @@ -1,64 +0,0 @@ -package repository - -import ( - "context" - "errors" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// AssistantRepository persists model.AssistantSession + AssistantMessage records. -type AssistantRepository struct{ db *gorm.DB } - -// ─── Session ──────────────────────────────────────────────────────────── - -// CreateSession inserts a new chat session. -func (r *AssistantRepository) CreateSession(ctx context.Context, s *model.AssistantSession) error { - return r.db.WithContext(ctx).Create(s).Error -} - -// FindSession returns a session by ID, or (nil, nil). -func (r *AssistantRepository) FindSession(ctx context.Context, id string) (*model.AssistantSession, error) { - var s model.AssistantSession - err := r.db.WithContext(ctx).Where("id = ?", id).First(&s).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &s, nil -} - -// ListSessions returns sessions for a user, or all when userID is empty. -func (r *AssistantRepository) ListSessions(ctx context.Context, userID string) ([]model.AssistantSession, error) { - q := r.db.WithContext(ctx).Model(&model.AssistantSession{}) - if userID != "" { - q = q.Where("user_id = ?", userID) - } - var rows []model.AssistantSession - err := q.Order("created_at desc").Find(&rows).Error - return rows, err -} - -// DeleteSession soft-deletes a session (cascade handled by GORM hooks if set). -func (r *AssistantRepository) DeleteSession(ctx context.Context, id string) error { - return r.db.WithContext(ctx).Delete(&model.AssistantSession{}, "id = ?", id).Error -} - -// ─── Message ──────────────────────────────────────────────────────────── - -// AppendMessage inserts a new message into a session. -func (r *AssistantRepository) AppendMessage(ctx context.Context, m *model.AssistantMessage) error { - return r.db.WithContext(ctx).Create(m).Error -} - -// ListMessages returns all messages for a session in chronological order. -func (r *AssistantRepository) ListMessages(ctx context.Context, sessionID string) ([]model.AssistantMessage, error) { - var rows []model.AssistantMessage - err := r.db.WithContext(ctx).Where("session_id = ?", sessionID). - Order("created_at asc").Find(&rows).Error - return rows, err -} diff --git a/internal/repository/download_client_repo.go b/internal/repository/download_client_repo.go deleted file mode 100644 index 9883878..0000000 --- a/internal/repository/download_client_repo.go +++ /dev/null @@ -1,105 +0,0 @@ -// Package repository 实现下载客户端配置的数据访问层。 -package repository - -import ( - "context" - "errors" - "time" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// DownloadClientRepository persists model.DownloadClient records. -type DownloadClientRepository struct{ db *gorm.DB } - -// Create inserts a new download client. -func (r *DownloadClientRepository) Create(ctx context.Context, c *model.DownloadClient) error { - return r.db.WithContext(ctx).Create(c).Error -} - -// FindByID returns the download client by ID, or (nil, nil) when absent. -func (r *DownloadClientRepository) FindByID(ctx context.Context, id string) (*model.DownloadClient, error) { - var c model.DownloadClient - err := r.db.WithContext(ctx).Where("id = ?", id).First(&c).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &c, nil -} - -// FindDefault returns the default download client, or (nil, nil). -func (r *DownloadClientRepository) FindDefault(ctx context.Context) (*model.DownloadClient, error) { - var c model.DownloadClient - err := r.db.WithContext(ctx). - Where("is_default = ? AND enabled = ?", true, true). - Order("created_at asc"). - First(&c).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &c, nil -} - -// List returns all download clients ordered by creation time. -func (r *DownloadClientRepository) List(ctx context.Context) ([]model.DownloadClient, error) { - var rows []model.DownloadClient - err := r.db.WithContext(ctx).Order("created_at asc").Find(&rows).Error - return rows, err -} - -// ListEnabled returns all enabled download clients. -func (r *DownloadClientRepository) ListEnabled(ctx context.Context) ([]model.DownloadClient, error) { - var rows []model.DownloadClient - err := r.db.WithContext(ctx).Where("enabled = ?", true).Order("created_at asc").Find(&rows).Error - return rows, err -} - -// HasAnyIncludingDeleted reports whether the operator has ever configured a -// download client. This distinguishes legacy-only installations from systems -// where deleting/disabling all clients is an intentional "stop downloads" -// action, even though rows are soft-deleted. -func (r *DownloadClientRepository) HasAnyIncludingDeleted(ctx context.Context) (bool, error) { - var n int64 - err := r.db.WithContext(ctx).Unscoped().Model(&model.DownloadClient{}).Count(&n).Error - return n > 0, err -} - -// Update persists changes to a download client. -func (r *DownloadClientRepository) Update(ctx context.Context, c *model.DownloadClient) error { - return r.db.WithContext(ctx).Save(c).Error -} - -// Delete removes a download client (soft-delete). -func (r *DownloadClientRepository) Delete(ctx context.Context, id string) error { - return r.db.WithContext(ctx).Delete(&model.DownloadClient{}, "id = ?", id).Error -} - -// ClearDefault unsets the default flag for all clients. -func (r *DownloadClientRepository) ClearDefault(ctx context.Context) error { - return r.db.WithContext(ctx).Model(&model.DownloadClient{}). - Where("is_default = ?", true).Update("is_default", false).Error -} - -// SetDefault sets a specific client as default and clears others. -func (r *DownloadClientRepository) SetDefault(ctx context.Context, id string) error { - now := time.Now() - return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.DownloadClient{}). - Where("is_default = ?", true).Update("is_default", false).Error; err != nil { - return err - } - return tx.Model(&model.DownloadClient{}). - Where("id = ?", id).Updates(map[string]any{ - "is_default": true, - "updated_at": now, - }).Error - }) -} diff --git a/internal/repository/download_repository.go b/internal/repository/download_repository.go deleted file mode 100644 index 041a557..0000000 --- a/internal/repository/download_repository.go +++ /dev/null @@ -1,24 +0,0 @@ -package repository - -import ( - "context" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// DownloadRepository persists model.DownloadTask records. -type DownloadRepository struct{ db *gorm.DB } - -// Create inserts a new download task. -func (r *DownloadRepository) Create(ctx context.Context, t *model.DownloadTask) error { - return r.db.WithContext(ctx).Create(t).Error -} - -// List returns all download tasks (admin view). -func (r *DownloadRepository) List(ctx context.Context) ([]model.DownloadTask, error) { - var rows []model.DownloadTask - err := r.db.WithContext(ctx).Order("created_at desc").Find(&rows).Error - return rows, err -} diff --git a/internal/repository/notify_channel_repo.go b/internal/repository/notify_channel_repo.go deleted file mode 100644 index f3aa4d4..0000000 --- a/internal/repository/notify_channel_repo.go +++ /dev/null @@ -1,74 +0,0 @@ -// Package repository 实现通知渠道配置的数据访问层。 -package repository - -import ( - "context" - "errors" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// NotifyChannelRepository persists model.NotifyChannel records. -type NotifyChannelRepository struct{ db *gorm.DB } - -// Create inserts a new notification channel. -func (r *NotifyChannelRepository) Create(ctx context.Context, c *model.NotifyChannel) error { - return r.db.WithContext(ctx).Create(c).Error -} - -// FindByID returns the notification channel by ID, or (nil, nil) when absent. -func (r *NotifyChannelRepository) FindByID(ctx context.Context, id string) (*model.NotifyChannel, error) { - var c model.NotifyChannel - err := r.db.WithContext(ctx).Where("id = ?", id).First(&c).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &c, nil -} - -// List returns all notification channels ordered by creation time. -func (r *NotifyChannelRepository) List(ctx context.Context) ([]model.NotifyChannel, error) { - var rows []model.NotifyChannel - err := r.db.WithContext(ctx).Order("created_at asc").Find(&rows).Error - return rows, err -} - -// ListEnabled returns all enabled notification channels. -func (r *NotifyChannelRepository) ListEnabled(ctx context.Context) ([]model.NotifyChannel, error) { - var rows []model.NotifyChannel - err := r.db.WithContext(ctx).Where("enabled = ?", true).Order("created_at asc").Find(&rows).Error - return rows, err -} - -// ListByEvent returns all enabled channels that subscribe to the given event type. -func (r *NotifyChannelRepository) ListByEvent(ctx context.Context, eventType string) ([]model.NotifyChannel, error) { - var rows []model.NotifyChannel - // Events is a JSON array stored as text; use LIKE for simple matching. - // This works for exact event type matches within the JSON array. - err := r.db.WithContext(ctx). - Where("enabled = ? AND events LIKE ?", true, "%\""+eventType+"\"%"). - Find(&rows).Error - return rows, err -} - -// ListByType returns all channels of a given type (telegram/wechat/bark/webhook/email). -func (r *NotifyChannelRepository) ListByType(ctx context.Context, channelType string) ([]model.NotifyChannel, error) { - var rows []model.NotifyChannel - err := r.db.WithContext(ctx).Where("type = ?", channelType).Find(&rows).Error - return rows, err -} - -// Update persists changes to a notification channel. -func (r *NotifyChannelRepository) Update(ctx context.Context, c *model.NotifyChannel) error { - return r.db.WithContext(ctx).Save(c).Error -} - -// Delete removes a notification channel (soft-delete). -func (r *NotifyChannelRepository) Delete(ctx context.Context, id string) error { - return r.db.WithContext(ctx).Delete(&model.NotifyChannel{}, "id = ?", id).Error -} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index e94f42c..08220db 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -17,20 +17,12 @@ type Container struct { History *HistoryRepository Favorite *FavoriteRepository Playlist *PlaylistRepository - Download *DownloadRepository - Subscription *SubscriptionRepository Setting *SettingRepository Log *AccessLogRepository Permission *PermissionRepository RefreshToken *RefreshTokenRepository ApiConfig *ApiConfigRepository - DownloadClient *DownloadClientRepository - NotifyChannel *NotifyChannelRepository - Site *SiteRepository - STRM *STRMRepository PlayProfile *PlayProfileRepository - StorageConfig *StorageConfigRepository - Assistant *AssistantRepository RegCode *RegistrationCodeRepository SignIn *SignInRepository UserDevice *UserDeviceRepository @@ -47,20 +39,12 @@ func New(db *gorm.DB) *Container { History: &HistoryRepository{db: db}, Favorite: &FavoriteRepository{db: db}, Playlist: &PlaylistRepository{db: db}, - Download: &DownloadRepository{db: db}, - Subscription: &SubscriptionRepository{db: db}, Setting: &SettingRepository{db: db}, Log: &AccessLogRepository{db: db}, Permission: &PermissionRepository{db: db}, RefreshToken: &RefreshTokenRepository{db: db}, ApiConfig: &ApiConfigRepository{db: db}, - DownloadClient: &DownloadClientRepository{db: db}, - NotifyChannel: &NotifyChannelRepository{db: db}, - Site: &SiteRepository{db: db}, - STRM: &STRMRepository{db: db}, PlayProfile: &PlayProfileRepository{db: db}, - StorageConfig: &StorageConfigRepository{db: db}, - Assistant: &AssistantRepository{db: db}, RegCode: &RegistrationCodeRepository{db: db}, SignIn: &SignInRepository{db: db}, UserDevice: &UserDeviceRepository{db: db}, diff --git a/internal/repository/site_repo.go b/internal/repository/site_repo.go deleted file mode 100644 index a3726bf..0000000 --- a/internal/repository/site_repo.go +++ /dev/null @@ -1,56 +0,0 @@ -// Package repository — PT 站点数据访问层。 -package repository - -import ( - "context" - "errors" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// SiteRepository persists model.Site records. -type SiteRepository struct{ db *gorm.DB } - -// Create inserts a new site. -func (r *SiteRepository) Create(ctx context.Context, s *model.Site) error { - return r.db.WithContext(ctx).Create(s).Error -} - -// FindByID returns the site by ID, or (nil, nil) when absent. -func (r *SiteRepository) FindByID(ctx context.Context, id string) (*model.Site, error) { - var s model.Site - err := r.db.WithContext(ctx).Where("id = ?", id).First(&s).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &s, nil -} - -// List returns all sites ordered by name. -func (r *SiteRepository) List(ctx context.Context) ([]model.Site, error) { - var rows []model.Site - err := r.db.WithContext(ctx).Order("name asc").Find(&rows).Error - return rows, err -} - -// ListEnabled returns all enabled sites. -func (r *SiteRepository) ListEnabled(ctx context.Context) ([]model.Site, error) { - var rows []model.Site - err := r.db.WithContext(ctx).Where("enabled = ?", true).Order("name asc").Find(&rows).Error - return rows, err -} - -// Update updates site fields. -func (r *SiteRepository) Update(ctx context.Context, s *model.Site) error { - return r.db.WithContext(ctx).Save(s).Error -} - -// Delete removes a site (soft-delete). -func (r *SiteRepository) Delete(ctx context.Context, id string) error { - return r.db.WithContext(ctx).Delete(&model.Site{}, "id = ?", id).Error -} diff --git a/internal/repository/strm_repo.go b/internal/repository/strm_repo.go deleted file mode 100644 index c9dafd3..0000000 --- a/internal/repository/strm_repo.go +++ /dev/null @@ -1,82 +0,0 @@ -// Package repository — STRM 文件记录数据访问层。 -package repository - -import ( - "context" - "errors" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// STRMRepository persists model.STRMRecord records. -type STRMRepository struct{ db *gorm.DB } - -// Create inserts a new STRM record. -func (r *STRMRepository) Create(ctx context.Context, s *model.STRMRecord) error { - return r.db.WithContext(ctx).Create(s).Error -} - -// CreateBatch inserts multiple STRM records. -func (r *STRMRepository) CreateBatch(ctx context.Context, records []model.STRMRecord) error { - if len(records) == 0 { - return nil - } - return r.db.WithContext(ctx).CreateInBatches(records, 100).Error -} - -// FindByID returns the STRM record by ID, or (nil, nil) when absent. -func (r *STRMRepository) FindByID(ctx context.Context, id string) (*model.STRMRecord, error) { - var s model.STRMRecord - err := r.db.WithContext(ctx).Where("id = ?", id).First(&s).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &s, nil -} - -// List returns STRM records with optional filters. Supports pagination. -// Filters: media_id, media_type, protocol -func (r *STRMRepository) List(ctx context.Context, filters map[string]string, offset, limit int) ([]model.STRMRecord, int64, error) { - q := r.db.WithContext(ctx).Model(&model.STRMRecord{}) - - if mediaID, ok := filters["media_id"]; ok && mediaID != "" { - q = q.Where("media_id = ?", mediaID) - } - if mediaType, ok := filters["media_type"]; ok && mediaType != "" { - q = q.Where("media_type = ?", mediaType) - } - if protocol, ok := filters["protocol"]; ok && protocol != "" { - q = q.Where("protocol = ?", protocol) - } - - var total int64 - if err := q.Count(&total).Error; err != nil { - return nil, 0, err - } - - var rows []model.STRMRecord - err := q.Order("created_at desc").Offset(offset).Limit(limit).Find(&rows).Error - return rows, total, err -} - -// Update updates a STRM record. -func (r *STRMRepository) Update(ctx context.Context, s *model.STRMRecord) error { - return r.db.WithContext(ctx).Save(s).Error -} - -// Delete removes a STRM record (soft-delete). -func (r *STRMRepository) Delete(ctx context.Context, id string) error { - return r.db.WithContext(ctx).Delete(&model.STRMRecord{}, "id = ?", id).Error -} - -// FindByMediaID returns STRM records for a given media ID. -func (r *STRMRepository) FindByMediaID(ctx context.Context, mediaID string) ([]model.STRMRecord, error) { - var rows []model.STRMRecord - err := r.db.WithContext(ctx).Where("media_id = ?", mediaID).Find(&rows).Error - return rows, err -} diff --git a/internal/repository/subscription_repository.go b/internal/repository/subscription_repository.go deleted file mode 100644 index 3a557b7..0000000 --- a/internal/repository/subscription_repository.go +++ /dev/null @@ -1,72 +0,0 @@ -package repository - -import ( - "context" - "errors" - "time" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// SubscriptionRepository persists model.Subscription records. -type SubscriptionRepository struct{ db *gorm.DB } - -// Create inserts a new subscription rule. -func (r *SubscriptionRepository) Create(ctx context.Context, s *model.Subscription) error { - return r.db.WithContext(ctx).Select("*").Omit("DeletedAt").Create(s).Error -} - -// FindActiveByIdentity returns an unarchived, non-deleted rule with the same -// per-user functional identity. excludeID is used while editing/restoring. -func (r *SubscriptionRepository) FindActiveByIdentity(ctx context.Context, userID, identityKey, excludeID string) (*model.Subscription, error) { - if identityKey == "" { - return nil, nil - } - q := r.db.WithContext(ctx). - Where("user_id = ? AND identity_key = ? AND archived_at IS NULL", userID, identityKey) - if excludeID != "" { - q = q.Where("id <> ?", excludeID) - } - var sub model.Subscription - err := q.First(&sub).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - return &sub, nil -} - -// List returns active subscription rules. Archived rows live in history and are -// intentionally excluded from scheduler polling and the active management list. -func (r *SubscriptionRepository) List(ctx context.Context) ([]model.Subscription, error) { - var rows []model.Subscription - err := r.db.WithContext(ctx).Unscoped(). - Where("archived_at IS NULL"). - Where("deleted_at IS NULL OR enabled = ?", true). - Order("created_at desc"). - Find(&rows).Error - return rows, err -} - -// History returns archived subscription rules. -func (r *SubscriptionRepository) History(ctx context.Context) ([]model.Subscription, error) { - var rows []model.Subscription - err := r.db.WithContext(ctx).Where("archived_at IS NOT NULL").Order("archived_at desc, updated_at desc").Find(&rows).Error - return rows, err -} - -// Archive moves a completed subscription out of the active list without -// deleting its rule details, so users can audit completed subscriptions later. -func (r *SubscriptionRepository) Archive(ctx context.Context, id, reason string, archivedAt time.Time) error { - return r.db.WithContext(ctx).Model(&model.Subscription{}). - Where("id = ? AND archived_at IS NULL", id). - Updates(map[string]any{ - "enabled": false, - "archived_at": &archivedAt, - "archive_reason": reason, - }).Error -} diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index 0e51362..6f7ac87 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -141,9 +141,6 @@ func (r *UserRepository) Delete(ctx context.Context, id string) error { if err := tx.Where("id = ?", id).First(&user).Error; err != nil { return err } - if err := tx.Unscoped().Where("user_id = ?", id).Delete(&model.TelegramBinding{}).Error; err != nil { - return err - } released := user.Username + "__deleted__" + time.Now().Format("20060102150405.000000000") if len(released) > 64 { sum := sha256.Sum256([]byte(user.ID + user.Username)) diff --git a/internal/service/ai.go b/internal/service/ai.go deleted file mode 100644 index 3ca88d2..0000000 --- a/internal/service/ai.go +++ /dev/null @@ -1,265 +0,0 @@ -// Package service — AI integration (OpenAI-compatible chat completions). -// -// AIService is a thin wrapper around any OpenAI-compatible REST endpoint -// (OpenAI, DeepSeek, Qwen, Ollama, …). Today we expose two operations: -// -// - SmartSearch: interpret a free-form Chinese / English query and -// return a normalised JSON intent the React UI can -// translate into filter params. -// - Recommend: given a list of recently-watched titles, generate -// a short list of "you might like…" recommendations. -// -// The service is disabled (every method returns nil) when ai.enabled is -// false or ai.api_key is empty. -package service - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" -) - -// AIService talks to an OpenAI-compatible chat-completions endpoint. -type AIService struct { - cfg *config.Config - log *zap.Logger - client *http.Client - apiConfig *APIConfigService -} - -// NewAIService is the constructor. -func NewAIService(cfg *config.Config, log *zap.Logger, apiConfig *APIConfigService) *AIService { - timeout := time.Duration(cfg.AI.Timeout) * time.Second - if timeout <= 0 { - timeout = 30 * time.Second - } - return &AIService{ - cfg: cfg, - log: log, - apiConfig: apiConfig, - client: NewExternalHTTPClient(timeout), - } -} - -// Enabled reports whether the AI integration is configured. -func (a *AIService) Enabled() bool { - return a.cfg.AI.Enabled && strings.TrimSpace(a.cfg.AI.APIKey) != "" -} - -// EnabledFor reports whether the AI integration is configured for a request. -func (a *AIService) EnabledFor(ctx context.Context) bool { - return a.resolveRuntimeConfig(ctx).Enabled -} - -// AIStatus is returned to the UI for connection-state display. -type AIStatus struct { - Enabled bool `json:"enabled"` - Provider string `json:"provider"` - Model string `json:"model"` -} - -// Status resolves live database-backed AI config for the UI. -func (a *AIService) Status(ctx context.Context) AIStatus { - cfg := a.resolveRuntimeConfig(ctx) - return AIStatus{Enabled: cfg.Enabled, Provider: cfg.Provider, Model: cfg.Model} -} - -// SearchIntent is the structured output the smart search endpoint returns. -type SearchIntent struct { - Query string `json:"query"` - Year int `json:"year,omitempty"` - Genre string `json:"genre,omitempty"` - Type string `json:"type,omitempty"` // movie / tv / anime / music - Sort string `json:"sort,omitempty"` // recent / rating / random - Language string `json:"language,omitempty"` -} - -// SmartSearch turns a natural-language query into a structured intent. -// Returns a best-effort intent on parse failure (raw query passes through). -func (a *AIService) SmartSearch(ctx context.Context, raw string) (*SearchIntent, error) { - runtime := a.resolveRuntimeConfig(ctx) - if !runtime.Enabled { - return &SearchIntent{Query: raw}, nil - } - const sys = "You are a media-library search assistant. Read the user's query and " + - "output a JSON object with the keys: query (string), year (int, optional), " + - "genre (string, optional), type (movie|tv|anime|music, optional), sort " + - "(recent|rating|random, optional), language (zh|en, optional). Respond with " + - "JSON only, no commentary." - out, err := a.complete(ctx, runtime, sys, raw) - if err != nil { - return &SearchIntent{Query: raw}, err - } - var intent SearchIntent - if err := json.Unmarshal([]byte(out), &intent); err != nil { - // Fallback: tolerate non-JSON output by treating the raw text as - // the cleaned query. - intent.Query = strings.TrimSpace(out) - } - if intent.Query == "" { - intent.Query = raw - } - return &intent, nil -} - -// Recommend builds a short comma-separated list of titles given the user's -// history. The first call is intentionally best-effort: a future iteration -// may chain media DB lookups onto each suggestion. -func (a *AIService) Recommend(ctx context.Context, history []string, max int) ([]string, error) { - runtime := a.resolveRuntimeConfig(ctx) - if !runtime.Enabled || len(history) == 0 { - return nil, nil - } - if max <= 0 || max > 20 { - max = 8 - } - sys := fmt.Sprintf("You are a film / TV recommendation assistant. Reply with %d "+ - "comma-separated titles only, no commentary, in the same language as the input.", max) - usr := "I recently watched: " + strings.Join(history, "; ") - out, err := a.complete(ctx, runtime, sys, usr) - if err != nil { - return nil, err - } - parts := strings.Split(out, ",") - titles := make([]string, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - p = strings.Trim(p, "\"'`") - if p != "" { - titles = append(titles, p) - } - } - return titles, nil -} - -// complete is the shared helper — POST /v1/chat/completions. -func (a *AIService) complete(ctx context.Context, runtime aiRuntimeConfig, system, user string) (string, error) { - payload := map[string]any{ - "model": runtime.Model, - "temperature": 0.2, - "messages": []map[string]string{ - {"role": "system", "content": system}, - {"role": "user", "content": user}, - }, - } - body, _ := json.Marshal(payload) - endpoint := strings.TrimRight(runtime.APIBase, "/") + "/chat/completions" - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+runtime.APIKey) - resp, err := a.client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - raw, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("ai %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) - } - - type choice struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - } - var out struct { - Choices []choice `json:"choices"` - } - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return "", err - } - if len(out.Choices) == 0 { - return "", errors.New("ai: empty completion") - } - return strings.TrimSpace(out.Choices[0].Message.Content), nil -} - -// ChatTurn is one message in a multi-turn assistant transcript. -type ChatTurn struct { - Role string `json:"role"` - Content string `json:"content"` -} - -// Chat sends an entire transcript to the LLM. When the AI is disabled -// we return a deterministic offline reply so the assistant UI still -// has something to render. -func (a *AIService) Chat(ctx context.Context, history []ChatTurn) (string, error) { - runtime := a.resolveRuntimeConfig(ctx) - if !runtime.Enabled || len(history) == 0 { - return offlineReply(history), nil - } - // Build a chat/completions payload preserving the history order. - msgs := make([]map[string]string, 0, len(history)+1) - msgs = append(msgs, map[string]string{ - "role": "system", - "content": "You are MediaStationGo's helpful media-library assistant. " + - "Respond concisely in the user's language. " + - "Never invent file paths or media that don't exist.", - }) - for _, t := range history { - msgs = append(msgs, map[string]string{"role": t.Role, "content": t.Content}) - } - payload := map[string]any{ - "model": runtime.Model, - "temperature": 0.4, - "messages": msgs, - } - body, _ := json.Marshal(payload) - endpoint := strings.TrimRight(runtime.APIBase, "/") + "/chat/completions" - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+runtime.APIKey) - resp, err := a.client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - raw, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("ai %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) - } - type choice struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - } - var out struct { - Choices []choice `json:"choices"` - } - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return "", err - } - if len(out.Choices) == 0 { - return "", errors.New("ai: empty completion") - } - return strings.TrimSpace(out.Choices[0].Message.Content), nil -} - -// offlineReply returns a deterministic stand-in response so the UI's -// chat view stays functional when the AI provider is not configured. -func offlineReply(history []ChatTurn) string { - if len(history) == 0 { - return "Hi — AI provider is not configured. Set up OpenAI/DeepSeek in API Configs to chat with me." - } - last := history[len(history)-1].Content - if len(last) > 80 { - last = last[:80] + "…" - } - return "(offline) Heard: " + last + "\n请在 API 配置中接入 LLM 后重试。" -} diff --git a/internal/service/ai_runtime_config.go b/internal/service/ai_runtime_config.go deleted file mode 100644 index 6fb5418..0000000 --- a/internal/service/ai_runtime_config.go +++ /dev/null @@ -1,60 +0,0 @@ -package service - -import ( - "context" - "strings" - - "go.uber.org/zap" -) - -type aiRuntimeConfig struct { - Enabled bool - Provider string - APIBase string - APIKey string - Model string -} - -func (a *AIService) resolveRuntimeConfig(ctx context.Context) aiRuntimeConfig { - out := aiRuntimeConfig{ - Enabled: a.cfg.AI.Enabled && strings.TrimSpace(a.cfg.AI.APIKey) != "", - Provider: strings.TrimSpace(a.cfg.AI.Provider), - APIBase: strings.TrimSpace(a.cfg.AI.APIBase), - APIKey: strings.TrimSpace(a.cfg.AI.APIKey), - Model: strings.TrimSpace(a.cfg.AI.Model), - } - if out.Provider == "" { - out.Provider = "openai" - } - if out.APIBase == "" { - out.APIBase = "https://api.openai.com/v1" - } - if out.Model == "" { - out.Model = "gpt-4o-mini" - } - - if a.apiConfig != nil { - resolved, err := a.apiConfig.Resolve(ctx, "openai") - if err != nil { - if a.log != nil { - a.log.Warn("ai: failed to resolve openai api config", zap.Error(err)) - } - return out - } - if resolved.BaseURL != "" { - out.APIBase = strings.TrimSpace(resolved.BaseURL) - } - if resolved.APIKey != "" { - out.APIKey = strings.TrimSpace(resolved.APIKey) - } - if resolved.Enabled && out.APIKey != "" { - out.Enabled = true - out.Provider = "openai" - return out - } - if !resolved.Enabled && (resolved.APIKey != "" || resolved.BaseURL != "" || resolved.Extra != "") { - out.Enabled = false - } - } - return out -} diff --git a/internal/service/ai_test.go b/internal/service/ai_test.go deleted file mode 100644 index c9e27a0..0000000 --- a/internal/service/ai_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package service - -import ( - "context" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestAIStatusUsesDatabaseOpenAIConfig(t *testing.T) { - db := newServiceTestDB(t, &model.APIConfig{}) - repo := &repository.Container{DB: db} - crypto := NewCryptoService("test-secret", zap.NewNop()) - apiConfig := NewAPIConfigService(zap.NewNop(), repo, crypto) - key := "sk-test" - baseURL := "https://example.test/v1" - enabled := true - if _, err := apiConfig.Update(context.Background(), "openai", APIConfigPatch{ - APIKey: &key, - BaseURL: &baseURL, - Enabled: &enabled, - }); err != nil { - t.Fatal(err) - } - - ai := NewAIService(&config.Config{ - AI: config.AIConfig{ - Enabled: false, - Model: "gpt-4o-mini", - }, - }, zap.NewNop(), apiConfig) - - status := ai.Status(context.Background()) - if !status.Enabled { - t.Fatalf("AI status disabled, want enabled from database config") - } - if status.Provider != "openai" { - t.Fatalf("provider = %q, want openai", status.Provider) - } -} - -func TestAIStatusHonorsDisabledDatabaseOpenAIConfig(t *testing.T) { - db := newServiceTestDB(t, &model.APIConfig{}) - repo := &repository.Container{DB: db} - apiConfig := NewAPIConfigService(zap.NewNop(), repo, NewCryptoService("test-secret", zap.NewNop())) - key := "sk-test" - enabled := false - if _, err := apiConfig.Update(context.Background(), "openai", APIConfigPatch{ - APIKey: &key, - Enabled: &enabled, - }); err != nil { - t.Fatal(err) - } - - ai := NewAIService(&config.Config{ - AI: config.AIConfig{ - Enabled: true, - APIKey: "sk-file", - Model: "gpt-4o-mini", - }, - }, zap.NewNop(), apiConfig) - - if ai.Status(context.Background()).Enabled { - t.Fatalf("AI status enabled, want disabled when database config is explicitly disabled") - } -} diff --git a/internal/service/aria2_adp.go b/internal/service/aria2_adp.go deleted file mode 100644 index 01af2e1..0000000 --- a/internal/service/aria2_adp.go +++ /dev/null @@ -1,203 +0,0 @@ -// Package service — Aria2 下载适配器。 -// -// Aria2Adapter 实现了 DownloadAdapter 接口,通过 Aria2 JSON-RPC API -// 管理下载任务。 -package service - -import ( - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "net/http" - "strings" - "sync" - "time" -) - -var errAria2ListUnavailable = errors.New("aria2 task list unavailable") - -// Aria2Adapter 是 Aria2 的 DownloadAdapter 实现。 -type Aria2Adapter struct { - mu sync.Mutex - cfg DownloadClientConfig - client *http.Client - idSeq int -} - -// NewAria2Adapter 创建新的 Aria2 适配器。 -func NewAria2Adapter() *Aria2Adapter { - return &Aria2Adapter{ - client: NewInternalHTTPClient(20 * time.Second), - } -} - -// AddTorrent 通过 URL 添加种子或磁力链接。 -func (a *Aria2Adapter) AddTorrent(ctx context.Context, torrentURL, savePath string) (string, error) { - a.mu.Lock() - defer a.mu.Unlock() - - // Aria2 addUri 的参数: [secret, [uris], options] - uris := []string{torrentURL} - options := map[string]string{} - if savePath != "" { - options["dir"] = savePath - } - - result, err := a.rpcLocked(ctx, "aria2.addUri", []interface{}{uris, options}) - if err != nil { - return "", err - } - var gid string - if err := json.Unmarshal(result, &gid); err != nil { - return "", err - } - return gid, nil -} - -// AddMagnet 通过磁力链接添加下载。 -func (a *Aria2Adapter) AddMagnet(ctx context.Context, magnet, savePath string) (string, error) { - return a.AddTorrent(ctx, magnet, savePath) -} - -// AddTorrentFile submits application-fetched .torrent bytes using -// aria2.addTorrent. The empty URI list matches aria2's RPC signature. -func (a *Aria2Adapter) AddTorrentFile(ctx context.Context, data []byte, _ string, savePath string) (string, error) { - a.mu.Lock() - defer a.mu.Unlock() - options := map[string]string{} - if savePath != "" { - options["dir"] = savePath - } - result, err := a.rpcLocked(ctx, "aria2.addTorrent", []interface{}{ - base64.StdEncoding.EncodeToString(data), - []string{}, - options, - }) - if err != nil { - return "", err - } - var gid string - if err := json.Unmarshal(result, &gid); err != nil { - return "", err - } - return gid, nil -} - -// Pause 暂停下载任务(通过 GID)。 -func (a *Aria2Adapter) Pause(ctx context.Context, hash string) error { - a.mu.Lock() - defer a.mu.Unlock() - _, err := a.rpcLocked(ctx, "aria2.pause", []interface{}{hash}) - return err -} - -// Resume 恢复下载任务(通过 GID)。 -func (a *Aria2Adapter) Resume(ctx context.Context, hash string) error { - a.mu.Lock() - defer a.mu.Unlock() - _, err := a.rpcLocked(ctx, "aria2.unpause", []interface{}{hash}) - return err -} - -// Remove 移除下载任务。 -func (a *Aria2Adapter) Remove(ctx context.Context, hash string, deleteFiles bool) error { - a.mu.Lock() - defer a.mu.Unlock() - _, removeErr := a.rpcLocked(ctx, "aria2.remove", []interface{}{hash}) - _, resultErr := a.rpcLocked(ctx, "aria2.removeDownloadResult", []interface{}{hash}) - if removeErr != nil && resultErr != nil { - return errors.Join(removeErr, resultErr) - } - _ = deleteFiles // aria2 RPC removes the task/result but has no delete-local-data flag. - return nil -} - -// List 列出所有活动/等待/已停止的任务。 -func (a *Aria2Adapter) List(ctx context.Context, filter string) ([]TorrentInfo, error) { - a.mu.Lock() - defer a.mu.Unlock() - - var allResults []TorrentInfo - var listErrs []error - var successfulCalls int - - // 获取活动任务 - active, err := a.rpcLocked(ctx, "aria2.tellActive", []interface{}{ - []string{"gid", "bittorrent", "files", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "status", "dir", "numSeeders", "connections", "errorCode"}, - }) - if err == nil && active != nil { - successfulCalls++ - items := a.parseAria2Items(active) - allResults = append(allResults, items...) - } else if err != nil { - listErrs = append(listErrs, err) - } - - // 获取等待中的任务 - waiting, err := a.rpcLocked(ctx, "aria2.tellWaiting", []interface{}{ - 0, 100, - []string{"gid", "bittorrent", "files", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "status", "dir", "numSeeders", "connections", "errorCode"}, - }) - if err == nil && waiting != nil { - successfulCalls++ - items := a.parseAria2Items(waiting) - allResults = append(allResults, items...) - } else if err != nil { - listErrs = append(listErrs, err) - } - - // 获取已停止的任务 - stopped, err := a.rpcLocked(ctx, "aria2.tellStopped", []interface{}{ - 0, 100, - []string{"gid", "bittorrent", "files", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "status", "dir", "numSeeders", "connections", "errorCode"}, - }) - if err == nil && stopped != nil { - successfulCalls++ - items := a.parseAria2Items(stopped) - allResults = append(allResults, items...) - } else if err != nil { - listErrs = append(listErrs, err) - } - if successfulCalls == 0 { - return nil, fmt.Errorf("%w: %w", errAria2ListUnavailable, errors.Join(listErrs...)) - } - - if filter != "" { - filtered := make([]TorrentInfo, 0, len(allResults)) - for _, item := range allResults { - if strings.EqualFold(item.State, filter) { - filtered = append(filtered, item) - } - } - return filtered, errors.Join(listErrs...) - } - - return allResults, errors.Join(listErrs...) -} - -// GetInfo 获取单个任务信息。 -func (a *Aria2Adapter) GetInfo(ctx context.Context, hash string) (*TorrentInfo, error) { - a.mu.Lock() - defer a.mu.Unlock() - - result, err := a.rpcLocked(ctx, "aria2.tellStatus", []interface{}{ - hash, - []string{"gid", "bittorrent", "files", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "status", "dir", "numSeeders", "connections"}, - }) - if err != nil { - return nil, err - } - - var item map[string]interface{} - if err := json.Unmarshal(result, &item); err != nil { - return nil, err - } - - info := a.parseSingleItem(item) - if info == nil { - return nil, fmt.Errorf("task %s not found", hash) - } - return info, nil -} diff --git a/internal/service/aria2_parse.go b/internal/service/aria2_parse.go deleted file mode 100644 index 8976851..0000000 --- a/internal/service/aria2_parse.go +++ /dev/null @@ -1,116 +0,0 @@ -package service - -import ( - "encoding/json" - "time" -) - -// parseAria2Items 解析 Aria2 返回的任务列表。 -func (a *Aria2Adapter) parseAria2Items(raw json.RawMessage) []TorrentInfo { - var items []map[string]interface{} - if err := json.Unmarshal(raw, &items); err != nil { - return nil - } - - result := make([]TorrentInfo, 0, len(items)) - for _, item := range items { - info := a.parseSingleItem(item) - if info != nil { - result = append(result, *info) - } - } - return result -} - -// parseSingleItem 解析单个 Aria2 任务项。 -func (a *Aria2Adapter) parseSingleItem(item map[string]interface{}) *TorrentInfo { - gid := strVal(item["gid"]) - totalLength := toInt64(item["totalLength"]) - completedLength := toInt64(item["completedLength"]) - dlSpeed := toInt64(item["downloadSpeed"]) - upSpeed := toInt64(item["uploadSpeed"]) - status := strVal(item["status"]) - dir := strVal(item["dir"]) - numSeeders := int(toInt64(item["numSeeders"])) - connections := int(toInt64(item["connections"])) - - var name string - var contentPath string - - // 尝试从 bittorrent info 获取名称和 hash - if bt, ok := item["bittorrent"].(map[string]interface{}); ok { - if info, ok := bt["info"].(map[string]interface{}); ok { - name = strVal(info["name"]) - } - contentPath = downloaderPayloadPath(dir, name) - } - - if name == "" { - // 尝试从 files 获取文件名 - if files, ok := item["files"].([]interface{}); ok && len(files) > 0 { - if f, ok := files[0].(map[string]interface{}); ok { - filePath := strVal(f["path"]) - if filePath != "" { - contentPath = filePath - name = downloaderPathBase(filePath) - } - if name == "" { - name = strVal(f["uris"]) - } - } - } - } - if name == "" { - name = gid - } - - var progress float64 - if totalLength > 0 { - progress = float64(completedLength) / float64(totalLength) - } - - // Aria2 状态映射 - state := canonicalTorrentState(aria2StatusStr(status), progress) - - return &TorrentInfo{ - Hash: gid, - Name: name, - Size: totalLength, - Progress: progress, - DLSpeed: dlSpeed, - UPSpeed: upSpeed, - State: state, - SavePath: dir, - NumSeeds: numSeeders, - NumLeechs: aria2MaxInt(connections-numSeeders, 0), - AddedOn: time.Now(), - ContentPath: contentPath, - } -} - -// aria2StatusStr 将 Aria2 状态转为可读字符串。 -func aria2StatusStr(status string) string { - switch status { - case "active": - return "downloading" - case "waiting": - return "queued" - case "paused": - return "paused" - case "error": - return "error" - case "complete": - return "seeding" - case "removed": - return "removed" - default: - return status - } -} - -func aria2MaxInt(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/internal/service/aria2_rpc.go b/internal/service/aria2_rpc.go deleted file mode 100644 index 0444e73..0000000 --- a/internal/service/aria2_rpc.go +++ /dev/null @@ -1,166 +0,0 @@ -package service - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" -) - -// aria2Request 是 Aria2 JSON-RPC 请求结构。 -type aria2Request struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method"` - ID string `json:"id"` - Params []interface{} `json:"params"` -} - -// aria2Response 是 Aria2 JSON-RPC 响应结构。 -type aria2Response struct { - JSONRPC string `json:"jsonrpc"` - ID string `json:"id"` - Result json.RawMessage `json:"result"` - Error *aria2Error `json:"error"` -} - -// aria2Error 是 Aria2 JSON-RPC 错误结构。 -type aria2Error struct { - Code int `json:"code"` - Message string `json:"message"` -} - -// Initialize 配置并初始化 Aria2 RPC 连接。 -func (a *Aria2Adapter) Initialize(ctx context.Context, cfg DownloadClientConfig) error { - a.mu.Lock() - defer a.mu.Unlock() - endpoint, err := normalizeDownloadClientEndpoint("aria2", cfg.Host) - if err != nil { - return err - } - cfg.Host = endpoint - a.cfg = cfg - a.idSeq = 0 - return a.getVersionLocked(ctx) -} - -// Ping 测试连接。 -func (a *Aria2Adapter) Ping(ctx context.Context) error { - a.mu.Lock() - defer a.mu.Unlock() - return a.getVersionLocked(ctx) -} - -// getVersionLocked 内部版本检查(调用者必须持有锁)。 -func (a *Aria2Adapter) getVersionLocked(ctx context.Context) error { - rpcURL, err := downloadClientRPCURL("aria2", a.cfg.Host) - if err != nil { - return err - } - - req := &aria2Request{ - JSONRPC: "2.0", - Method: "aria2.getVersion", - ID: a.nextID(), - Params: []interface{}{"token:" + a.cfg.Password}, - } - - body, err := json.Marshal(req) - if err != nil { - return err - } - - httpReq, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, rpcURL, bytes.NewReader(body)) - if err != nil { - return err - } - httpReq.Header.Set("Content-Type", "application/json") - if a.cfg.Username != "" { - httpReq.SetBasicAuth(a.cfg.Username, a.cfg.Password) - } - - resp, err := a.client.Do(httpReq) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return fmt.Errorf("aria2 rpc: %d", resp.StatusCode) - } - return nil -} - -// rpcLocked 发送 JSON-RPC 请求(调用者必须持有锁)。 -func (a *Aria2Adapter) rpcLocked(ctx context.Context, method string, params []interface{}) (json.RawMessage, error) { - rpcURL, err := downloadClientRPCURL("aria2", a.cfg.Host) - if err != nil { - return nil, err - } - - if params == nil { - params = []interface{}{} - } - - // 如果 secret 不在 params 中,添加到第一位 - if len(params) > 0 { - if secret, ok := params[0].(string); ok && strings.HasPrefix(secret, "token:") { - // 已经有 secret - } else { - newParams := make([]interface{}, 0, len(params)+1) - newParams = append(newParams, "token:"+a.cfg.Password) - newParams = append(newParams, params...) - params = newParams - } - } else { - params = []interface{}{"token:" + a.cfg.Password} - } - - req := &aria2Request{ - JSONRPC: "2.0", - Method: method, - ID: a.nextID(), - Params: params, - } - - body, err := json.Marshal(req) - if err != nil { - return nil, err - } - - httpReq, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, rpcURL, bytes.NewReader(body)) - if err != nil { - return nil, err - } - httpReq.Header.Set("Content-Type", "application/json") - if a.cfg.Username != "" { - httpReq.SetBasicAuth(a.cfg.Username, a.cfg.Password) - } - - resp, err := a.client.Do(httpReq) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - var rpcResp aria2Response - if err := json.Unmarshal(respBody, &rpcResp); err != nil { - return nil, err - } - if rpcResp.Error != nil { - return nil, fmt.Errorf("aria2 rpc error [%d]: %s", rpcResp.Error.Code, rpcResp.Error.Message) - } - return rpcResp.Result, nil -} - -// nextID 生成递增的请求 ID。 -func (a *Aria2Adapter) nextID() string { - a.idSeq++ - return fmt.Sprintf("msg-%d", a.idSeq) -} diff --git a/internal/service/assistant.go b/internal/service/assistant.go deleted file mode 100644 index fed00a2..0000000 --- a/internal/service/assistant.go +++ /dev/null @@ -1,209 +0,0 @@ -// Package service — multi-turn AI assistant chat. -// -// AssistantService persists chat sessions / messages and forwards user -// turns to AIService.Chat() for the actual LLM call. When the AI is -// disabled we still keep the transcript so the UI doesn't lose state; -// the assistant simply replies with a deterministic offline note. -// -// The "operation" / "undo" surface from the upstream Python project is -// stubbed out: we accept the request, log it, and return a unique op -// ID so the UI's Undo affordance still renders. Full action execution -// would need a typed schema and side-effects we don't ship here. -package service - -import ( - "context" - "errors" - "strings" - "time" - - "github.com/google/uuid" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// AssistantService coordinates AssistantSession + AssistantMessage rows -// against the underlying AIService. -type AssistantService struct { - log *zap.Logger - repo *repository.Container - ai *AIService -} - -// NewAssistantService is the constructor. -func NewAssistantService(log *zap.Logger, repo *repository.Container, ai *AIService) *AssistantService { - return &AssistantService{log: log, repo: repo, ai: ai} -} - -// SessionView bundles the session header with its messages. -type SessionView struct { - Session model.AssistantSession `json:"session"` - Messages []model.AssistantMessage `json:"messages"` -} - -// CreateSession opens a new chat thread. -func (s *AssistantService) CreateSession(ctx context.Context, userID, title string) (*model.AssistantSession, error) { - if title == "" { - title = "New chat" - } - sess := &model.AssistantSession{UserID: userID, Title: title} - if err := s.repo.Assistant.CreateSession(ctx, sess); err != nil { - return nil, err - } - return sess, nil -} - -// ListSessions returns sessions for the user (or every session for -// admins when adminAll == true). -func (s *AssistantService) ListSessions(ctx context.Context, userID string, adminAll bool) ([]model.AssistantSession, error) { - if adminAll { - return s.repo.Assistant.ListSessions(ctx, "") - } - return s.repo.Assistant.ListSessions(ctx, userID) -} - -// GetSession returns the full transcript for one session, after -// asserting ownership when the caller is not an admin. -func (s *AssistantService) GetSession(ctx context.Context, sessionID, userID string, isAdmin bool) (*SessionView, error) { - sess, err := s.repo.Assistant.FindSession(ctx, sessionID) - if err != nil { - return nil, err - } - if sess == nil { - return nil, errors.New("session not found") - } - if !isAdmin && sess.UserID != userID { - return nil, errors.New("forbidden") - } - msgs, err := s.repo.Assistant.ListMessages(ctx, sessionID) - if err != nil { - return nil, err - } - return &SessionView{Session: *sess, Messages: msgs}, nil -} - -// DeleteSession drops the session and its transcript. -func (s *AssistantService) DeleteSession(ctx context.Context, sessionID, userID string, isAdmin bool) error { - sess, err := s.repo.Assistant.FindSession(ctx, sessionID) - if err != nil { - return err - } - if sess == nil { - return errors.New("session not found") - } - if !isAdmin && sess.UserID != userID { - return errors.New("forbidden") - } - return s.repo.Assistant.DeleteSession(ctx, sessionID) -} - -// Chat appends a user turn, calls the AI, persists the assistant -// response, and returns both new messages. -func (s *AssistantService) Chat(ctx context.Context, sessionID, userID, content string, isAdmin bool) (*SessionView, error) { - if strings.TrimSpace(content) == "" { - return nil, errors.New("content required") - } - sess, err := s.repo.Assistant.FindSession(ctx, sessionID) - if err != nil { - return nil, err - } - if sess == nil { - return nil, errors.New("session not found") - } - if !isAdmin && sess.UserID != userID { - return nil, errors.New("forbidden") - } - - // Append the user turn. - userMsg := &model.AssistantMessage{ - SessionID: sessionID, - Role: "user", - Content: strings.TrimSpace(content), - } - if err := s.repo.Assistant.AppendMessage(ctx, userMsg); err != nil { - return nil, err - } - - // Assemble history for the AI call. - prior, _ := s.repo.Assistant.ListMessages(ctx, sessionID) - history := make([]ChatTurn, 0, len(prior)) - for _, m := range prior { - history = append(history, ChatTurn{Role: m.Role, Content: m.Content}) - } - - // Call the LLM (or fall back to a deterministic offline reply). - reply, err := s.ai.Chat(ctx, history) - if err != nil { - s.log.Warn("assistant chat failed", zap.Error(err)) - reply = "(AI 暂未配置或调用失败,请稍后再试。)" - } - asstMsg := &model.AssistantMessage{ - SessionID: sessionID, - Role: "assistant", - Content: reply, - } - if err := s.repo.Assistant.AppendMessage(ctx, asstMsg); err != nil { - return nil, err - } - return s.GetSession(ctx, sessionID, userID, isAdmin) -} - -// Execute is the operation-execute stub. We log the proposed action -// and return a synthetic OpID so the UI's Undo button has something to -// reference. Real execution would need a typed action schema we don't -// ship here. -func (s *AssistantService) Execute(ctx context.Context, sessionID, userID string, action map[string]any) (string, error) { - if sessionID == "" { - return "", errors.New("session_id required") - } - opID := uuid.NewString() - s.log.Info("assistant.execute (stub)", - zap.String("session_id", sessionID), - zap.String("user_id", userID), - zap.String("op_id", opID), - zap.Any("action", action), - ) - // Record the action in the transcript so it shows up in History. - _ = s.repo.Assistant.AppendMessage(ctx, &model.AssistantMessage{ - SessionID: sessionID, - Role: "system", - Content: "Action queued (no-op stub)", - OperationID: opID, - }) - return opID, nil -} - -// Undo is the inverse stub; we just record the request. -func (s *AssistantService) Undo(ctx context.Context, opID string) error { - s.log.Info("assistant.undo (stub)", zap.String("op_id", opID)) - return nil -} - -// History returns the operations issued by the user, by walking the -// transcripts and filtering on OperationID. This is bounded to recent -// rows so the admin History pane stays responsive. -func (s *AssistantService) History(ctx context.Context, userID string, isAdmin bool) ([]map[string]any, error) { - sessions, err := s.ListSessions(ctx, userID, isAdmin) - if err != nil { - return nil, err - } - out := make([]map[string]any, 0) - cutoff := time.Now().AddDate(0, 0, -30) - for _, sess := range sessions { - msgs, _ := s.repo.Assistant.ListMessages(ctx, sess.ID) - for _, m := range msgs { - if m.OperationID == "" || m.CreatedAt.Before(cutoff) { - continue - } - out = append(out, map[string]any{ - "op_id": m.OperationID, - "session": sess.ID, - "created_at": m.CreatedAt, - "content": m.Content, - }) - } - } - return out, nil -} diff --git a/internal/service/auth.go b/internal/service/auth.go index 9029a6c..d72f9a3 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -40,10 +40,15 @@ var ( ErrUserExpired = errors.New("user account has expired") ) -// MaxUsers is kept for compatibility with tests and callers; dynamic runtime -// checks use LicensedMaxUsers so official licensed builds can raise the quota. +// MaxUsers 是单实例允许的最大用户数(开源版本固定上限)。 const MaxUsers = OpenSourceUserLimit +// OpenSourceUserLimit 是开源版本的用户数上限。 +const OpenSourceUserLimit = 20 + +// UserLimit 是注册/邀请码发放时的固定用户数上限(授权管理已移除,固定为开源上限)。 +const UserLimit = OpenSourceUserLimit + // SeedAdmin makes sure at least one admin user exists. It mirrors the // legacy default behaviour: if no admin row is found we create // `admin / admin123` (overridable through ADMIN_INITIAL_PASSWORD) and warn. @@ -100,7 +105,7 @@ func (s *AuthService) Register(ctx context.Context, username, password string) ( } if n, err := s.repo.User.Count(ctx); err != nil { return nil, nil, err - } else if n >= LicensedMaxUsers(ctx, s.repo) { + } else if n >= UserLimit { return nil, nil, ErrUserLimitReached } hash, err := hashPassword(password) diff --git a/internal/service/auth_user_limits_test.go b/internal/service/auth_user_limits_test.go index fe10110..3b3a9a4 100644 --- a/internal/service/auth_user_limits_test.go +++ b/internal/service/auth_user_limits_test.go @@ -2,7 +2,6 @@ package service import ( "context" - "encoding/json" "errors" "fmt" "path/filepath" @@ -21,7 +20,7 @@ import ( func newAuthTestServices(t *testing.T) (*repository.Container, *AuthService, *ProfileService, *PermissionService) { t.Helper() - db := newServiceTestDB(t, &model.User{}, &model.UserPermission{}, &model.RefreshToken{}, &model.TelegramBinding{}, &model.Setting{}) + db := newServiceTestDB(t, &model.User{}, &model.UserPermission{}, &model.RefreshToken{}, &model.Setting{}) sqlDB, err := db.DB() if err != nil { t.Fatal(err) @@ -58,44 +57,6 @@ func TestRegisterRejectsMoreThanTwentyUsers(t *testing.T) { } } -func TestRegisterUsesLicensedUserLimit(t *testing.T) { - ctx := context.Background() - repos, auth, _, _ := newAuthTestServices(t) - maxUsers := 25 - state := LicenseActivationState{Valid: true, LicenseType: "plus", MaxUsers: &maxUsers} - raw, _ := json.Marshal(state) - if err := repos.Setting.Set(ctx, LicenseSettingActivation, string(raw)); err != nil { - t.Fatal(err) - } - for i := 0; i < OpenSourceUserLimit; i++ { - if err := repos.User.Create(ctx, &model.User{ - Username: fmt.Sprintf("licensed-%02d", i), - PasswordHash: "hash", - Role: "user", - Tier: "free", - }); err != nil { - t.Fatal(err) - } - } - - if _, _, err := auth.Register(ctx, "extra", "password"); err != nil { - t.Fatalf("licensed user limit should allow user 21: %v", err) - } -} - -func TestLicensedMaxUsersCanBeUnlimited(t *testing.T) { - ctx := context.Background() - repos, _, _, _ := newAuthTestServices(t) - state := LicenseActivationState{Valid: true, LicenseType: "enterprise", UnlimitedUsers: true} - raw, _ := json.Marshal(state) - if err := repos.Setting.Set(ctx, LicenseSettingActivation, string(raw)); err != nil { - t.Fatal(err) - } - if got := LicensedMaxUsers(ctx, repos); got <= 1_000_000 { - t.Fatalf("unlimited license should return a very high limit, got %d", got) - } -} - func TestRegisterDefaultsAdultLibrariesHidden(t *testing.T) { _, auth, _, _ := newAuthTestServices(t) user, _, err := auth.Register(context.Background(), "viewer", "password") @@ -114,14 +75,6 @@ func TestDeletedUserCanBeRecreatedWithSameUsername(t *testing.T) { if err != nil { t.Fatalf("register old user: %v", err) } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 10001, - TelegramName: "@viewer", - ChatID: 10001, - UserID: user.ID, - }).Error; err != nil { - t.Fatalf("create telegram binding: %v", err) - } if err := repos.User.Delete(ctx, user.ID); err != nil { t.Fatalf("delete user: %v", err) } @@ -136,13 +89,6 @@ func TestDeletedUserCanBeRecreatedWithSameUsername(t *testing.T) { if _, err := auth.Login(ctx, "viewer", "new-password"); err != nil { t.Fatalf("login recreated user: %v", err) } - var bindings int64 - if err := repos.DB.Model(&model.TelegramBinding{}).Where("telegram_user_id = ?", 10001).Count(&bindings).Error; err != nil { - t.Fatalf("count bindings: %v", err) - } - if bindings != 0 { - t.Fatalf("deleted user telegram bindings should be removed, got %d", bindings) - } } func TestRegisterReleasesLegacySoftDeletedUsername(t *testing.T) { diff --git a/internal/service/bangumi_calendar.go b/internal/service/bangumi_calendar.go deleted file mode 100644 index 192e70d..0000000 --- a/internal/service/bangumi_calendar.go +++ /dev/null @@ -1,77 +0,0 @@ -// Package service — Bangumi discovery calendar. -package service - -import ( - "context" - "strconv" - "strings" -) - -// Calendar returns Bangumi's public on-air anime calendar as a recommendation -// rail. It needs no token, but NewBangumiProvider still attaches one when set. -func (b *BangumiProvider) Calendar(ctx context.Context) ([]ExternalMediaResult, error) { - type subject struct { - ID int `json:"id"` - Name string `json:"name"` - NameCN string `json:"name_cn"` - Summary string `json:"summary"` - AirDate string `json:"air_date"` - Images struct { - Large string `json:"large"` - Common string `json:"common"` - } `json:"images"` - Rating struct { - Score float32 `json:"score"` - } `json:"rating"` - } - type day struct { - Items []subject `json:"items"` - } - var days []day - if err := b.getJSON(ctx, b.base+"/calendar", &days); err != nil { - return nil, err - } - out := make([]ExternalMediaResult, 0, 24) - seen := map[int]struct{}{} - for _, day := range days { - for _, item := range day.Items { - if _, ok := seen[item.ID]; ok { - continue - } - seen[item.ID] = struct{}{} - title := strings.TrimSpace(item.NameCN) - if title == "" { - title = strings.TrimSpace(item.Name) - } - if title == "" { - continue - } - poster := item.Images.Large - if poster == "" { - poster = item.Images.Common - } - poster = normalizeBangumiImageURL(poster) - year := 0 - if len(item.AirDate) >= 4 { - year, _ = strconv.Atoi(item.AirDate[:4]) - } - out = append(out, ExternalMediaResult{ - Source: "bangumi", - MediaType: "anime", - Title: title, - OriginalName: item.Name, - Overview: item.Summary, - PosterURL: poster, - Year: year, - Rating: item.Rating.Score, - BangumiID: item.ID, - SubscribeKeyword: buildSubscribeKeyword(title, year), - SubscribeAliases: buildSubscribeAliases(title, item.Name, year), - }) - if len(out) >= 24 { - return out, nil - } - } - } - return out, nil -} diff --git a/internal/service/boot_cloud.go b/internal/service/boot_cloud.go deleted file mode 100644 index 85dcea2..0000000 --- a/internal/service/boot_cloud.go +++ /dev/null @@ -1,74 +0,0 @@ -package service - -import ( - "context" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// BootCloudLibraries optionally scans cloud libraries after startup. It is -// disabled by default for huge cloud mounts; normal automatic refresh is handled -// by the nightly cloud_sync scheduler window, and operators can still scan -// manually at any time. -func (c *Container) BootCloudLibraries(ctx context.Context) { - if c == nil || c.Repo == nil || c.Scan == nil { - return - } - if !bootCloudLibraryScanEnabled(ctx, c.Repo) { - c.Log.Info("boot: cloud library scans disabled; use manual scan or nightly cloud sync") - return - } - libs, err := c.Repo.Library.List(ctx) - if err != nil { - c.Log.Warn("boot cloud libraries: list failed", zap.Error(err)) - return - } - libs = FilterScannableCloudLibraries(ctx, c.Repo, libs) - cloudLibs := make([]model.Library, 0) - for _, lib := range libs { - if !lib.Enabled { - continue - } - if _, ok := ParseCloudLibraryMount(lib.Path); ok { - cloudLibs = append(cloudLibs, lib) - } - } - if len(cloudLibs) == 0 { - return - } - c.Log.Info("boot: scheduling cloud library scans", zap.Int("count", len(cloudLibs))) - // 延迟3秒后启动,避免和系统初始化任务冲突 - time.AfterFunc(3*time.Second, func() { - go c.runBootCloudLibraryScanQueue(cloudLibs) - }) -} - -func (c *Container) runBootCloudLibraryScanQueue(cloudLibs []model.Library) { - for _, lib := range cloudLibs { - libID := lib.ID - libName := lib.Name - scanCtx, cancel := cloudScanContext(context.Background(), cloudScanTimeout(context.Background(), c.Repo, 24*time.Hour)) - c.Log.Info("boot: scanning cloud library", zap.String("id", libID), zap.String("name", libName)) - if _, err := c.Scan.ScanLibraryWithoutAutoScrape(scanCtx, libID); err != nil { - c.Log.Warn("boot: cloud library scan failed", zap.String("id", libID), zap.String("name", libName), zap.Error(err)) - } else { - c.Log.Info("boot: cloud library scan completed", zap.String("id", libID), zap.String("name", libName)) - } - cancel() - } -} - -func bootCloudLibraryScanEnabled(ctx context.Context, repo *repository.Container) bool { - if repo == nil || repo.Setting == nil { - return false - } - value, err := repo.Setting.Get(ctx, "cloud.boot_scan_enabled") - if err != nil { - return false - } - return parseBoolSetting(value, false) -} diff --git a/internal/service/boot_cloud_health.go b/internal/service/boot_cloud_health.go deleted file mode 100644 index 8da6560..0000000 --- a/internal/service/boot_cloud_health.go +++ /dev/null @@ -1,102 +0,0 @@ -package service - -import ( - "context" - "strings" - "time" - - "go.uber.org/zap" -) - -const cloudStorageMissingConfigWarnPrefix = "cloud.storage.missing_config_warned." - -// BootCloudStorageHealthCheck验证所有已配置的云盘存储在启动时是否可用 -func (c *Container) BootCloudStorageHealthCheck(ctx context.Context) { - if c == nil || c.StorageCfg == nil { - return - } - - configs, err := c.StorageCfg.List(ctx) - if err != nil { - c.Log.Warn("boot: cloud storage health check failed to list configs", zap.Error(err)) - return - } - - cloudConfigs := make([]StorageView, 0) - for _, cfg := range configs { - if cfg.Enabled && IsAdminCloudConfigurable(cfg.Type) { - cloudConfigs = append(cloudConfigs, cfg) - } - } - - if len(cloudConfigs) == 0 { - c.Log.Info("boot: no enabled cloud storage configured") - return - } - - c.Log.Info("boot: checking cloud storage health", zap.Int("count", len(cloudConfigs))) - - for _, cfg := range cloudConfigs { - go func(typ string) { - checkCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - provider, err := c.StorageCfg.CloudProvider(checkCtx, typ) - if err != nil { - if c.warnMissingCloudStorageConfigOnce(checkCtx, typ, err) { - return - } - c.Log.Warn("boot: cloud storage unavailable", zap.String("type", typ), zap.Error(err)) - return - } - - if err := provider.Ping(checkCtx); err != nil { - if c.warnMissingCloudStorageConfigOnce(checkCtx, typ, err) { - return - } - c.Log.Warn("boot: cloud storage ping failed", zap.String("type", typ), zap.Error(err)) - } else { - c.Log.Info("boot: cloud storage healthy", zap.String("type", typ)) - } - }(cfg.Type) - } -} - -func (c *Container) warnMissingCloudStorageConfigOnce(ctx context.Context, typ string, err error) bool { - reason := cloudStorageMissingConfigReason(err) - if reason == "" { - return false - } - if c == nil || c.Repo == nil || c.Repo.Setting == nil { - if c != nil && c.Log != nil { - c.Log.Warn("boot: cloud storage config incomplete; skipping health check", zap.String("type", typ), zap.String("reason", reason), zap.Error(err)) - } - return true - } - key := cloudStorageMissingConfigWarnPrefix + strings.TrimSpace(typ) + "." + reason - if value, getErr := c.Repo.Setting.Get(ctx, key); getErr == nil && strings.EqualFold(strings.TrimSpace(value), "true") { - return true - } - if c.Log != nil { - c.Log.Warn("boot: cloud storage config incomplete; skipping health check", zap.String("type", typ), zap.String("reason", reason), zap.Error(err)) - } - if setErr := c.Repo.Setting.Set(ctx, key, "true"); setErr != nil && c.Log != nil { - c.Log.Debug("remember cloud storage config warning failed", zap.String("type", typ), zap.Error(setErr)) - } - return true -} - -func cloudStorageMissingConfigReason(err error) string { - if err == nil { - return "" - } - msg := strings.ToLower(strings.TrimSpace(err.Error())) - switch { - case strings.Contains(msg, "missing cookie") || (strings.Contains(msg, "missing") && strings.Contains(msg, "cookie")): - return "missing_cookie" - case strings.Contains(msg, "missing webdav url"): - return "missing_webdav_url" - default: - return "" - } -} diff --git a/internal/service/boot_cloud_health_test.go b/internal/service/boot_cloud_health_test.go deleted file mode 100644 index 09245cc..0000000 --- a/internal/service/boot_cloud_health_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package service - -import ( - "context" - "errors" - "testing" - - "go.uber.org/zap" - "go.uber.org/zap/zaptest/observer" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestCloudStorageMissingConfigReason(t *testing.T) { - cases := []struct { - err error - want string - }{ - {errors.New("115: missing cookie"), "missing_cookie"}, - {errors.New("openlist: missing cookie"), "missing_cookie"}, - {errors.New("clouddrive2: missing WebDAV URL"), "missing_webdav_url"}, - {errors.New("openlist: token expired"), ""}, - } - for _, tc := range cases { - if got := cloudStorageMissingConfigReason(tc.err); got != tc.want { - t.Fatalf("reason(%q) = %q, want %q", tc.err, got, tc.want) - } - } -} - -func TestWarnMissingCloudStorageConfigOncePersistsMarker(t *testing.T) { - db := newServiceTestDB(t, &model.Setting{}) - core, observed := observer.New(zap.WarnLevel) - c := &Container{ - Log: zap.New(core), - Repo: repository.New(db), - } - err := errors.New("115: missing cookie") - - if !c.warnMissingCloudStorageConfigOnce(context.Background(), "cloud115", err) { - t.Fatal("missing config should be handled") - } - if !c.warnMissingCloudStorageConfigOnce(context.Background(), "cloud115", err) { - t.Fatal("missing config should still be classified on second call") - } - if observed.FilterMessage("boot: cloud storage config incomplete; skipping health check").Len() != 1 { - t.Fatalf("warn count = %d, want 1", observed.Len()) - } - if c.warnMissingCloudStorageConfigOnce(context.Background(), "cloud115", errors.New("network timeout")) { - t.Fatal("non-missing config error should not be swallowed") - } -} diff --git a/internal/service/bot_cleanup_test.go b/internal/service/bot_cleanup_test.go deleted file mode 100644 index 71629b7..0000000 --- a/internal/service/bot_cleanup_test.go +++ /dev/null @@ -1,246 +0,0 @@ -package service - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestBotCleanupRulesDefaultToEmpty(t *testing.T) { - ctx := context.Background() - repos, _ := newBotTestService(t) - - cfg := loadBotConfig(ctx, repos) - if len(cfg.AccountCleanupRules) != 0 { - t.Fatalf("default cleanup rules should be empty, got %+v", cfg.AccountCleanupRules) - } -} - -func TestBotCleanupRulesCanBeDeletedUntilEmpty(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - if err := repos.User.Create(ctx, admin); err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "root"}, Chat: TelegramChat{ID: 9001, Type: "private"}} - - if _, err := bot.executeCommand(ctx, channel, msg, "/cleanup_rule add watch_hours watch_3_5d_6h 观看3到5天满6小时 3 5 6"); err != nil { - t.Fatal(err) - } - reply, err := bot.executeCommand(ctx, channel, msg, "/cleanup_rule del watch_3_5d_6h") - if err != nil { - t.Fatal(err) - } - cfg := loadBotConfig(ctx, repos) - if len(cfg.AccountCleanupRules) != 0 { - t.Fatalf("cleanup rules should stay empty after deleting the last rule; reply=%q rules=%+v", reply.Text, cfg.AccountCleanupRules) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule list") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "暂无规则") { - t.Fatalf("expected empty rule list, got %q", reply.Text) - } -} - -func TestBotCleanupRunPreviewsBeforeConfirm(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - if err := repos.User.Create(ctx, admin); err != nil { - t.Fatal(err) - } - now := time.Now() - old := now.Add(-30 * 24 * time.Hour) - stale := &model.User{Username: "stale", PasswordHash: "x", Role: "user", IsActive: true} - stale.CreatedAt = old - stale.LastLoginAt = &old - recent := &model.User{Username: "recent", PasswordHash: "x", Role: "user", IsActive: true} - recent.CreatedAt = old - recent.LastLoginAt = &now - newUser := &model.User{Username: "newbie", PasswordHash: "x", Role: "user", IsActive: true} - newUser.CreatedAt = now - for _, user := range []*model.User{stale, recent, newUser} { - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - } - if err := repos.Setting.Set(ctx, SettingAccountCleanupEnabled, "true"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingAccountCleanupKeepMode, "any"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingAccountCleanupRules, `[ - {"id":"login_7d","name":"最近登录","type":"recent_login","enabled":true,"window_days_max":7}, - {"id":"new_7d","name":"新号宽限","type":"account_age_grace","enabled":true,"min_count":7} - ]`); err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "root"}, Chat: TelegramChat{ID: 9001, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/cleanup run") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "当前只是预览") || !strings.Contains(reply.Text, "stale") || !strings.Contains(reply.Text, "/cleanup run confirm") { - t.Fatalf("cleanup run should preview candidates and confirmation command, got %q", reply.Text) - } - if got, _ := repos.User.FindByID(ctx, stale.ID); got == nil { - t.Fatal("cleanup preview must not delete the stale user") - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/deleted") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "当前只是预览") { - t.Fatalf("/deleted alias should preview only, got %q", reply.Text) - } - if got, _ := repos.User.FindByID(ctx, stale.ID); got == nil { - t.Fatal("/deleted preview alias must not delete users") - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup run confirm") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已清理 1") { - t.Fatalf("cleanup confirm should delete exactly one stale user, got %q", reply.Text) - } - if got, _ := repos.User.FindByID(ctx, stale.ID); got != nil { - t.Fatal("stale user should be deleted after explicit confirmation") - } - for _, user := range []*model.User{recent, newUser, admin} { - if got, _ := repos.User.FindByID(ctx, user.ID); got == nil { - t.Fatalf("%s should be kept by保号 rules/protection", user.Username) - } - } -} - -func TestBotCleanupLegacyCountModeStillKeepsSingleMatchedRule(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - if err := repos.User.Create(ctx, admin); err != nil { - t.Fatal(err) - } - now := time.Now() - old := now.Add(-30 * 24 * time.Hour) - recent := &model.User{Username: "recent", PasswordHash: "x", Role: "user", IsActive: true} - recent.CreatedAt = old - recent.LastLoginAt = &now - stale := &model.User{Username: "stale", PasswordHash: "x", Role: "user", IsActive: true} - stale.CreatedAt = old - stale.LastLoginAt = &old - for _, user := range []*model.User{recent, stale} { - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - } - if err := repos.Setting.Set(ctx, SettingAccountCleanupEnabled, "true"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingAccountCleanupKeepMode, "count"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingAccountCleanupRequiredCount, "2"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingAccountCleanupRules, `[ - {"id":"login_7d","name":"最近登录","type":"recent_login","enabled":true,"window_days_max":7}, - {"id":"new_7d","name":"新号宽限","type":"account_age_grace","enabled":true,"min_count":7} - ]`); err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "root"}, Chat: TelegramChat{ID: 9001, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/cleanup run") - if err != nil { - t.Fatal(err) - } - if strings.Contains(reply.Text, "recent") { - t.Fatalf("user matching one keep rule must not be a cleanup candidate, got %q", reply.Text) - } - if !strings.Contains(reply.Text, "stale") { - t.Fatalf("user matching no keep rules should be a candidate, got %q", reply.Text) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup run confirm") - if err != nil { - t.Fatal(err) - } - if got, _ := repos.User.FindByID(ctx, recent.ID); got == nil { - t.Fatal("legacy count mode must not delete a user matching one keep rule") - } - if got, _ := repos.User.FindByID(ctx, stale.ID); got != nil { - t.Fatalf("stale user should be deleted after confirm, reply=%q", reply.Text) - } -} - -func TestBotCleanupConfirmRequiresEnabledRules(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - user := &model.User{Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true} - user.CreatedAt = time.Now().Add(-30 * 24 * time.Hour) - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingAccountCleanupEnabled, "true"); err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "root"}, Chat: TelegramChat{ID: 9001, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/cleanup run confirm") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "没有启用的保号规则") { - t.Fatalf("cleanup confirm without rules should be blocked, got %q", reply.Text) - } - if got, _ := repos.User.FindByID(ctx, user.ID); got == nil { - t.Fatal("cleanup confirm without enabled rules must not delete users") - } -} - -func TestBotCleanupRuleListInfersDaysAndHidesDuplicateNames(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - if err := repos.User.Create(ctx, admin); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingAccountCleanupRules, `[ - {"id":"login_7d","name":"login_7d","type":"recent_login","enabled":true,"window_days_min":1,"window_days_max":5,"min_count":1}, - {"id":"new_7d","name":"new_7d","type":"account_age_grace","enabled":true,"window_days_min":1,"window_days_max":1,"min_count":1} - ]`); err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "root"}, Chat: TelegramChat{ID: 9001, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/cleanup_rule") - if err != nil { - t.Fatal(err) - } - for _, bad := range []string{"login_7d · login_7d", "new_7d · new_7d", "5 天内登录", "新号宽限 1 天", "add watch_hours", "Mgo 保号规则命令"} { - if strings.Contains(reply.Text, bad) { - t.Fatalf("rule list still contains bad fragment %q: %s", bad, reply.Text) - } - } - for _, want := range []string{"login_7d", "7 天内登录", "new_7d", "新号宽限 7 天"} { - if !strings.Contains(reply.Text, want) { - t.Fatalf("rule list missing %q: %s", want, reply.Text) - } - } -} diff --git a/internal/service/bot_commands_test.go b/internal/service/bot_commands_test.go deleted file mode 100644 index 53a328f..0000000 --- a/internal/service/bot_commands_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package service - -import ( - "context" - "strings" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestBotRegistrationCommandUsesOpenRegQuota(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - if err := repos.User.Create(ctx, admin); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingOpenRegEnabled, "false"); err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 9051, - TelegramName: "@root", - ChatID: 9051, - UserID: admin.ID, - }).Error; err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9051"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9051, Username: "root"}, Chat: TelegramChat{ID: 9051, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/registration on 2") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "2 个名额") { - t.Fatalf("expected quota feedback, got %q", reply.Text) - } - capacity := bot.loadCapacity(ctx) - if !capacity.OpenRegOn || capacity.OpenRegLimit != 2 || capacity.OpenRegUsed != 0 { - t.Fatalf("registration command should open quota-aware registration, got %+v", capacity) - } -} - -func TestBotUserCommandsAndAdminGate(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - user := &model.User{Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true} - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 9101, - TelegramName: "@viewer", - ChatID: 9101, - UserID: user.ID, - }).Error; err != nil { - t.Fatal(err) - } - now := time.Now() - if err := repos.UserDevice.Create(ctx, &model.UserDevice{ - UserID: user.ID, DeviceID: "dev-1", DeviceName: "iPhone", Client: "Infuse", FirstSeenAt: now, LastSeenAt: now, - }); err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9101, Username: "viewer"}, Chat: TelegramChat{ID: 9101, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/antishare on") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "仅管理员") { - t.Fatalf("regular user should not manage policy, got %q", reply.Text) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/devices") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "我的登录设备") { - t.Fatalf("expected device list, got %q", reply.Text) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/kick 1") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已踢下线") { - t.Fatalf("expected kick feedback, got %q", reply.Text) - } - if kicked := bot.device; kicked != nil { - t.Fatal("test should not require wired device service") - } - if ok := NewDeviceService(zap.NewNop(), repos).IsDeviceKicked(ctx, user.ID, "dev-1"); !ok { - t.Fatal("device should be marked kicked") - } -} - -func TestBotAdminCodeAndUserCommands(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - user := &model.User{Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true} - if err := repos.User.Create(ctx, admin); err != nil { - t.Fatal(err) - } - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 9301, - TelegramName: "@root", - ChatID: 9301, - UserID: admin.ID, - }).Error; err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9301"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9301, Username: "root"}, Chat: TelegramChat{ID: 9301, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/gencode renew 90 7") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已生成续期码") { - t.Fatalf("expected generated renew code, got %q", reply.Text) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/renew_user viewer 30") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "viewer") { - t.Fatalf("renew command should return user actions, got %q", reply.Text) - } - updated, _ := repos.User.FindByID(ctx, user.ID) - if updated.ExpiredAt == nil || updated.ExpiredAt.Before(time.Now()) { - t.Fatalf("renew_user should set future expiry, got %v", updated.ExpiredAt) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/delete_user viewer") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "需要确认") { - t.Fatalf("delete without confirm should be rejected, got %q", reply.Text) - } -} - -func TestBotGroupMenuShowsAdminActionsOnlyForAdmins(t *testing.T) { - ctx := context.Background() - _, bot := newBotTestService(t) - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9301","group_chat_id":"-1001"}`} - adminMsg := &TelegramMessage{From: TelegramUser{ID: 9301, Username: "admin"}, Chat: TelegramChat{ID: -1001, Type: "group"}} - reply, err := bot.executeCommand(ctx, channel, adminMsg, "/menu") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "管理员入口") || len(reply.Buttons) == 0 { - t.Fatalf("admin group menu should expose management actions, got %#v", reply) - } - - userMsg := &TelegramMessage{From: TelegramUser{ID: 9302, Username: "user"}, Chat: TelegramChat{ID: -1001, Type: "group"}} - reply, err = bot.executeCommand(ctx, channel, userMsg, "/menu") - if err != nil { - t.Fatal(err) - } - if strings.Contains(reply.Text, "管理员入口") { - t.Fatalf("non-admin group menu must not expose management actions, got %#v", reply) - } -} diff --git a/internal/service/bot_device_policy_test.go b/internal/service/bot_device_policy_test.go deleted file mode 100644 index 2b43673..0000000 --- a/internal/service/bot_device_policy_test.go +++ /dev/null @@ -1,253 +0,0 @@ -package service - -import ( - "context" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestDeviceKickAndConcurrency(t *testing.T) { - ctx := context.Background() - repos, _ := newBotTestService(t) - dev := NewDeviceService(zap.NewNop(), repos) - u := &model.User{Username: "carol", PasswordHash: "x", Role: "user", IsActive: true} - if err := repos.User.Create(ctx, u); err != nil { - t.Fatal(err) - } - - dev.RecordLogin(ctx, u.ID, "dev-1", "iPhone", "Infuse", "1.2.3.4") - dev.RecordPlayback(ctx, u.ID, "dev-1", "iPhone", "Infuse") - devices, _ := dev.ListDevices(ctx, u.ID) - if len(devices) != 1 { - t.Fatalf("expected 1 device, got %d", len(devices)) - } - - // 踢下线后命中 kicked - if err := dev.KickDevice(ctx, u.ID, "dev-1"); err != nil { - t.Fatal(err) - } - if !dev.IsDeviceKicked(ctx, u.ID, "dev-1") { - t.Fatal("device should be kicked") - } - // 重新登录清除 kicked - dev.RecordLogin(ctx, u.ID, "dev-1", "iPhone", "Infuse", "1.2.3.4") - if dev.IsDeviceKicked(ctx, u.ID, "dev-1") { - t.Fatal("re-login should clear kicked flag") - } - - // 并发播放计数 - now := time.Now() - for i, id := range []string{"d1", "d2", "d3", "d4"} { - _ = repos.UserDevice.Create(ctx, &model.UserDevice{ - UserID: u.ID, DeviceID: id, FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now, - }) - _ = i - } - n, err := repos.UserDevice.CountConcurrentPlaying(ctx, u.ID, now.Add(-time.Minute)) - if err != nil { - t.Fatal(err) - } - if n < 4 { - t.Fatalf("expected >=4 concurrent playing, got %d", n) - } -} - -func TestTerminalDeviceLimitDeduplicatesAppsOnSameDevice(t *testing.T) { - ctx := context.Background() - repos, _ := newBotTestService(t) - dev := NewDeviceService(zap.NewNop(), repos) - now := time.Date(2026, 6, 25, 21, 30, 0, 0, time.UTC) - tracker := NewSessionTrackerService(zap.NewNop()) - tracker.now = func() time.Time { return now } - dev.SetSessionTracker(tracker) - u := &model.User{Username: "device-user", PasswordHash: "x", Role: "user", IsActive: true} - if err := repos.User.Create(ctx, u); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingAntiShareEnabled, "true"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(ctx, SettingMaxLoggedClients, "3"); err != nil { - t.Fatal(err) - } - - for _, login := range []struct { - id string - name string - client string - }{ - {id: "phone-infuse", name: "iPhone", client: "Infuse"}, - {id: "phone-emby", name: " iPhone ", client: "Emby"}, - {id: "phone-jellyfin", name: "IPHONE", client: "Jellyfin"}, - } { - dev.RecordLogin(ctx, u.ID, login.id, login.name, login.client, "1.2.3.4") - now = now.Add(time.Second) - } - count, err := repos.UserDevice.CountActiveClients(ctx, u.ID, now.Add(-24*time.Hour)) - if err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("same terminal through multiple apps should count as 1, got %d", count) - } - devices, err := dev.ListDevices(ctx, u.ID) - if err != nil { - t.Fatal(err) - } - if len(devices) != 1 { - t.Fatalf("same terminal should show as one device row, got %#v", devices) - } - rawRows, err := repos.UserDevice.ListByUser(ctx, u.ID) - if err != nil { - t.Fatal(err) - } - if len(rawRows) != 1 { - t.Fatalf("same terminal should be persisted as one canonical row, got %#v", rawRows) - } - if devices[0].DeviceID != "phone-jellyfin" || devices[0].Client != "Jellyfin" { - t.Fatalf("merged device row should keep latest login channel, got %#v", devices[0]) - } - got, _ := repos.User.FindByID(ctx, u.ID) - if !got.IsActive { - t.Fatal("same terminal through multiple apps must not disable the account") - } - - dev.RecordLogin(ctx, u.ID, "tablet", "iPad", "Infuse", "1.2.3.4") - dev.RecordLogin(ctx, u.ID, "pc", "Windows PC", "Browser", "1.2.3.4") - count, err = repos.UserDevice.CountActiveClients(ctx, u.ID, now.Add(-24*time.Hour)) - if err != nil { - t.Fatal(err) - } - if count != 3 { - t.Fatalf("three distinct terminal devices should count as 3, got %d", count) - } - got, _ = repos.User.FindByID(ctx, u.ID) - if !got.IsActive { - t.Fatal("device limit is inclusive; 3 of 3 terminals should stay active") - } - - dev.RecordLogin(ctx, u.ID, "tv", "Apple TV", "Emby", "1.2.3.4") - got, _ = repos.User.FindByID(ctx, u.ID) - if got.IsActive { - t.Fatal("fourth distinct terminal should disable the account") - } -} - -func TestDeviceKickAppliesToMergedTerminal(t *testing.T) { - ctx := context.Background() - repos, _ := newBotTestService(t) - dev := NewDeviceService(zap.NewNop(), repos) - u := &model.User{Username: "kick-merged", PasswordHash: "x", Role: "user", IsActive: true} - if err := repos.User.Create(ctx, u); err != nil { - t.Fatal(err) - } - - dev.RecordLogin(ctx, u.ID, "phone-infuse", "iPhone", "Infuse", "1.2.3.4") - dev.RecordLogin(ctx, u.ID, "phone-emby", " iPhone ", "Emby", "1.2.3.4") - if err := dev.KickDevice(ctx, u.ID, "phone-emby"); err != nil { - t.Fatal(err) - } - if !dev.IsTerminalKicked(ctx, u.ID, "phone-jellyfin", "IPHONE", "Jellyfin") { - t.Fatal("same terminal with a new app/device id should still be kicked") - } - - dev.RecordLogin(ctx, u.ID, "phone-jellyfin", "IPHONE", "Jellyfin", "1.2.3.4") - if dev.IsTerminalKicked(ctx, u.ID, "phone-jellyfin", "IPHONE", "Jellyfin") { - t.Fatal("re-login should clear kicked state for the merged terminal") - } - rawRows, err := repos.UserDevice.ListByUser(ctx, u.ID) - if err != nil { - t.Fatal(err) - } - if len(rawRows) != 1 || rawRows[0].DeviceID != "phone-jellyfin" { - t.Fatalf("merged terminal should keep one latest row, got %#v", rawRows) - } -} - -func TestRecordPlaybackMergesChangingDeviceIDOnSameTerminal(t *testing.T) { - ctx := context.Background() - repos, _ := newBotTestService(t) - dev := NewDeviceService(zap.NewNop(), repos) - u := &model.User{Username: "play-merged", PasswordHash: "x", Role: "user", IsActive: true} - if err := repos.User.Create(ctx, u); err != nil { - t.Fatal(err) - } - - dev.RecordPlayback(ctx, u.ID, "tv-emby", "Living Room TV", "Emby") - dev.RecordPlayback(ctx, u.ID, "tv-infuse", " living room tv ", "Infuse") - - rawRows, err := repos.UserDevice.ListByUser(ctx, u.ID) - if err != nil { - t.Fatal(err) - } - if len(rawRows) != 1 { - t.Fatalf("same playback terminal should persist one row, got %#v", rawRows) - } - if rawRows[0].DeviceID != "tv-infuse" || rawRows[0].Client != "Infuse" || rawRows[0].LastPlayAt == nil { - t.Fatalf("merged playback row should keep latest playback channel, got %#v", rawRows[0]) - } - count, err := repos.UserDevice.CountConcurrentPlaying(ctx, u.ID, time.Now().Add(-time.Minute)) - if err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("same terminal playback should count once, got %d", count) - } -} - -func TestConcurrentPlaybackDeduplicatesAppsOnSameDevice(t *testing.T) { - ctx := context.Background() - repos, _ := newBotTestService(t) - u := &model.User{Username: "play-user", PasswordHash: "x", Role: "user", IsActive: true} - if err := repos.User.Create(ctx, u); err != nil { - t.Fatal(err) - } - now := time.Now() - fp := fingerprint("Infuse", "Living Room TV") - for _, row := range []model.UserDevice{ - {UserID: u.ID, DeviceID: "tv-emby", DeviceName: "Living Room TV", Client: "Emby", Fingerprint: fp, FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now}, - {UserID: u.ID, DeviceID: "tv-jellyfin", DeviceName: "living room tv", Client: "Jellyfin", Fingerprint: fp, FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now}, - {UserID: u.ID, DeviceID: "phone", DeviceName: "iPhone", Client: "Infuse", Fingerprint: fingerprint("Infuse", "iPhone"), FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now}, - } { - if err := repos.UserDevice.Create(ctx, &row); err != nil { - t.Fatal(err) - } - } - count, err := repos.UserDevice.CountConcurrentPlaying(ctx, u.ID, now.Add(-time.Minute)) - if err != nil { - t.Fatal(err) - } - if count != 2 { - t.Fatalf("same terminal playback through multiple apps should count as 1 terminal, got %d", count) - } -} - -func TestProtectedAdminNeverViolated(t *testing.T) { - ctx := context.Background() - repos, _ := newBotTestService(t) - dev := NewDeviceService(zap.NewNop(), repos) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - if err := repos.User.Create(ctx, admin); err != nil { - t.Fatal(err) - } - _ = repos.Setting.Set(ctx, SettingAntiShareEnabled, "true") - cfg := loadBotConfig(ctx, repos) - // 多次违规也不应删除/警告/禁用管理员 - for i := 0; i < 5; i++ { - dev.registerFingerprintWarning(ctx, admin.ID, "test", cfg) - } - got, _ := repos.User.FindByID(ctx, admin.ID) - if got == nil { - t.Fatal("admin must never be auto-deleted") - } - if !got.IsActive { - t.Fatal("admin must never be auto-disabled") - } - if got.ShareWarnings != 0 { - t.Fatalf("admin should accrue no warnings, got %d", got.ShareWarnings) - } -} diff --git a/internal/service/bot_features.go b/internal/service/bot_features.go deleted file mode 100644 index e39ab65..0000000 --- a/internal/service/bot_features.go +++ /dev/null @@ -1,285 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// ── 容量 / 开注名额 ────────────────────────────────────────────────────────── - -// capacityInfo 描述当前用户容量(随凭证授权实时变化)与开注名额状态。 -type capacityInfo struct { - UsedUsers int64 - MaxUsers int64 // 来自 LicensedMaxUsers,随授权实时变化 - OpenRegOn bool - OpenRegLimit int // 0 = 不限(仅受 MaxUsers 约束) - OpenRegUsed int -} - -// Remaining 返回还能注册多少个账号(同时受授权上限与开注名额约束)。 -func (c capacityInfo) Remaining() int64 { - byLicense := c.MaxUsers - c.UsedUsers - if byLicense < 0 { - byLicense = 0 - } - if c.OpenRegLimit > 0 { - byQuota := int64(c.OpenRegLimit - c.OpenRegUsed) - if byQuota < 0 { - byQuota = 0 - } - if byQuota < byLicense { - return byQuota - } - } - return byLicense -} - -// loadCapacity reads live capacity + open-reg quota state. -func (s *TelegramBotService) loadCapacity(ctx context.Context) capacityInfo { - used, _ := s.repo.User.Count(ctx) - info := capacityInfo{ - UsedUsers: used, - MaxUsers: LicensedMaxUsers(ctx, s.repo), - OpenRegOn: s.openRegEnabled(ctx), - OpenRegLimit: s.intSetting(ctx, SettingOpenRegLimit, 0), - OpenRegUsed: s.intSetting(ctx, SettingOpenRegUsed, 0), - } - return info -} - -func (s *TelegramBotService) intSetting(ctx context.Context, key string, fallback int) int { - v, err := s.repo.Setting.Get(ctx, key) - if err != nil { - return fallback - } - return parseIntSettingDefault(v, fallback) -} - -// openRegEnabled reports whether bot registration is currently open. It honours -// both the new open-reg switch and the legacy registration switch. -func (s *TelegramBotService) openRegEnabled(ctx context.Context) bool { - if v, _ := s.repo.Setting.Get(ctx, SettingOpenRegEnabled); v != "" { - return parseBoolSetting(v, false) - } - return s.registrationEnabled(ctx) -} - -// openRegistration opens registration for `limit` new accounts (0 = unlimited, -// bounded only by the license). Resets the used counter. -func (s *TelegramBotService) openRegistration(ctx context.Context, limit int) error { - if limit < 0 { - limit = 0 - } - if err := s.repo.Setting.Set(ctx, SettingOpenRegEnabled, "true"); err != nil { - return err - } - if err := s.repo.Setting.Set(ctx, SettingOpenRegLimit, strconv.Itoa(limit)); err != nil { - return err - } - if err := s.repo.Setting.Set(ctx, SettingOpenRegUsed, "0"); err != nil { - return err - } - // 与旧开关同步,兼容系统设置页。 - return s.setRegistrationEnabled(ctx, true) -} - -// closeRegistration disables bot registration. -func (s *TelegramBotService) closeRegistration(ctx context.Context) error { - if err := s.repo.Setting.Set(ctx, SettingOpenRegEnabled, "false"); err != nil { - return err - } - return s.setRegistrationEnabled(ctx, false) -} - -// consumeOpenRegSlot increments the used counter and auto-closes registration -// once the quota is exhausted. Call after a successful bot registration. -func (s *TelegramBotService) consumeOpenRegSlot(ctx context.Context) { - limit := s.intSetting(ctx, SettingOpenRegLimit, 0) - used := s.intSetting(ctx, SettingOpenRegUsed, 0) + 1 - _ = s.repo.Setting.Set(ctx, SettingOpenRegUsed, strconv.Itoa(used)) - if limit > 0 && used >= limit { - _ = s.closeRegistration(ctx) - } -} - -// ── 兑换码 ────────────────────────────────────────────────────────────────── - -// generateCode creates a random redemption code of the given kind. durationDays -// sets the account validity granted on redeem (0 = permanent). validDays sets -// how long the code itself stays redeemable (0 = never expires). -func (s *TelegramBotService) generateCode(ctx context.Context, kind string, durationDays, validDays int, createdBy string) (*model.RegistrationCode, error) { - return s.generateCodeWithUses(ctx, kind, durationDays, validDays, 1, createdBy) -} - -func (s *TelegramBotService) generateCodeWithUses(ctx context.Context, kind string, durationDays, validDays, maxUses int, createdBy string) (*model.RegistrationCode, error) { - if kind != model.RegistrationCodeRegister && kind != model.RegistrationCodeRenew { - kind = model.RegistrationCodeRegister - } - if maxUses <= 0 { - maxUses = 1 - } - code := &model.RegistrationCode{ - Code: randomCode(12), - Kind: kind, - DurationDays: durationDays, - MaxUses: maxUses, - CreatedByID: createdBy, - } - if validDays > 0 { - exp := time.Now().Add(time.Duration(validDays) * 24 * time.Hour) - code.ExpiresAt = &exp - } - if err := s.repo.RegCode.Create(ctx, code); err != nil { - return nil, err - } - return code, nil -} - -// lookupRedeemableCode validates a code without consuming it. Callers mark it -// used only after the dependent action (account create / renew) succeeds, so a -// failed action never burns a code. -func (s *TelegramBotService) lookupRedeemableCode(ctx context.Context, raw, wantKind string) (*model.RegistrationCode, string) { - code := normalizeRedemptionCode(raw) - if code == "" { - return nil, "请提供兑换码。" - } - rc, err := s.repo.RegCode.FindByCode(ctx, code) - if err != nil || rc == nil { - return nil, "兑换码无效。" - } - if rc.IsUsed() { - return nil, "兑换码已被使用。" - } - if rc.IsExpired() { - return nil, "兑换码已过期。" - } - if wantKind != "" && rc.Kind != wantKind { - switch rc.Kind { - case model.RegistrationCodeRenew: - return nil, "这是续期兑换码,请在「我的账号」里使用它续期。" - default: - return nil, "这是注册兑换码,请用于注册新账号。" - } - } - return rc, "" -} - -func normalizeRedemptionCode(raw string) string { - code := strings.ToUpper(strings.TrimSpace(raw)) - code = strings.NewReplacer(" ", "", "-", "", "_", "").Replace(code) - return code -} - -func looksLikeRedemptionCode(raw string) bool { - code := normalizeRedemptionCode(raw) - if len(code) < 8 || len(code) > 32 { - return false - } - for _, ch := range code { - if !strings.ContainsRune(codeAlphabet, ch) { - return false - } - } - return true -} - -// ── 续期 ──────────────────────────────────────────────────────────────────── - -// renewUser extends a user's expiry by durationDays. A nil/zero current expiry -// starts from now; a future expiry is extended from that point. durationDays<=0 -// sets the account to never expire (permanent). -func renewExpiry(current *time.Time, durationDays int) *time.Time { - if durationDays <= 0 { - return nil // permanent - } - base := time.Now() - if current != nil && current.After(base) { - base = *current - } - exp := base.Add(time.Duration(durationDays) * 24 * time.Hour) - return &exp -} - -// applyRenewal renews a user account and clears any expiry-related suspension. -func (s *TelegramBotService) applyRenewal(ctx context.Context, userID string, durationDays int) error { - u, err := s.repo.User.FindByID(ctx, userID) - if err != nil || u == nil { - return fmt.Errorf("user not found") - } - exp := renewExpiry(u.ExpiredAt, durationDays) - updates := map[string]any{"expired_at": exp, "is_active": true} - return s.repo.User.UpdateFields(ctx, userID, updates) -} - -// ── 签到 ──────────────────────────────────────────────────────────────────── - -// signInResult 描述一次签到的结果。 -type signInResult struct { - AlreadySigned bool - Streak int - Total int -} - -// signIn records a daily sign-in for the user, tracking consecutive-day streaks -// only (no points). A second sign-in on the same calendar day is a no-op. -func (s *TelegramBotService) signIn(ctx context.Context, userID string) (signInResult, error) { - now := time.Now() - today := now.Truncate(24 * time.Hour) - rec, err := s.repo.SignIn.Get(ctx, userID) - if err != nil { - return signInResult{}, err - } - if rec == nil { - rec = &model.SignIn{UserID: userID, LastSignIn: now, StreakDays: 1, TotalDays: 1} - if err := s.repo.SignIn.Save(ctx, rec); err != nil { - return signInResult{}, err - } - return signInResult{Streak: 1, Total: 1}, nil - } - last := rec.LastSignIn.Truncate(24 * time.Hour) - switch { - case last.Equal(today): - return signInResult{AlreadySigned: true, Streak: rec.StreakDays, Total: rec.TotalDays}, nil - case last.Equal(today.Add(-24 * time.Hour)): - rec.StreakDays++ - default: - rec.StreakDays = 1 // streak broken - } - rec.TotalDays++ - rec.LastSignIn = now - if err := s.repo.SignIn.Save(ctx, rec); err != nil { - return signInResult{}, err - } - return signInResult{Streak: rec.StreakDays, Total: rec.TotalDays}, nil -} - -// ── helpers ───────────────────────────────────────────────────────────────── - -const codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // no ambiguous 0/O/1/I - -func randomCode(n int) string { - b := make([]byte, n) - secureRandomBytes(b) - out := make([]byte, n) - for i := range b { - out[i] = codeAlphabet[int(b[i])%len(codeAlphabet)] - } - return string(out) -} - -// formatExpiry renders a user's expiry status for display. -func formatExpiry(t *time.Time) string { - if t == nil { - return "永久有效" - } - if time.Now().After(*t) { - return "已过期(" + t.Format("2006-01-02") + ")" - } - days := int(time.Until(*t).Hours() / 24) - return fmt.Sprintf("%s(剩 %d 天)", t.Format("2006-01-02"), days) -} diff --git a/internal/service/bot_features_test.go b/internal/service/bot_features_test.go deleted file mode 100644 index d5b5758..0000000 --- a/internal/service/bot_features_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package service - -import ( - "context" - "testing" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" - "go.uber.org/zap" -) - -func newBotTestService(t *testing.T) (*repository.Container, *TelegramBotService) { - t.Helper() - db := newServiceTestDB(t, model.AllModels()...) - repos := repository.New(db) - cfg := &config.Config{} - cfg.Secrets.JWTSecret = "test-secret" - log := zap.NewNop() - perms := NewPermissionService(log, repos) - tokenSvc := NewTokenService(cfg, log, repos) - auth := NewAuthService(cfg, log, repos, tokenSvc, perms) - crypto := NewCryptoService("test-secret", log) - bot := NewTelegramBotService(log, repos, crypto, auth) - return repos, bot -} - -// ── pure logic ────────────────────────────────────────────────────────────── - -func TestRenewExpiry(t *testing.T) { - // 永久(0 天)→ nil - if got := renewExpiry(nil, 0); got != nil { - t.Fatalf("expected nil for permanent, got %v", got) - } - // 从现在起 +30 天(当前为空) - got := renewExpiry(nil, 30) - if got == nil || got.Before(time.Now().Add(29*24*time.Hour)) { - t.Fatalf("expected ~30d expiry, got %v", got) - } - // 已有未来到期 → 在原到期基础上叠加 - future := time.Now().Add(10 * 24 * time.Hour) - got = renewExpiry(&future, 30) - if got == nil || got.Before(future.Add(29*24*time.Hour)) { - t.Fatalf("expected stacking on future expiry, got %v", got) - } - // 已过期 → 从现在起算 - past := time.Now().Add(-10 * 24 * time.Hour) - got = renewExpiry(&past, 5) - if got == nil || got.Before(time.Now().Add(4*24*time.Hour)) { - t.Fatalf("expected fresh window from now, got %v", got) - } -} - -func TestCapacityRemaining(t *testing.T) { - cases := []struct { - name string - c capacityInfo - want int64 - }{ - {"license only", capacityInfo{UsedUsers: 5, MaxUsers: 20}, 15}, - {"quota tighter", capacityInfo{UsedUsers: 5, MaxUsers: 100, OpenRegLimit: 10, OpenRegUsed: 3}, 7}, - {"license tighter", capacityInfo{UsedUsers: 95, MaxUsers: 100, OpenRegLimit: 50, OpenRegUsed: 0}, 5}, - {"full", capacityInfo{UsedUsers: 20, MaxUsers: 20}, 0}, - {"quota exhausted", capacityInfo{UsedUsers: 1, MaxUsers: 100, OpenRegLimit: 5, OpenRegUsed: 5}, 0}, - } - for _, tc := range cases { - if got := tc.c.Remaining(); got != tc.want { - t.Errorf("%s: Remaining()=%d want %d", tc.name, got, tc.want) - } - } -} - -func TestRandomWindowDays(t *testing.T) { - for i := 0; i < 200; i++ { - d := randomWindowDays(3, 5) - if d < 3 || d > 5 { - t.Fatalf("randomWindowDays(3,5)=%d out of range", d) - } - } - if d := randomWindowDays(4, 4); d != 4 { - t.Fatalf("randomWindowDays(4,4)=%d want 4", d) - } -} - -func TestFingerprintStability(t *testing.T) { - a := fingerprint("Infuse", "iPhone") - b := fingerprint("infuse", " iPhone ") - if a != b { - t.Fatalf("fingerprint should be case/space-insensitive: %s != %s", a, b) - } - if a != fingerprint("Emby", "iPhone") { - t.Fatal("different apps on the same terminal must share one fingerprint") - } - if a == fingerprint("Infuse", "iPad") { - t.Fatal("different device names must yield different fingerprints") - } -} - -// ── DB-backed ───────────────────────────────────────────────────────────── - -func TestSignInStreak(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - u := &model.User{Username: "alice", PasswordHash: "x", Role: "user"} - if err := repos.User.Create(ctx, u); err != nil { - t.Fatal(err) - } - - res, err := bot.signIn(ctx, u.ID) - if err != nil || res.Streak != 1 || res.Total != 1 { - t.Fatalf("first sign-in: %+v err=%v", res, err) - } - // 同日重复签到 → 不增长 - res, _ = bot.signIn(ctx, u.ID) - if !res.AlreadySigned || res.Streak != 1 { - t.Fatalf("same-day re-signin should be no-op: %+v", res) - } - // 模拟昨天签到 → 连续 +1 - rec, _ := repos.SignIn.Get(ctx, u.ID) - rec.LastSignIn = time.Now().Add(-24 * time.Hour) - _ = repos.SignIn.Save(ctx, rec) - res, _ = bot.signIn(ctx, u.ID) - if res.Streak != 2 || res.Total != 2 { - t.Fatalf("consecutive day should bump streak: %+v", res) - } - // 中断(前天)→ 重置为 1 - rec, _ = repos.SignIn.Get(ctx, u.ID) - rec.LastSignIn = time.Now().Add(-72 * time.Hour) - _ = repos.SignIn.Save(ctx, rec) - res, _ = bot.signIn(ctx, u.ID) - if res.Streak != 1 { - t.Fatalf("broken streak should reset to 1: %+v", res) - } -} diff --git a/internal/service/bot_policy_commands_test.go b/internal/service/bot_policy_commands_test.go deleted file mode 100644 index 4ae8faf..0000000 --- a/internal/service/bot_policy_commands_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package service - -import ( - "context" - "strings" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestBotAdminCommandsManageDevicePolicy(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - if err := repos.User.Create(ctx, admin); err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 9001, - TelegramName: "@root", - ChatID: 9001, - UserID: admin.ID, - }).Error; err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "root"}, Chat: TelegramChat{ID: 9001, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/antishare on play=4 login=5 warn=3") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "防共享:已开启") { - t.Fatalf("expected antishare enabled reply, got %q", reply.Text) - } - cfg := loadBotConfig(ctx, repos) - if !cfg.AntiShareEnabled || cfg.MaxConcurrentPlay != 4 || cfg.MaxLoggedClients != 5 || cfg.WarnThreshold != 3 { - t.Fatalf("unexpected device policy: %+v", cfg) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_mode count 2") - if err != nil { - t.Fatal(err) - } - cfg = loadBotConfig(ctx, repos) - if cfg.AccountCleanupKeepMode != "any" || cfg.AccountCleanupRequiredCount != 1 { - t.Fatalf("unexpected cleanup mode: %+v; reply=%q", cfg, reply.Text) - } - if !strings.Contains(reply.Text, "满足任意一条") { - t.Fatalf("cleanup mode should explain fixed any-rule policy, got %q", reply.Text) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule add recent_login login_7d 七天内登录 7") - if err != nil { - t.Fatal(err) - } - cfg = loadBotConfig(ctx, repos) - found := false - for _, rule := range cfg.AccountCleanupRules { - if rule.ID == "login_7d" && rule.Type == "recent_login" && rule.WindowDaysMax == 7 { - found = true - } - } - if !found { - t.Fatalf("cleanup rule not added; reply=%q rules=%+v", reply.Text, cfg.AccountCleanupRules) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule edit recent_login login_7d 十四天内登录 14") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已更新规则") { - t.Fatalf("expected cleanup rule update reply, got %q", reply.Text) - } - cfg = loadBotConfig(ctx, repos) - matches := 0 - for _, rule := range cfg.AccountCleanupRules { - if rule.ID == "login_7d" { - matches++ - if rule.Type != "recent_login" || rule.WindowDaysMax != 14 || rule.Name != "十四天内登录" { - t.Fatalf("cleanup rule should be updated in place, got %+v", rule) - } - } - } - if matches != 1 { - t.Fatalf("cleanup rule update should not create duplicates, got %d rules=%+v", matches, cfg.AccountCleanupRules) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule 修改 recent_login login_7d 二十一天内登录 21") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已更新规则") { - t.Fatalf("expected Chinese cleanup rule update reply, got %q", reply.Text) - } - cfg = loadBotConfig(ctx, repos) - matches = 0 - for _, rule := range cfg.AccountCleanupRules { - if rule.ID == "login_7d" { - matches++ - if rule.WindowDaysMax != 21 || rule.Name != "二十一天内登录" { - t.Fatalf("Chinese cleanup rule update should update values, got %+v", rule) - } - } - } - if matches != 1 { - t.Fatalf("Chinese cleanup rule update should not create duplicates, got %d rules=%+v", matches, cfg.AccountCleanupRules) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule add account_age_grace new_7d 7") - if err != nil { - t.Fatal(err) - } - cfg = loadBotConfig(ctx, repos) - found = false - for _, rule := range cfg.AccountCleanupRules { - if rule.ID == "new_7d" && rule.Type == "account_age_grace" && rule.MinCount == 7 { - found = true - } - } - if !found { - t.Fatalf("cleanup shorthand rule not added; reply=%q rules=%+v", reply.Text, cfg.AccountCleanupRules) - } -} diff --git a/internal/service/bot_registration_code_test.go b/internal/service/bot_registration_code_test.go deleted file mode 100644 index ab98e21..0000000 --- a/internal/service/bot_registration_code_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "strings" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestBotRedeemRegisterRequiresAllowedTelegramUser(t *testing.T) { - ctx := context.Background() - _, bot := newBotTestService(t) - code, err := bot.generateCode(ctx, model.RegistrationCodeRegister, 30, 0, "") - if err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9201, Username: "outsider"}, Chat: TelegramChat{ID: 9201, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/redeem_register "+code.Code) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "不在管理员配置") { - t.Fatalf("outsider should not redeem register code, got %q", reply.Text) - } - - channel.Config = `{"admin_user_ids":"9201"}` - reply, err = bot.executeCommand(ctx, channel, msg, "/redeem_register "+code.Code) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "兑换成功") { - t.Fatalf("allowed user should redeem register code, got %q", reply.Text) - } - if binding := bot.telegramBinding(ctx, 9201); binding == nil { - t.Fatal("redeemed account should be bound to telegram user") - } -} - -func TestBotRedeemRegisterCodeCreatesOnlyOneAccount(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - code, err := bot.generateCode(ctx, model.RegistrationCodeRegister, 30, 0, "") - if err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9201,9202"}`} - - first := &TelegramMessage{From: TelegramUser{ID: 9201, Username: "first"}, Chat: TelegramChat{ID: 9201, Type: "private"}} - reply, err := bot.executeCommand(ctx, channel, first, "/redeem_register "+code.Code) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "兑换成功") { - t.Fatalf("first redeem should succeed, got %q", reply.Text) - } - - second := &TelegramMessage{From: TelegramUser{ID: 9202, Username: "second"}, Chat: TelegramChat{ID: 9202, Type: "private"}} - reply, err = bot.executeCommand(ctx, channel, second, "/redeem_register "+code.Code) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "兑换码已被使用") && !strings.Contains(reply.Text, "兑换码刚刚被使用") { - t.Fatalf("second redeem should be rejected as used, got %q", reply.Text) - } - var users int64 - if err := repos.DB.Model(&model.User{}).Count(&users).Error; err != nil { - t.Fatal(err) - } - if users != 1 { - t.Fatalf("one register code must create exactly one user, got %d", users) - } - if binding := bot.telegramBinding(ctx, 9202); binding != nil { - t.Fatal("second telegram user must not be bound by an already-used register code") - } -} - -func TestBotRegisterCommandAcceptsRegistrationCode(t *testing.T) { - ctx := context.Background() - _, bot := newBotTestService(t) - code, err := bot.generateCode(ctx, model.RegistrationCodeRegister, 30, 0, "") - if err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9301"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9301, Username: "codeuser"}, Chat: TelegramChat{ID: 9301, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/register "+strings.ToLower(code.Code[:4])+"-"+strings.ToLower(code.Code[4:])) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "兑换成功") { - t.Fatalf("/register CODE should redeem registration code, got %q", reply.Text) - } - if binding := bot.telegramBinding(ctx, 9301); binding == nil { - t.Fatal("register code should bind the newly created account") - } -} - -func TestBotPlainRegistrationCodeMessageRedeems(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - code, err := bot.generateCode(ctx, model.RegistrationCodeRegister, 30, 0, "") - if err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.NotifyChannel{ - Name: "Telegram", - Type: "telegram", - Enabled: true, - Config: `{"admin_user_ids":"9302"}`, - }).Error; err != nil { - t.Fatal(err) - } - update, _ := json.Marshal(TelegramUpdate{ - UpdateID: 1, - Message: &TelegramMessage{ - MessageID: 12, - Text: strings.ToLower(code.Code), - From: TelegramUser{ID: 9302, Username: "plaincode"}, - Chat: TelegramChat{ID: 9302, Type: "private"}, - }, - }) - - if err := bot.HandleWebhook(ctx, update); err != nil { - t.Fatal(err) - } - if binding := bot.telegramBinding(ctx, 9302); binding == nil { - t.Fatal("plain code private message should redeem and bind account") - } - var used model.RegistrationCode - if err := repos.DB.Where("code = ?", code.Code).First(&used).Error; err != nil { - t.Fatal(err) - } - if used.UsedAt == nil || used.UsedByUserID == "" { - t.Fatal("plain code message should mark registration code as used") - } -} diff --git a/internal/service/bot_registration_test.go b/internal/service/bot_registration_test.go deleted file mode 100644 index 5ea91b1..0000000 --- a/internal/service/bot_registration_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package service - -import ( - "context" - "testing" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestRegistrationCodeRedeemOnce(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - - code, err := bot.generateCode(ctx, model.RegistrationCodeRenew, 30, 0, "") - if err != nil { - t.Fatal(err) - } - // 首次校验通过 - rc, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew) - if rc == nil { - t.Fatalf("expected valid code, got msg=%q", msg) - } - // 标记使用后不可再用 - if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-1"); err != nil { - t.Fatal(err) - } - if _, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew); msg == "" { - t.Fatal("used code must not validate again") - } - // 第二次 MarkUsed 应失败(防止双花) - if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-2"); err == nil { - t.Fatal("double-spend should be rejected") - } - // 类型不匹配应被拒 - reg, _ := bot.generateCode(ctx, model.RegistrationCodeRegister, 0, 0, "") - if _, msg := bot.lookupRedeemableCode(ctx, reg.Code, model.RegistrationCodeRenew); msg == "" { - t.Fatal("register code should not validate as renew") - } -} - -func TestRegistrationCodeCanBeGeneratedForMultipleUses(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - - code, err := bot.generateCodeWithUses(ctx, model.RegistrationCodeRenew, 30, 0, 2, "") - if err != nil { - t.Fatal(err) - } - rc, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew) - if rc == nil { - t.Fatalf("expected valid code, got msg=%q", msg) - } - if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-1"); err != nil { - t.Fatal(err) - } - rc, msg = bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew) - if rc == nil { - t.Fatalf("code should remain redeemable after first use, got msg=%q", msg) - } - if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-2"); err != nil { - t.Fatal(err) - } - if _, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew); msg == "" { - t.Fatal("code should be exhausted after max uses") - } - var used model.RegistrationCode - if err := repos.DB.Where("id = ?", code.ID).First(&used).Error; err != nil { - t.Fatal(err) - } - if used.UsedCount != 2 || used.UsedAt == nil { - t.Fatalf("expected exhausted code with used_count=2, got %+v", used) - } -} - -func TestRenewalClearsExpiry(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - past := time.Now().Add(-time.Hour) - u := &model.User{Username: "bob", PasswordHash: "x", Role: "user", IsActive: false, ExpiredAt: &past} - if err := repos.User.Create(ctx, u); err != nil { - t.Fatal(err) - } - if err := bot.applyRenewal(ctx, u.ID, 30); err != nil { - t.Fatal(err) - } - got, _ := repos.User.FindByID(ctx, u.ID) - if !got.IsActive { - t.Fatal("renewal should re-activate account") - } - if got.ExpiredAt == nil || got.ExpiredAt.Before(time.Now()) { - t.Fatalf("renewal should set future expiry, got %v", got.ExpiredAt) - } -} diff --git a/internal/service/bot_unbind_test.go b/internal/service/bot_unbind_test.go deleted file mode 100644 index ad88a50..0000000 --- a/internal/service/bot_unbind_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package service - -import ( - "context" - "strings" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestBotAdminUnbindMultipleUsers(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - viewer := &model.User{Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true} - guest := &model.User{Username: "guest", PasswordHash: "x", Role: "user", IsActive: true} - for _, user := range []*model.User{admin, viewer, guest} { - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - } - bindings := []model.TelegramBinding{ - {TelegramUserID: 9401, TelegramName: "@root", ChatID: 9401, UserID: admin.ID}, - {TelegramUserID: 9402, TelegramName: "@viewer", ChatID: 9402, UserID: viewer.ID}, - {TelegramUserID: 9403, TelegramName: "@guest", ChatID: 9403, UserID: guest.ID}, - } - for i := range bindings { - if err := repos.DB.Create(&bindings[i]).Error; err != nil { - t.Fatal(err) - } - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9401"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9401, Username: "root"}, Chat: TelegramChat{ID: 9401, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/unbind viewer,guest missing root") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已解绑:2") || !strings.Contains(reply.Text, "root(管理员)") || !strings.Contains(reply.Text, "missing") { - t.Fatalf("unexpected unbind reply: %q", reply.Text) - } - for _, user := range []*model.User{viewer, guest} { - var count int64 - if err := repos.DB.Model(&model.TelegramBinding{}).Where("user_id = ?", user.ID).Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 0 { - t.Fatalf("%s binding count = %d, want 0", user.Username, count) - } - } - if binding := bot.telegramBinding(ctx, 9401); binding == nil { - t.Fatal("admin binding should be protected from /unbind by username") - } -} - -func TestBotAdminUnbindInactiveAndInvalidBindings(t *testing.T) { - ctx := context.Background() - repos, bot := newBotTestService(t) - oldTime := time.Now().Add(-45 * 24 * time.Hour) - recentTime := time.Now().Add(-2 * 24 * time.Hour) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true, LastLoginAt: &oldTime} - oldUser := &model.User{Username: "old", PasswordHash: "x", Role: "user", IsActive: true, LastLoginAt: &oldTime} - realtimeUser := &model.User{Username: "realtime", PasswordHash: "x", Role: "user", IsActive: true, LastLoginAt: &oldTime} - recentUser := &model.User{Username: "recent", PasswordHash: "x", Role: "user", IsActive: true, LastLoginAt: &recentTime} - for _, user := range []*model.User{admin, oldUser, realtimeUser, recentUser} { - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - } - for _, binding := range []model.TelegramBinding{ - {TelegramUserID: 9501, TelegramName: "@root", ChatID: 9501, UserID: admin.ID}, - {TelegramUserID: 9502, TelegramName: "@old", ChatID: 9502, UserID: oldUser.ID}, - {TelegramUserID: 9503, TelegramName: "@recent", ChatID: 9503, UserID: recentUser.ID}, - {TelegramUserID: 9505, TelegramName: "@realtime", ChatID: 9505, UserID: realtimeUser.ID}, - {TelegramUserID: 9504, TelegramName: "@ghost", ChatID: 9504, UserID: "missing-user"}, - } { - row := binding - if err := repos.DB.Create(&row).Error; err != nil { - t.Fatal(err) - } - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9501"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9501, Username: "root"}, Chat: TelegramChat{ID: 9501, Type: "private"}} - tracker := NewSessionTrackerService(zap.NewNop()) - tracker.RecordActivity(ctx, realtimeUser.ID, realtimeUser.Username, "phone-1", "iPhone", "Infuse", "192.0.2.10") - device := NewDeviceService(zap.NewNop(), repos) - device.SetSessionTracker(tracker) - bot.SetDeviceService(device) - - reply, err := bot.executeCommand(ctx, channel, msg, "/unbind_inactive 30") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已解绑:1") || !strings.Contains(reply.Text, "old") { - t.Fatalf("unexpected inactive unbind reply: %q", reply.Text) - } - if binding := bot.telegramBinding(ctx, 9502); binding != nil { - t.Fatal("old user binding should be removed") - } - if binding := bot.telegramBinding(ctx, 9501); binding == nil { - t.Fatal("admin binding should be skipped by inactive cleanup") - } - if binding := bot.telegramBinding(ctx, 9503); binding == nil { - t.Fatal("recent user binding should remain") - } - if binding := bot.telegramBinding(ctx, 9505); binding == nil { - t.Fatal("realtime active user binding should remain") - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/unbind_duplicates") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已解绑:1") || !strings.Contains(reply.Text, "tg:9504") { - t.Fatalf("unexpected duplicate cleanup reply: %q", reply.Text) - } - if binding := bot.telegramBinding(ctx, 9504); binding != nil { - t.Fatal("invalid binding should be removed") - } -} - -func TestTelegramMembershipChatIDsIncludesCommandChatID(t *testing.T) { - _, bot := newBotTestService(t) - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"command_chat_id":"-100123"}`} - got := bot.telegramMembershipChatIDs(channel) - if len(got) != 1 || got[0] != "-100123" { - t.Fatalf("telegramMembershipChatIDs() = %#v, want command_chat_id", got) - } -} - -func TestTelegramMembershipChatIDsDedupesGroupChannelAndCommandIDs(t *testing.T) { - _, bot := newBotTestService(t) - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"group_chat_id":"-100123","channel_chat_id":"-100124","command_chat_id":"-100123"}`} - got := bot.telegramMembershipChatIDs(channel) - if len(got) != 2 || got[0] != "-100123" || got[1] != "-100124" { - t.Fatalf("telegramMembershipChatIDs() = %#v, want deduped ids", got) - } -} diff --git a/internal/service/cloud/cloud.go b/internal/service/cloud/cloud.go deleted file mode 100644 index 985bef7..0000000 --- a/internal/service/cloud/cloud.go +++ /dev/null @@ -1,137 +0,0 @@ -// Package cloud implements pluggable cloud-disk (网盘) providers used by the -// external-storage subsystem to expose remote files as playable media via -// HTTP 302 redirects. -// -// The design offloads playback to the cloud provider: instead of the -// host downloading and re-streaming bytes, a provider resolves a file to a -// short-lived direct download URL and the player is 302-redirected straight to -// the cloud CDN. The host only performs a tiny redirect, freeing its CPU and -// bandwidth. -// -// Each provider authenticates with a cookie (obtained via the web UI, an API -// cookie, or a QR-code login flow). Providers are intentionally side-effect -// free and take an *http.Client so they can be exercised against httptest -// mock servers in unit tests. -package cloud - -import ( - "context" - "errors" - "net/http" - "strings" - "time" -) - -// timeNow is a seam so tests can pin timestamps. -var timeNow = time.Now - -// Provider types recognised by the registry. -const ( - Type115 = "cloud115" // 115 网盘 - TypeCloudDrive2 = "clouddrive2" // CloudDrive2 桥接网盘 - TypeOpenList = "openlist" // OpenList / AList-compatible bridge -) - -// ErrUnsupported is returned for an unknown provider type. -var ErrUnsupported = errors.New("unsupported cloud provider") - -// FileEntry is one item in a cloud directory listing. -type FileEntry struct { - ID string `json:"id"` // provider-native file id - Name string `json:"name"` - IsDir bool `json:"is_dir"` - Size int64 `json:"size"` - // PickCode is 115-specific; other providers use ID directly. - PickCode string `json:"pick_code,omitempty"` -} - -// DirectLink is a resolved playback target. -type DirectLink struct { - URL string `json:"url"` - // Headers that must accompany a request to URL (e.g. User-Agent, Cookie). - Headers map[string]string `json:"-"` - // Proxy reports whether URL requires the host to reverse-proxy the bytes - // (because the headers cannot be carried by a plain browser 302). When - // false the play handler issues a pure 302 redirect (true offload). - Proxy bool `json:"-"` -} - -// Provider is the common cloud-disk interface. -type Provider interface { - // Type returns the provider key. - Type() string - // Ping validates the stored credentials (cookie). Cheap, used by the - // storage-config Test() probe. - Ping(ctx context.Context) error - // List returns the entries under dirID. An empty dirID means the root. - List(ctx context.Context, dirID string) ([]FileEntry, error) - // Resolve turns a provider-native file reference (id or pickcode) into a - // short-lived direct download link suitable for 302 playback. - Resolve(ctx context.Context, fileRef string) (*DirectLink, error) -} - -// MutableProvider is implemented by cloud bridges that support safe folder -// management through their official API or standard WebDAV methods. -type MutableProvider interface { - Provider - Mkdir(ctx context.Context, parentDir, name string) (*FileEntry, error) - Rename(ctx context.Context, ref, name string) (*FileEntry, error) -} - -// MovableProvider is implemented by writable cloud bridges that can move an -// entry across directories, optionally renaming it in the same operation. -type MovableProvider interface { - MutableProvider - Move(ctx context.Context, ref, targetDir, name string) (*FileEntry, error) -} - -// New constructs a provider of the given type from a free-form config map -// (as persisted by StorageConfigService). The client is shared so callers can -// inject timeouts / test transports. -func New(typ string, cfg map[string]any, client *http.Client) (Provider, error) { - if client == nil { - client = http.DefaultClient - } - switch typ { - case Type115: - return new115(cfg, client), nil - case TypeCloudDrive2: - return newCloudDrive2(cfg, client), nil - case TypeOpenList: - return newOpenList(cfg, client), nil - default: - return nil, ErrUnsupported - } -} - -// IsCloudType reports whether typ is a cloud-disk provider. -func IsCloudType(typ string) bool { - return typ == Type115 || typ == TypeCloudDrive2 || typ == TypeOpenList -} - -// str coerces a config value to a trimmed string. -func str(v any) string { - if v == nil { - return "" - } - if s, ok := v.(string); ok { - return strings.TrimSpace(s) - } - return "" -} - -// boolish coerces a config value to bool ("true"/"1"/true → true). -func boolish(v any) bool { - switch t := v.(type) { - case bool: - return t - case string: - s := strings.ToLower(strings.TrimSpace(t)) - return s == "1" || s == "true" || s == "yes" || s == "on" - default: - return false - } -} - -// defaultUA is a desktop browser UA accepted by upstream cloud providers. -const defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36" diff --git a/internal/service/cloud/cloud_115_test.go b/internal/service/cloud/cloud_115_test.go deleted file mode 100644 index 631ebf7..0000000 --- a/internal/service/cloud/cloud_115_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package cloud - -import ( - "context" - "encoding/base64" - "fmt" - "net/http" - "net/http/httptest" - "strconv" - "strings" - "testing" -) - -func Test115ListAndResolve(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/files": - if r.URL.Query().Get("cid") != "0" { - t.Errorf("bad cid %q", r.URL.Query().Get("cid")) - } - w.Write([]byte(`{"state":true,"data":[ - {"cid":"100","n":"Movies","s":0}, - {"fid":"200","n":"Inception.mkv","s":456,"pc":"pick200"}]}`)) - default: - t.Errorf("unexpected path %s", r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(Type115, map[string]any{"cookie": "UID=1; CID=2", "base": srv.URL}, srv.Client()) - if err != nil { - t.Fatal(err) - } - // The downurl endpoint is m115-encrypted end-to-end (the server side - // requires 115's private key), so stub the decrypted payload via the seam - // and assert the pickcode->URL extraction. The live crypto/transport path is - // exercised by integration testing against the real 115 API. - p115, ok := p.(*pan115Provider) - if !ok { - t.Fatalf("expected *pan115Provider, got %T", p) - } - p115.downURLPayload = func(ctx context.Context, pickcode string) ([]byte, error) { - if pickcode != "pick200" { - t.Errorf("bad pickcode %q", pickcode) - } - return []byte(`{"200":{"file_name":"Inception.mkv","file_size":"456","url":{"url":"https://cdn.115/x.mkv?t=1"}}}`), nil - } - entries, err := p.List(context.Background(), "") - if err != nil { - t.Fatalf("list: %v", err) - } - if len(entries) != 2 { - t.Fatalf("want 2 entries: %#v", entries) - } - if !entries[0].IsDir || entries[0].ID != "100" { - t.Fatalf("dir entry wrong: %#v", entries[0]) - } - if entries[1].IsDir || entries[1].PickCode != "pick200" || entries[1].Size != 456 { - t.Fatalf("file entry wrong: %#v", entries[1]) - } - link, err := p.Resolve(context.Background(), "pick200") - if err != nil { - t.Fatalf("resolve: %v", err) - } - if link.URL != "https://cdn.115/x.mkv?t=1" { - t.Fatalf("bad url: %s", link.URL) - } - if link.Proxy { - t.Fatalf("115 should default to 302 (no proxy)") - } -} - -func Test115ListPaginates(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/files" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) - count := 100 - if offset > 0 { - count = 1 - } - items := make([]string, 0, count) - for i := 0; i < count; i++ { - n := offset + i - items = append(items, fmt.Sprintf(`{"fid":"%d","n":"Movie.%03d.mkv","s":%d,"pc":"pick%d"}`, n, n, n, n)) - } - w.Write([]byte(`{"state":true,"data":[` + strings.Join(items, ",") + `]}`)) - })) - defer srv.Close() - - p, err := New(Type115, map[string]any{"cookie": "UID=1; CID=2", "base": srv.URL}, srv.Client()) - if err != nil { - t.Fatal(err) - } - entries, err := p.List(context.Background(), "0") - if err != nil { - t.Fatalf("list: %v", err) - } - if len(entries) != 101 { - t.Fatalf("entries = %d, want 101", len(entries)) - } - if entries[100].ID != "100" || entries[100].PickCode != "pick100" { - t.Fatalf("last entry wrong: %#v", entries[100]) - } -} - -// Test115DownURLEndpointAndError exercises the live fetchDownURLPayload path: -// it must POST an m115-encrypted `data` body to /app/chrome/downurl?t=... and -// surface 115's error when state=false (no decryption needed for that branch). -func Test115DownURLEndpointAndError(t *testing.T) { - var gotData, gotT string - pro := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/app/chrome/downurl" { - t.Errorf("unexpected path %s", r.URL.Path) - } - gotT = r.URL.Query().Get("t") - _ = r.ParseForm() - gotData = r.PostFormValue("data") - w.Write([]byte(`{"state":false,"error":"not exist"}`)) - })) - defer pro.Close() - - p, err := New(Type115, map[string]any{"cookie": "UID=1", "pro_base": pro.URL}, pro.Client()) - if err != nil { - t.Fatal(err) - } - _, err = p.Resolve(context.Background(), "pickX") - if err == nil || !strings.Contains(err.Error(), "not exist") { - t.Fatalf("want upstream error surfaced, got %v", err) - } - if gotT == "" { - t.Errorf("missing t query param") - } - if gotData == "" { - t.Errorf("missing encrypted data body") - } - if _, derr := base64.StdEncoding.DecodeString(gotData); derr != nil { - t.Errorf("data body is not base64: %v", derr) - } -} - -func Test115QRFlow(t *testing.T) { - // status sequence: waiting -> scanned -> confirmed - calls := 0 - api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/1.0/web/1.0/token/": - w.Write([]byte(`{"state":1,"data":{"uid":"U1","time":1700,"sign":"S1"}}`)) - case "/get/status/": - if r.URL.Query().Get("uid") != "U1" { - t.Errorf("bad uid %q", r.URL.Query().Get("uid")) - } - calls++ - switch calls { - case 1: - w.Write([]byte(`{"state":1,"data":{"status":0}}`)) - case 2: - w.Write([]byte(`{"state":1,"data":{"status":1}}`)) - default: - w.Write([]byte(`{"state":1,"data":{"status":2}}`)) - } - default: - t.Errorf("unexpected api path %s", r.URL.Path) - } - })) - defer api.Close() - passport := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/app/1.0/web/1.0/login/qrcode/" { - t.Errorf("unexpected passport path %s", r.URL.Path) - } - w.Write([]byte(`{"state":1,"data":{"cookie":{"UID":"u","CID":"c","SEID":"s"}}}`)) - })) - defer passport.Close() - - oldA, oldP := qr115APIBase, qr115PassportBase - qr115APIBase, qr115PassportBase = api.URL, passport.URL - defer func() { qr115APIBase, qr115PassportBase = oldA, oldP }() - - ctx := context.Background() - sess, err := QRStart(ctx, api.Client()) - if err != nil { - t.Fatalf("qr start: %v", err) - } - if sess.UID != "U1" || sess.QRImageURL == "" { - t.Fatalf("bad session: %#v", sess) - } - want := []string{"waiting", "scanned", "confirmed"} - for i, exp := range want { - st, err := QRPoll(ctx, api.Client(), sess) - if err != nil { - t.Fatalf("poll %d: %v", i, err) - } - if st.State != exp { - t.Fatalf("poll %d: want %s got %s", i, exp, st.State) - } - if exp == "confirmed" { - if st.Cookie == "" || !containsAll(st.Cookie, "UID=u", "SEID=s") { - t.Fatalf("confirmed must yield cookie: %q", st.Cookie) - } - } - } -} - -func containsAll(s string, subs ...string) bool { - for _, sub := range subs { - if !strings.Contains(s, sub) { - return false - } - } - return true -} diff --git a/internal/service/cloud/cloud_test.go b/internal/service/cloud/cloud_test.go deleted file mode 100644 index c22fccd..0000000 --- a/internal/service/cloud/cloud_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package cloud - -import ( - "net/http" - "testing" -) - -func TestDeprecatedProviderPlaybackOverrideKeysAreIgnored(t *testing.T) { - pan115 := new115(map[string]any{"cookie": "UID=1; CID=2", "force_proxy": "true"}, http.DefaultClient) - if pan115.proxy { - t.Fatalf("115 should keep safe direct mode; force_proxy is deprecated") - } - cd2 := newCloudDrive2(map[string]any{"url": "http://example.test/dav", "force_302": "true"}, http.DefaultClient) - if !cd2.proxy { - t.Fatalf("clouddrive2 should keep safe proxy mode; force_302 is deprecated") - } -} - -func TestUnsupportedProvider(t *testing.T) { - if _, err := New("dropbox", nil, nil); err != ErrUnsupported { - t.Fatalf("want ErrUnsupported, got %v", err) - } - if _, err := New("quark", nil, nil); err != ErrUnsupported { - t.Fatalf("quark should be unsupported, got %v", err) - } - if IsCloudType("quark") { - t.Fatal("quark should not be an active cloud provider") - } -} diff --git a/internal/service/cloud/clouddrive2.go b/internal/service/cloud/clouddrive2.go deleted file mode 100644 index 64ddfa6..0000000 --- a/internal/service/cloud/clouddrive2.go +++ /dev/null @@ -1,237 +0,0 @@ -package cloud - -import ( - "context" - "encoding/base64" - "fmt" - "net/http" - "net/url" - "path" - "strings" -) - -// cloudDrive2Provider bridges CloudDrive2 through its WebDAV endpoint. -// -// CloudDrive2 integrates many cloud disks (115 / 123 / Aliyun and more). -// Treating it as a WebDAV-backed cloud provider lets MediaStationGo -// browse, mount and upload to those disks without carrying every provider's -// private chunk-upload protocol in this project. -type cloudDrive2Provider struct { - typ string - name string - base *url.URL - username string - password string - token string - ua string - apiBase *url.URL - client *http.Client - proxy bool -} - -func newCloudDrive2(cfg map[string]any, client *http.Client) *cloudDrive2Provider { - return newCloudDAVProvider(TypeCloudDrive2, "clouddrive2", cfg, client, "/dav") -} - -func newOpenList(cfg map[string]any, client *http.Client) *cloudDrive2Provider { - return newCloudDAVProvider(TypeOpenList, "openlist", cfg, client, "/dav") -} - -func newCloudDAVProvider(typ, name string, cfg map[string]any, client *http.Client, defaultDAVPath string) *cloudDrive2Provider { - rawURL := webDAVURLFromConfig(cfg, defaultDAVPath) - u, _ := url.Parse(strings.TrimRight(rawURL, "/")) - var apiBase *url.URL - if typ == TypeOpenList { - apiBase = openListAPIBaseFromConfig(cfg, rawURL, defaultDAVPath) - } - ua := str(cfg["ua"]) - if ua == "" { - ua = defaultUA - } - proxy := true - return &cloudDrive2Provider{ - typ: typ, - name: name, - base: u, - username: str(cfg["username"]), - password: str(cfg["password"]), - token: str(cfg["token"]), - ua: ua, - apiBase: apiBase, - client: client, - proxy: proxy, - } -} - -func (p *cloudDrive2Provider) Type() string { return p.typ } - -func (p *cloudDrive2Provider) Ping(ctx context.Context) error { - _, err := p.List(ctx, "") - return err -} - -func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) { - if err := p.validate(); err != nil { - return nil, err - } - ref := normalizeCloudDAVPath(fileRef) - if ref == "/" { - return nil, fmt.Errorf("%s: file reference required", p.name) - } - if p.typ == TypeOpenList && isCloudVideoPlaybackCandidate(ref) { - if p.apiBase == nil { - return nil, fmt.Errorf("%s: pure 302 playback requires an OpenList API server address; configure server/api_url so /api/fs/get can return raw_url", p.name) - } - link, err := p.resolveOpenListAPIDirect(ctx, ref) - if err != nil { - return nil, fmt.Errorf("%s: pure 302 playback requires OpenList raw_url for %s: %w", p.name, ref, err) - } - return link, nil - } - if p.typ == TypeCloudDrive2 && isCloudVideoPlaybackCandidate(ref) { - link, err := p.resolveCloudDAVRedirectDirect(ctx, ref) - if err != nil { - return nil, fmt.Errorf("%s: pure 302 playback requires CloudDrive2/WebDAV to return a CDN Location for %s: %w", p.name, ref, err) - } - return link, nil - } - headers := map[string]string{ - "User-Agent": p.ua, - } - if p.token != "" { - headers["Authorization"] = p.token - } else if p.username != "" { - headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(p.username+":"+p.password)) - } - return &DirectLink{URL: p.urlFor(ref), Headers: headers, Proxy: p.proxy}, nil -} - -func (p *cloudDrive2Provider) validate() error { - if p.base == nil || p.base.Scheme == "" || p.base.Host == "" { - return fmt.Errorf("%s: missing WebDAV URL", p.name) - } - return nil -} - -func webDAVURLFromConfig(cfg map[string]any, defaultDAVPath string) string { - rawURL := str(cfg["url"]) - if rawURL == "" { - rawURL = str(cfg["webdav_url"]) - } - if rawURL != "" { - return ensureDefaultDAVPath(rawURL, defaultDAVPath) - } - return defaultWebDAVURL(str(cfg["server"]), defaultDAVPath) -} - -func defaultWebDAVURL(server, defaultDAVPath string) string { - server = strings.TrimRight(strings.TrimSpace(server), "/") - if server == "" { - return "" - } - davPath := strings.TrimSpace(defaultDAVPath) - if davPath == "" { - return server - } - if !strings.HasPrefix(davPath, "/") { - davPath = "/" + davPath - } - return server + davPath -} - -func openListAPIBaseFromConfig(cfg map[string]any, webDAVURL, defaultDAVPath string) *url.URL { - raw := str(cfg["server"]) - if raw == "" { - raw = firstNonEmpty(str(cfg["api_url"]), webDAVURL) - } - raw = strings.TrimRight(strings.TrimSpace(raw), "/") - if raw == "" { - return nil - } - u, err := url.Parse(raw) - if err != nil || u.Scheme == "" || u.Host == "" { - return nil - } - davPath := strings.Trim(strings.TrimSpace(defaultDAVPath), "/") - if davPath != "" { - pathParts := strings.Split(strings.TrimRight(u.Path, "/"), "/") - if len(pathParts) > 0 && strings.EqualFold(pathParts[len(pathParts)-1], davPath) { - u.Path = strings.Join(pathParts[:len(pathParts)-1], "/") - if u.Path == "" { - u.Path = "/" - } - } - } - u.RawPath = "" - u.RawQuery = "" - u.Fragment = "" - return u -} - -func (p *cloudDrive2Provider) openListAPIURL(apiPath string) string { - if p.apiBase == nil { - return "" - } - u := *p.apiBase - u.RawPath = "" - basePath := strings.TrimRight(u.Path, "/") - apiPath = "/" + strings.TrimLeft(apiPath, "/") - if basePath == "" || basePath == "/" { - u.Path = apiPath - } else { - u.Path = basePath + apiPath - } - return u.String() -} - -func ensureDefaultDAVPath(rawURL, defaultDAVPath string) string { - rawURL = strings.TrimRight(strings.TrimSpace(rawURL), "/") - if rawURL == "" { - return "" - } - u, err := url.Parse(rawURL) - if err != nil || u.Scheme == "" || u.Host == "" { - return rawURL - } - if strings.TrimSpace(defaultDAVPath) == "" { - return rawURL - } - if u.Path == "" || u.Path == "/" { - davPath := strings.TrimSpace(defaultDAVPath) - if !strings.HasPrefix(davPath, "/") { - davPath = "/" + davPath - } - u.Path = davPath - u.RawPath = "" - return strings.TrimRight(u.String(), "/") - } - return rawURL -} - -func normalizeCloudDAVPath(p string) string { - p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/") - if p == "" || p == "." { - return "/" - } - if !strings.HasPrefix(p, "/") { - p = "/" + p - } - cleaned := path.Clean(p) - if cleaned == "." { - return "/" - } - return cleaned -} - -func sameCloudDAVPath(a, b string) bool { - return strings.TrimRight(normalizeCloudDAVPath(a), "/") == strings.TrimRight(normalizeCloudDAVPath(b), "/") -} - -func firstNonEmpty(values ...string) string { - for _, v := range values { - if strings.TrimSpace(v) != "" { - return strings.TrimSpace(v) - } - } - return "" -} diff --git a/internal/service/cloud/clouddrive2_dav.go b/internal/service/cloud/clouddrive2_dav.go deleted file mode 100644 index 042946c..0000000 --- a/internal/service/cloud/clouddrive2_dav.go +++ /dev/null @@ -1,172 +0,0 @@ -package cloud - -import ( - "context" - "encoding/base64" - "encoding/xml" - "fmt" - "io" - "net/http" - "net/url" - "path" - "strings" -) - -func (p *cloudDrive2Provider) List(ctx context.Context, dir string) ([]FileEntry, error) { - if err := p.validate(); err != nil { - return nil, err - } - if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() { - return p.listOpenListAPI(ctx, dir) - } - target := normalizeCloudDAVPath(dir) - req, err := http.NewRequestWithContext(ctx, "PROPFIND", p.urlFor(target), strings.NewReader(cloudDAVPropfindBody)) - if err != nil { - return nil, err - } - p.auth(req) - req.Header.Set("Depth", "1") - req.Header.Set("Content-Type", "application/xml; charset=utf-8") - req.Header.Set("Accept", "application/xml,text/xml,*/*") - resp, err := p.client.Do(req) - if err != nil { - return nil, decorateDAVTransportError(p.name, p.urlFor(target), err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, p.decorateDAVStatusError(resp, target) - } - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) - var multi cloudDAVMultiStatus - if err := xml.Unmarshal(body, &multi); err != nil { - return nil, fmt.Errorf("%s: decode webdav: %w", p.name, err) - } - basePath := strings.TrimRight(p.base.EscapedPath(), "/") - currentID := normalizeCloudDAVPath(target) - out := make([]FileEntry, 0, len(multi.Responses)) - for _, item := range multi.Responses { - entryPath, err := p.entryIDFromHref(item.Href, basePath) - if err != nil || entryPath == "" || sameCloudDAVPath(entryPath, currentID) { - continue - } - name := firstNonEmpty(item.PropStat.Prop.DisplayName, path.Base(strings.TrimRight(entryPath, "/"))) - if decoded, err := url.PathUnescape(name); err == nil { - name = decoded - } - if name == "" || name == "." || name == "/" { - continue - } - out = append(out, FileEntry{ - ID: entryPath, - Name: name, - IsDir: item.PropStat.Prop.ResourceType.Collection != nil || strings.HasSuffix(item.Href, "/"), - Size: parseDAVSize(item.PropStat.Prop.ContentLength), - }) - } - return out, nil -} - -func (p *cloudDrive2Provider) resolveCloudDAVRedirectDirect(ctx context.Context, fileRef string) (*DirectLink, error) { - target := p.urlFor(fileRef) - headers := map[string]string{ - "User-Agent": p.ua, - } - if p.token != "" { - headers["Authorization"] = p.token - } else if p.username != "" { - headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(p.username+":"+p.password)) - } - location, status, err := p.firstHTTPRedirectLocation(ctx, target, headers) - if err != nil { - return nil, decorateDAVTransportError(p.name, target, err) - } - if location == "" { - return nil, fmt.Errorf("%s: WebDAV %s returned http %d without CDN Location; refusing WebDAV/proxy fallback for pure 302 playback", p.name, fileRef, status) - } - return &DirectLink{URL: location, Headers: nil, Proxy: false}, nil -} - -func (p *cloudDrive2Provider) firstHTTPRedirectLocation(ctx context.Context, target string, headers map[string]string) (string, int, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) - if err != nil { - return "", 0, err - } - req.Header.Set("Accept", "*/*") - req.Header.Set("Accept-Encoding", "identity") - req.Header.Set("Range", "bytes=0-0") - if strings.TrimSpace(p.ua) != "" { - req.Header.Set("User-Agent", p.ua) - } - for key, value := range headers { - key = strings.TrimSpace(key) - if key != "" && strings.TrimSpace(value) != "" { - req.Header.Set(key, value) - } - } - client := p.client - if client == nil { - client = http.DefaultClient - } - noFollow := *client - noFollow.CheckRedirect = func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - } - resp, err := noFollow.Do(req) - if err != nil { - return "", 0, err - } - defer resp.Body.Close() - status := resp.StatusCode - if status >= 300 && status < 400 { - rawLocation := strings.TrimSpace(resp.Header.Get("Location")) - if rawLocation == "" { - return "", status, fmt.Errorf("%s: upstream returned redirect http %d without Location", p.name, status) - } - location, err := resolveHTTPRedirectLocation(target, rawLocation) - if err != nil { - return "", status, err - } - return location, status, nil - } - return "", status, nil -} - -func resolveHTTPRedirectLocation(baseURL, rawLocation string) (string, error) { - rawLocation = strings.TrimSpace(rawLocation) - if rawLocation == "" { - return "", fmt.Errorf("empty redirect Location") - } - if strings.HasPrefix(rawLocation, "//") { - base, err := url.Parse(baseURL) - if err != nil || base.Scheme == "" { - return "", fmt.Errorf("protocol-relative redirect Location without base scheme") - } - rawLocation = base.Scheme + ":" + rawLocation - } - location, err := url.Parse(rawLocation) - if err != nil { - return "", fmt.Errorf("invalid redirect Location: %w", err) - } - if location.IsAbs() { - if location.Scheme != "http" && location.Scheme != "https" { - return "", fmt.Errorf("unsupported redirect Location scheme %q", location.Scheme) - } - return location.String(), nil - } - base, err := url.Parse(baseURL) - if err != nil { - return "", fmt.Errorf("invalid redirect base URL: %w", err) - } - return base.ResolveReference(location).String(), nil -} - -func (p *cloudDrive2Provider) auth(req *http.Request) { - req.Header.Set("User-Agent", p.ua) - if p.token != "" { - req.Header.Set("Authorization", p.token) - return - } - if p.username != "" { - req.SetBasicAuth(p.username, p.password) - } -} diff --git a/internal/service/cloud/clouddrive2_dav_error.go b/internal/service/cloud/clouddrive2_dav_error.go deleted file mode 100644 index 6ae5372..0000000 --- a/internal/service/cloud/clouddrive2_dav_error.go +++ /dev/null @@ -1,55 +0,0 @@ -package cloud - -import ( - "fmt" - "io" - "net/http" - "strings" -) - -func (p *cloudDrive2Provider) decorateDAVStatusError(resp *http.Response, target string) error { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - detail := compactDAVErrorBody(string(body)) - if detail == "" { - if resp.StatusCode == http.StatusMethodNotAllowed { - return fmt.Errorf("%s: list %s returned http %d;请确认填写的是 WebDAV 地址(通常以 /dav 结尾),并且桥接网盘已在 OpenList/CloudDrive2 内完成登录或 Cookie 保存", p.name, target, resp.StatusCode) - } - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - return fmt.Errorf("%s: list %s returned http %d;请填写 OpenList/CloudDrive2 的 Token 或用户名密码,或确认 WebDAV 凭据可用", p.name, target, resp.StatusCode) - } - return fmt.Errorf("%s: list %s returned http %d", p.name, target, resp.StatusCode) - } - if resp.StatusCode == http.StatusMethodNotAllowed { - return fmt.Errorf("%s: list %s returned http %d:%s;请确认填写的是 WebDAV 地址(通常以 /dav 结尾),并且桥接网盘已在 OpenList/CloudDrive2 内完成登录或 Cookie 保存", p.name, target, resp.StatusCode, detail) - } - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - return fmt.Errorf("%s: list %s returned http %d:%s;请检查 WebDAV 用户名/密码、Authorization Token,或先在 OpenList/CloudDrive2 中保存对应网盘 Cookie", p.name, target, resp.StatusCode, detail) - } - return fmt.Errorf("%s: list %s returned http %d:%s", p.name, target, resp.StatusCode, detail) -} - -func compactDAVErrorBody(raw string) string { - raw = strings.TrimSpace(strings.ReplaceAll(raw, "\x00", "")) - if raw == "" { - return "" - } - raw = strings.Join(strings.Fields(raw), " ") - if len([]rune(raw)) > 180 { - return string([]rune(raw)[:180]) + "…" - } - return raw -} - -func decorateDAVTransportError(name, target string, err error) error { - if err == nil { - return nil - } - message := err.Error() - if strings.Contains(message, "server gave HTTP response to HTTPS client") { - return fmt.Errorf("%s: %w;当前地址使用 https://,但服务端返回 HTTP。请改用 http:// 地址,例如 OpenList 默认 WebDAV 通常是 http://host:5244/dav/;如果必须使用 https,请在 OpenList 前配置反向代理和证书", name, err) - } - if strings.Contains(message, "first record does not look like a TLS handshake") { - return fmt.Errorf("%s: %w;疑似把 HTTP 服务配置成了 https://,请检查 %s 的协议头", name, err, target) - } - return err -} diff --git a/internal/service/cloud/clouddrive2_dav_path.go b/internal/service/cloud/clouddrive2_dav_path.go deleted file mode 100644 index 14837c4..0000000 --- a/internal/service/cloud/clouddrive2_dav_path.go +++ /dev/null @@ -1,85 +0,0 @@ -package cloud - -import ( - "net/url" - "strconv" - "strings" -) - -func (p *cloudDrive2Provider) urlFor(remotePath string) string { - u := *p.base - u.RawPath = "" - basePath := strings.TrimRight(u.Path, "/") - remote := strings.Trim(normalizeCloudDAVPath(remotePath), "/") - switch { - case basePath == "" || basePath == "/": - if remote == "" { - u.Path = "/" - } else { - u.Path = "/" + remote - } - case remote == "": - u.Path = basePath - default: - u.Path = basePath + "/" + remote - } - return u.String() -} - -func (p *cloudDrive2Provider) entryIDFromHref(href, basePath string) (string, error) { - if href == "" { - return "", nil - } - parsed, err := url.Parse(href) - if err != nil { - return "", err - } - hrefPath := parsed.EscapedPath() - if hrefPath == "" { - hrefPath = href - } - if basePath != "" && basePath != "/" { - hrefPath = strings.TrimPrefix(hrefPath, basePath) - } - if decoded, err := url.PathUnescape(hrefPath); err == nil { - hrefPath = decoded - } - return normalizeCloudDAVPath(hrefPath), nil -} - -const cloudDAVPropfindBody = ` - - - - - - -` - -type cloudDAVMultiStatus struct { - Responses []cloudDAVResponse `xml:"response"` -} - -type cloudDAVResponse struct { - Href string `xml:"href"` - PropStat cloudDAVPropStat `xml:"propstat"` -} - -type cloudDAVPropStat struct { - Prop cloudDAVProp `xml:"prop"` -} - -type cloudDAVProp struct { - DisplayName string `xml:"displayname"` - ContentLength string `xml:"getcontentlength"` - ResourceType cloudDAVResourceType `xml:"resourcetype"` -} - -type cloudDAVResourceType struct { - Collection *struct{} `xml:"collection"` -} - -func parseDAVSize(raw string) int64 { - n, _ := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) - return n -} diff --git a/internal/service/cloud/clouddrive2_mutation.go b/internal/service/cloud/clouddrive2_mutation.go deleted file mode 100644 index 11a97f4..0000000 --- a/internal/service/cloud/clouddrive2_mutation.go +++ /dev/null @@ -1,233 +0,0 @@ -package cloud - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "path" - "strings" -) - -func (p *cloudDrive2Provider) Mkdir(ctx context.Context, parentDir, name string) (*FileEntry, error) { - cleanName, err := cleanCloudEntryName(name) - if err != nil { - return nil, err - } - parent := normalizeCloudDAVPath(parentDir) - target := joinOpenListAPIPath(parent, cleanName) - if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() { - if err := p.openListAPIMkdir(ctx, target); err != nil { - return nil, err - } - return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil - } - if err := p.webDAVMkdir(ctx, target); err != nil { - return nil, err - } - return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil -} - -func (p *cloudDrive2Provider) Rename(ctx context.Context, ref, name string) (*FileEntry, error) { - cleanName, err := cleanCloudEntryName(name) - if err != nil { - return nil, err - } - source := normalizeCloudDAVPath(ref) - if source == "/" { - return nil, fmt.Errorf("%s: cannot rename root directory", p.name) - } - target := joinOpenListAPIPath(path.Dir(source), cleanName) - if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() { - if err := p.openListAPIRename(ctx, source, cleanName); err != nil { - return nil, err - } - return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil - } - if err := p.webDAVRename(ctx, source, target); err != nil { - return nil, err - } - return &FileEntry{ID: target, Name: cleanName, IsDir: true}, nil -} - -func (p *cloudDrive2Provider) Move(ctx context.Context, ref, targetDir, name string) (*FileEntry, error) { - source := normalizeCloudDAVPath(ref) - if source == "/" { - return nil, fmt.Errorf("%s: cannot move root directory", p.name) - } - cleanName := strings.TrimSpace(name) - if cleanName == "" { - cleanName = path.Base(source) - } - var err error - cleanName, err = cleanCloudEntryName(cleanName) - if err != nil { - return nil, err - } - targetDir = normalizeCloudDAVPath(targetDir) - target := joinOpenListAPIPath(targetDir, cleanName) - if sameCloudDAVPath(source, target) { - return &FileEntry{ID: target, Name: cleanName}, nil - } - if p.typ == TypeOpenList && p.apiBase != nil && p.hasOpenListAPICredentials() { - if err := p.openListAPIMove(ctx, source, targetDir, cleanName); err != nil { - return nil, err - } - return &FileEntry{ID: target, Name: cleanName}, nil - } - if err := p.webDAVRename(ctx, source, target); err != nil { - return nil, err - } - return &FileEntry{ID: target, Name: cleanName}, nil -} - -func cleanCloudEntryName(name string) (string, error) { - name = strings.TrimSpace(name) - if name == "" || name == "." || name == ".." { - return "", fmt.Errorf("entry name is required") - } - if strings.ContainsAny(name, `/\`) { - return "", fmt.Errorf("entry name cannot contain path separators") - } - return name, nil -} - -func (p *cloudDrive2Provider) openListAPIMkdir(ctx context.Context, target string) error { - return p.openListAPIPost(ctx, "/api/fs/mkdir", map[string]string{"path": normalizeCloudDAVPath(target)}, "mkdir") -} - -func (p *cloudDrive2Provider) openListAPIRename(ctx context.Context, source, name string) error { - return p.openListAPIPost(ctx, "/api/fs/rename", map[string]string{ - "path": normalizeCloudDAVPath(source), - "name": name, - }, "rename") -} - -func (p *cloudDrive2Provider) openListAPIMove(ctx context.Context, source, targetDir, targetName string) error { - targetDir = normalizeCloudDAVPath(targetDir) - sourceName := path.Base(normalizeCloudDAVPath(source)) - if sameCloudDAVPath(path.Dir(source), targetDir) { - if sourceName == targetName { - return nil - } - return p.openListAPIRename(ctx, source, targetName) - } - if err := p.openListAPIPost(ctx, "/api/fs/move", map[string]any{ - "src_dir": normalizeCloudDAVPath(path.Dir(source)), - "dst_dir": targetDir, - "names": []string{sourceName}, - }, "move"); err != nil { - return err - } - if sourceName != targetName { - moved := joinOpenListAPIPath(targetDir, sourceName) - return p.openListAPIRename(ctx, moved, targetName) - } - return nil -} - -func (p *cloudDrive2Provider) openListAPIPost(ctx context.Context, apiPath string, payload any, action string) error { - token, err := p.openListAPIToken(ctx) - if err != nil { - return err - } - body, _ := json.Marshal(payload) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL(apiPath), bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", p.ua) - if token != "" { - req.Header.Set("Authorization", token) - } - resp, err := p.client.Do(req) - if err != nil { - return decorateDAVTransportError(p.name, p.openListAPIURL(apiPath), err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("%s: api %s returned http %d", p.name, action, resp.StatusCode) - } - var decoded struct { - Code int `json:"code"` - Message string `json:"message"` - } - if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil { - return fmt.Errorf("%s: decode api %s: %w", p.name, action, err) - } - if decoded.Code != 0 && decoded.Code != 200 { - msg := strings.TrimSpace(decoded.Message) - if msg == "" { - msg = fmt.Sprintf("code %d", decoded.Code) - } - return fmt.Errorf("%s: api %s failed: %s", p.name, action, msg) - } - return nil -} - -func (p *cloudDrive2Provider) webDAVMkdir(ctx context.Context, target string) error { - req, err := http.NewRequestWithContext(ctx, "MKCOL", p.urlFor(target), nil) - if err != nil { - return err - } - p.auth(req) - resp, err := p.client.Do(req) - if err != nil { - return decorateDAVTransportError(p.name, p.urlFor(target), err) - } - defer resp.Body.Close() - switch resp.StatusCode { - case http.StatusCreated, http.StatusOK, http.StatusNoContent: - return nil - case http.StatusMethodNotAllowed: - return fmt.Errorf("%s: mkdir %s returned http %d; the folder may already exist or this WebDAV backend is read-only", p.name, target, resp.StatusCode) - default: - return p.decorateDAVMutationStatusError(resp, "mkdir", target) - } -} - -func (p *cloudDrive2Provider) webDAVRename(ctx context.Context, source, target string) error { - req, err := http.NewRequestWithContext(ctx, "MOVE", p.urlFor(source), nil) - if err != nil { - return err - } - p.auth(req) - req.Header.Set("Destination", p.webDAVDestination(target)) - req.Header.Set("Overwrite", "F") - resp, err := p.client.Do(req) - if err != nil { - return decorateDAVTransportError(p.name, p.urlFor(source), err) - } - defer resp.Body.Close() - switch resp.StatusCode { - case http.StatusCreated, http.StatusOK, http.StatusNoContent: - return nil - default: - return p.decorateDAVMutationStatusError(resp, "rename", source) - } -} - -func (p *cloudDrive2Provider) webDAVDestination(target string) string { - raw := p.urlFor(target) - u, err := url.Parse(raw) - if err != nil { - return raw - } - u.RawQuery = "" - u.Fragment = "" - return u.String() -} - -func (p *cloudDrive2Provider) decorateDAVMutationStatusError(resp *http.Response, action, target string) error { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - detail := compactDAVErrorBody(string(body)) - if detail == "" { - return fmt.Errorf("%s: %s %s returned http %d", p.name, action, target, resp.StatusCode) - } - return fmt.Errorf("%s: %s %s returned http %d:%s", p.name, action, target, resp.StatusCode, detail) -} diff --git a/internal/service/cloud/clouddrive2_openlist.go b/internal/service/cloud/clouddrive2_openlist.go deleted file mode 100644 index 6c4e2f4..0000000 --- a/internal/service/cloud/clouddrive2_openlist.go +++ /dev/null @@ -1,240 +0,0 @@ -package cloud - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" -) - -func (p *cloudDrive2Provider) listOpenListAPI(ctx context.Context, dir string) ([]FileEntry, error) { - token, err := p.openListAPIToken(ctx) - if err != nil { - return nil, err - } - const pageSize = 500 - target := normalizeCloudDAVPath(dir) - out := make([]FileEntry, 0, pageSize) - for pageNum := 1; ; pageNum++ { - payload := map[string]any{ - "path": target, - "password": "", - "page": pageNum, - "per_page": pageSize, - "refresh": false, - } - body, _ := json.Marshal(payload) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/list"), bytes.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", p.ua) - if token != "" { - req.Header.Set("Authorization", token) - } - resp, err := p.client.Do(req) - if err != nil { - return nil, decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/list"), err) - } - var decoded openListListResponse - decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&decoded) - resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, p.openListAPIStatusError("list", target, resp.StatusCode) - } - if decodeErr != nil { - return nil, fmt.Errorf("%s: decode api list: %w", p.name, decodeErr) - } - if decoded.Code != 0 && decoded.Code != 200 { - msg := strings.TrimSpace(decoded.Message) - if msg == "" { - msg = fmt.Sprintf("code %d", decoded.Code) - } - return nil, fmt.Errorf("%s: api list %s failed: %s", p.name, target, msg) - } - for _, item := range decoded.Data.Content { - name := strings.TrimSpace(item.Name) - if name == "" || name == "." || name == "/" { - continue - } - out = append(out, FileEntry{ - ID: joinOpenListAPIPath(target, name), - Name: name, - IsDir: item.IsDir, - Size: item.Size, - }) - } - total := decoded.Data.Total - if total > 0 { - if len(out) >= total || len(decoded.Data.Content) == 0 { - break - } - continue - } - if len(decoded.Data.Content) == 0 || len(decoded.Data.Content) < pageSize { - break - } - } - return out, nil -} - -func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, fileRef string) (*DirectLink, error) { - token, err := p.openListAPIToken(ctx) - if err != nil { - return nil, err - } - payload, _ := json.Marshal(map[string]string{"path": normalizeCloudDAVPath(fileRef), "password": ""}) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/get"), bytes.NewReader(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", p.ua) - if token != "" { - req.Header.Set("Authorization", token) - } - resp, err := p.client.Do(req) - if err != nil { - return nil, decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/get"), err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, p.openListAPIStatusError("get", fileRef, resp.StatusCode) - } - var decoded openListGetResponse - if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil { - return nil, fmt.Errorf("%s: decode api get: %w", p.name, err) - } - if decoded.Code != 0 && decoded.Code != 200 { - msg := strings.TrimSpace(decoded.Message) - if msg == "" { - msg = fmt.Sprintf("code %d", decoded.Code) - } - return nil, fmt.Errorf("%s: api get %s failed: %s", p.name, fileRef, msg) - } - raw := firstNonEmpty(decoded.Data.RawURL, decoded.Data.URL) - if raw == "" { - return nil, fmt.Errorf("%s: api get %s returned empty raw_url", p.name, fileRef) - } - resolved, err := p.resolveOpenListPlaybackURL(raw) - if err != nil { - return nil, err - } - headers := normalizeOpenListPlaybackHeaders(decoded.Data.Header) - if len(headers) > 0 { - return nil, fmt.Errorf("%s: api get %s returned raw_url that requires headers (%s); refusing WebDAV/proxy fallback for pure 302 playback", p.name, fileRef, strings.Join(sortedHeaderNames(headers), ",")) - } - resolved, err = p.resolveOpenListCDNRedirect(ctx, fileRef, resolved) - if err != nil { - return nil, err - } - return &DirectLink{URL: resolved, Headers: nil, Proxy: false}, nil -} - -func (p *cloudDrive2Provider) resolveOpenListCDNRedirect(ctx context.Context, fileRef, rawURL string) (string, error) { - if p.apiBase == nil || !sameURLHost(rawURL, p.apiBase) { - return rawURL, nil - } - location, status, err := p.firstHTTPRedirectLocation(ctx, rawURL, nil) - if err != nil { - return "", fmt.Errorf("%s: probe raw_url %s failed: %w", p.name, fileRef, err) - } - if location != "" { - return location, nil - } - return "", fmt.Errorf("%s: api get %s returned an OpenList-hosted raw_url with http %d and no CDN Location; refusing OpenList/WebDAV proxy fallback for pure 302 playback", p.name, fileRef, status) -} - -func (p *cloudDrive2Provider) openListAPIStatusError(action, target string, status int) error { - if status == http.StatusUnauthorized || status == http.StatusForbidden { - return fmt.Errorf("%s: api %s %s returned http %d;请检查 OpenList Token 或用户名密码,并确认填写的是 OpenList 服务地址而不是 /dav 地址", p.name, action, target, status) - } - return fmt.Errorf("%s: api %s %s returned http %d", p.name, action, target, status) -} - -func (p *cloudDrive2Provider) hasOpenListAPICredentials() bool { - return strings.TrimSpace(p.token) != "" || (strings.TrimSpace(p.username) != "" && p.password != "") -} - -func (p *cloudDrive2Provider) openListAPIToken(ctx context.Context) (string, error) { - if token := strings.TrimSpace(p.token); token != "" { - return token, nil - } - if strings.TrimSpace(p.username) == "" || p.password == "" { - return "", nil - } - payload, _ := json.Marshal(map[string]string{ - "username": p.username, - "password": p.password, - }) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/auth/login"), bytes.NewReader(payload)) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", p.ua) - resp, err := p.client.Do(req) - if err != nil { - return "", decorateDAVTransportError(p.name, p.openListAPIURL("/api/auth/login"), err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("%s: api login returned http %d", p.name, resp.StatusCode) - } - var decoded openListLoginResponse - if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil { - return "", fmt.Errorf("%s: decode api login: %w", p.name, err) - } - if decoded.Code != 0 && decoded.Code != 200 { - msg := strings.TrimSpace(decoded.Message) - if msg == "" { - msg = fmt.Sprintf("code %d", decoded.Code) - } - return "", fmt.Errorf("%s: api login failed: %s", p.name, msg) - } - token := strings.TrimSpace(decoded.Data.Token) - if token == "" { - return "", fmt.Errorf("%s: api login returned empty token", p.name) - } - p.token = token - return token, nil -} - -func (p *cloudDrive2Provider) resolveOpenListPlaybackURL(raw string) (string, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return "", fmt.Errorf("%s: empty playback URL", p.name) - } - if strings.HasPrefix(raw, "//") { - if p.apiBase == nil || p.apiBase.Scheme == "" { - return "", fmt.Errorf("%s: protocol-relative playback URL without API base", p.name) - } - raw = p.apiBase.Scheme + ":" + raw - } - u, err := url.Parse(raw) - if err != nil { - return "", fmt.Errorf("%s: invalid playback URL: %w", p.name, err) - } - if u.IsAbs() { - if u.Scheme != "http" && u.Scheme != "https" { - return "", fmt.Errorf("%s: unsupported playback URL scheme %q", p.name, u.Scheme) - } - return u.String(), nil - } - if p.apiBase == nil { - return "", fmt.Errorf("%s: relative playback URL without API base", p.name) - } - base := *p.apiBase - base.RawPath = "" - base.RawQuery = "" - base.Fragment = "" - return base.ResolveReference(u).String(), nil -} diff --git a/internal/service/cloud/clouddrive2_openlist_helpers.go b/internal/service/cloud/clouddrive2_openlist_helpers.go deleted file mode 100644 index d5d1880..0000000 --- a/internal/service/cloud/clouddrive2_openlist_helpers.go +++ /dev/null @@ -1,126 +0,0 @@ -package cloud - -import ( - "encoding/json" - "net/url" - "path" - "sort" - "strings" -) - -func sortedHeaderNames(headers map[string]string) []string { - if len(headers) == 0 { - return nil - } - out := make([]string, 0, len(headers)) - for key := range headers { - key = strings.TrimSpace(key) - if key != "" { - out = append(out, key) - } - } - sort.Strings(out) - return out -} - -func sameURLHost(raw string, base *url.URL) bool { - if base == nil { - return false - } - u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil { - return false - } - if !u.IsAbs() { - return true - } - return strings.EqualFold(u.Host, base.Host) -} - -func normalizeOpenListPlaybackHeaders(raw json.RawMessage) map[string]string { - if len(raw) == 0 || string(raw) == "null" { - return nil - } - var obj map[string]any - if err := json.Unmarshal(raw, &obj); err != nil { - return nil - } - out := make(map[string]string, len(obj)) - for k, v := range obj { - key := strings.TrimSpace(k) - if key == "" { - continue - } - switch value := v.(type) { - case string: - if strings.TrimSpace(value) != "" { - out[key] = strings.TrimSpace(value) - } - case []any: - parts := make([]string, 0, len(value)) - for _, item := range value { - if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { - parts = append(parts, strings.TrimSpace(s)) - } - } - if len(parts) > 0 { - out[key] = strings.Join(parts, ", ") - } - } - } - if len(out) == 0 { - return nil - } - return out -} - -func isCloudVideoPlaybackCandidate(fileRef string) bool { - switch strings.ToLower(path.Ext(strings.TrimSpace(fileRef))) { - case ".mkv", ".mp4", ".m4v", ".avi", ".mov", ".webm", ".ts", ".rmvb", ".rm", ".3gp", ".mpg", ".mpeg": - return true - default: - return false - } -} - -type openListListResponse struct { - Code int `json:"code"` - Message string `json:"message"` - Data struct { - Content []openListListItem `json:"content"` - Total int `json:"total"` - } `json:"data"` -} - -type openListListItem struct { - Name string `json:"name"` - Size int64 `json:"size"` - IsDir bool `json:"is_dir"` -} - -type openListGetResponse struct { - Code int `json:"code"` - Message string `json:"message"` - Data struct { - RawURL string `json:"raw_url"` - URL string `json:"url"` - Header json.RawMessage `json:"header"` - } `json:"data"` -} - -type openListLoginResponse struct { - Code int `json:"code"` - Message string `json:"message"` - Data struct { - Token string `json:"token"` - } `json:"data"` -} - -func joinOpenListAPIPath(dir, name string) string { - dir = strings.TrimRight(normalizeCloudDAVPath(dir), "/") - name = strings.Trim(strings.ReplaceAll(name, "\\", "/"), "/") - if dir == "" || dir == "/" { - return normalizeCloudDAVPath(name) - } - return normalizeCloudDAVPath(dir + "/" + name) -} diff --git a/internal/service/cloud/clouddrive2_test.go b/internal/service/cloud/clouddrive2_test.go deleted file mode 100644 index 78cb264..0000000 --- a/internal/service/cloud/clouddrive2_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package cloud - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestCloudDrive2WebDAVListAndResolve(t *testing.T) { - var gotAuth, gotDepth, gotRange string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "PROPFIND" && r.URL.Path == "/dav": - gotAuth = r.Header.Get("Authorization") - gotDepth = r.Header.Get("Depth") - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - _, _ = w.Write([]byte(` - - - /dav/ - - - - /dav/115/ - 115 - - - /dav/123/Movie.mkv - Movie.mkv789 - -`)) - case r.Method == http.MethodGet && r.URL.Path == "/dav/123/Movie.mkv": - gotAuth = r.Header.Get("Authorization") - gotRange = r.Header.Get("Range") - http.Redirect(w, r, "https://cdn.example.test/123/Movie.mkv?sign=1", http.StatusFound) - default: - t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeCloudDrive2, map[string]any{"url": srv.URL + "/dav", "username": "u", "password": "p"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - entries, err := p.List(context.Background(), "") - if err != nil { - t.Fatalf("list: %v", err) - } - if gotDepth != "1" { - t.Fatalf("Depth = %q, want 1", gotDepth) - } - if !strings.HasPrefix(gotAuth, "Basic ") { - t.Fatalf("missing basic auth: %q", gotAuth) - } - if len(entries) != 2 { - t.Fatalf("entries = %#v", entries) - } - if !entries[0].IsDir || entries[0].ID != "/115" { - t.Fatalf("dir entry wrong: %#v", entries[0]) - } - if entries[1].IsDir || entries[1].ID != "/123/Movie.mkv" || entries[1].Size != 789 { - t.Fatalf("file entry wrong: %#v", entries[1]) - } - link, err := p.Resolve(context.Background(), entries[1].ID) - if err != nil { - t.Fatalf("resolve: %v", err) - } - if link.URL != "https://cdn.example.test/123/Movie.mkv?sign=1" { - t.Fatalf("bad url: %s", link.URL) - } - if link.Proxy || len(link.Headers) != 0 { - t.Fatalf("clouddrive2 video should resolve to pure 302 link: %#v", link) - } - if gotRange != "bytes=0-0" { - t.Fatalf("resolve should probe with a tiny range, got %q", gotRange) - } -} - -func TestCloudDrive2ResolveRejectsWebDAVProxyFallbackWithoutRedirect(t *testing.T) { - var getSeen bool - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "PROPFIND" && r.URL.Path == "/dav": - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - _, _ = w.Write([]byte(`/dav/`)) - case r.Method == http.MethodGet && r.URL.Path == "/dav/123/Movie.mkv": - getSeen = true - w.Header().Set("Content-Range", "bytes 0-0/10") - w.WriteHeader(http.StatusPartialContent) - _, _ = w.Write([]byte("x")) - default: - t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeCloudDrive2, map[string]any{"url": srv.URL + "/dav", "username": "u", "password": "p"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - _, err = p.Resolve(context.Background(), "/123/Movie.mkv") - if err == nil || !strings.Contains(err.Error(), "without CDN Location") || !strings.Contains(err.Error(), "refusing WebDAV/proxy fallback") { - t.Fatalf("resolve error = %v, want pure 302 refusal", err) - } - if !getSeen { - t.Fatal("expected CloudDrive2 WebDAV direct-link probe") - } -} - -func TestCloudDrive2MutableProviderUsesWebDAV(t *testing.T) { - var mkcolSeen bool - var destinations []string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "MKCOL" && r.URL.Path == "/dav/TV": - mkcolSeen = true - w.WriteHeader(http.StatusCreated) - case r.Method == "MOVE" && r.URL.Path == "/dav/TV": - destinations = append(destinations, r.Header.Get("Destination")) - if r.Header.Get("Overwrite") != "F" { - t.Fatalf("Overwrite = %q, want F", r.Header.Get("Overwrite")) - } - w.WriteHeader(http.StatusCreated) - case r.Method == "MOVE" && r.URL.Path == "/dav/Inbox/Movie.mkv": - destinations = append(destinations, r.Header.Get("Destination")) - if r.Header.Get("Overwrite") != "F" { - t.Fatalf("Overwrite = %q, want F", r.Header.Get("Overwrite")) - } - w.WriteHeader(http.StatusCreated) - default: - t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeCloudDrive2, map[string]any{"url": srv.URL + "/dav", "username": "u", "password": "p"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - mutable, ok := p.(MutableProvider) - if !ok { - t.Fatal("clouddrive2 should support mutable provider") - } - if _, err := mutable.Mkdir(context.Background(), "", "TV"); err != nil { - t.Fatalf("mkdir: %v", err) - } - if _, err := mutable.Rename(context.Background(), "/TV", "电视剧"); err != nil { - t.Fatalf("rename: %v", err) - } - moved, err := mutable.(MovableProvider).Move(context.Background(), "/Inbox/Movie.mkv", "/电影/欧美电影/Movie (2026)", "Movie (2026).mkv") - if err != nil { - t.Fatalf("move: %v", err) - } - if !mkcolSeen || len(destinations) != 2 { - t.Fatalf("mkcol=%v destinations=%#v, want mkdir and two MOVE calls", mkcolSeen, destinations) - } - if destinations[0] != srv.URL+"/dav/%E7%94%B5%E8%A7%86%E5%89%A7" { - t.Fatalf("rename Destination = %q", destinations[0]) - } - if destinations[1] != srv.URL+"/dav/%E7%94%B5%E5%BD%B1/%E6%AC%A7%E7%BE%8E%E7%94%B5%E5%BD%B1/Movie%20%282026%29/Movie%20%282026%29.mkv" { - t.Fatalf("move Destination = %q", destinations[1]) - } - if moved.ID != "/电影/欧美电影/Movie (2026)/Movie (2026).mkv" { - t.Fatalf("moved entry = %#v", moved) - } -} diff --git a/internal/service/cloud/openlist_list_test.go b/internal/service/cloud/openlist_list_test.go deleted file mode 100644 index 7dd6164..0000000 --- a/internal/service/cloud/openlist_list_test.go +++ /dev/null @@ -1,280 +0,0 @@ -package cloud - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestOpenListWebDAVListAndResolve(t *testing.T) { - var gotPath, gotDepth string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/auth/login" { - http.NotFound(w, r) - return - } - if r.URL.Path == "/api/fs/get" { - http.NotFound(w, r) - return - } - if r.Method != "PROPFIND" || r.URL.Path != "/dav" { - t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) - } - gotPath = r.URL.Path - gotDepth = r.Header.Get("Depth") - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - _, _ = w.Write([]byte(` - - - /dav/ - - - - /dav/Cloud/Movie.mkv - Movie.mkv1024 - -`)) - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"url": srv.URL + "/dav"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - if p.Type() != TypeOpenList { - t.Fatalf("type = %q, want %q", p.Type(), TypeOpenList) - } - entries, err := p.List(context.Background(), "") - if err != nil { - t.Fatalf("list: %v", err) - } - if gotPath != "/dav" { - t.Fatalf("path = %q, want /dav", gotPath) - } - if gotDepth != "1" { - t.Fatalf("Depth = %q, want 1", gotDepth) - } - if len(entries) != 1 || entries[0].ID != "/Cloud/Movie.mkv" || entries[0].Size != 1024 { - t.Fatalf("entries = %#v", entries) - } - _, err = p.Resolve(context.Background(), entries[0].ID) - if err == nil || !strings.Contains(err.Error(), "pure 302 playback requires OpenList raw_url") { - t.Fatalf("openlist video resolve should require raw_url instead of WebDAV proxy fallback, err=%v", err) - } -} - -func TestOpenListListUsesAPIUsernamePasswordWithoutWebDAVFallback(t *testing.T) { - var loginSeen, listSeen, davSeen bool - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/auth/login": - loginSeen = true - var body map[string]string - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode login body: %v", err) - } - if body["username"] != "alice" || body["password"] != "secret" { - t.Fatalf("login body = %#v", body) - } - _, _ = w.Write([]byte(`{"code":200,"data":{"token":"api-token"}}`)) - case "/api/fs/list": - listSeen = true - if r.Header.Get("Authorization") != "api-token" { - t.Fatalf("Authorization = %q, want api-token", r.Header.Get("Authorization")) - } - _, _ = w.Write([]byte(`{"code":200,"data":{"content":[{"name":"Movies","is_dir":true,"size":0},{"name":"Movie.mkv","is_dir":false,"size":1024}],"total":2}}`)) - case "/dav": - davSeen = true - w.WriteHeader(http.StatusMultiStatus) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "alice", "password": "secret"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - entries, err := p.List(context.Background(), "") - if err != nil { - t.Fatalf("list: %v", err) - } - if !loginSeen || !listSeen { - t.Fatalf("expected api login/list, login=%v list=%v", loginSeen, listSeen) - } - if davSeen { - t.Fatal("openlist API credentials should not fall back to WebDAV") - } - if len(entries) != 2 || entries[0].ID != "/Movies" || !entries[0].IsDir || entries[1].ID != "/Movie.mkv" || entries[1].Size != 1024 { - t.Fatalf("entries = %#v", entries) - } -} - -func TestOpenListMutableProviderUsesAPI(t *testing.T) { - var mkdirPath, renamePath, renameName, moveSrcDir, moveDstDir string - var moveNames []string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/fs/mkdir": - var body map[string]string - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode mkdir body: %v", err) - } - mkdirPath = body["path"] - if r.Header.Get("Authorization") != "alist-token" { - t.Fatalf("mkdir Authorization = %q", r.Header.Get("Authorization")) - } - _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) - case "/api/fs/rename": - var body map[string]string - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode rename body: %v", err) - } - renamePath = body["path"] - renameName = body["name"] - _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) - case "/api/fs/move": - var body struct { - SrcDir string `json:"src_dir"` - DstDir string `json:"dst_dir"` - Names []string `json:"names"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode move body: %v", err) - } - moveSrcDir = body.SrcDir - moveDstDir = body.DstDir - moveNames = body.Names - _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - mutable, ok := p.(MutableProvider) - if !ok { - t.Fatal("openlist should support mutable provider") - } - created, err := mutable.Mkdir(context.Background(), "/电视剧", "欧美剧") - if err != nil { - t.Fatalf("mkdir: %v", err) - } - if mkdirPath != "/电视剧/欧美剧" || created.ID != "/电视剧/欧美剧" || !created.IsDir { - t.Fatalf("mkdir path=%q entry=%#v", mkdirPath, created) - } - renamed, err := mutable.Rename(context.Background(), "/电视剧/欧美剧", "美剧") - if err != nil { - t.Fatalf("rename: %v", err) - } - if renamePath != "/电视剧/欧美剧" || renameName != "美剧" || renamed.ID != "/电视剧/美剧" { - t.Fatalf("rename path=%q name=%q entry=%#v", renamePath, renameName, renamed) - } - moved, err := mutable.(MovableProvider).Move(context.Background(), "/待整理/Show.S01E01.mkv", "/动漫/国漫/Show/Season 01", "Show - S01E01.mkv") - if err != nil { - t.Fatalf("move: %v", err) - } - if moveSrcDir != "/待整理" || moveDstDir != "/动漫/国漫/Show/Season 01" || len(moveNames) != 1 || moveNames[0] != "Show.S01E01.mkv" { - t.Fatalf("move src=%q dst=%q names=%#v", moveSrcDir, moveDstDir, moveNames) - } - if renamePath != "/动漫/国漫/Show/Season 01/Show.S01E01.mkv" || renameName != "Show - S01E01.mkv" { - t.Fatalf("post-move rename path=%q name=%q", renamePath, renameName) - } - if moved.ID != "/动漫/国漫/Show/Season 01/Show - S01E01.mkv" { - t.Fatalf("moved entry = %#v", moved) - } -} - -func TestOpenListListAPIFailureDoesNotFallbackToWebDAV(t *testing.T) { - var davSeen bool - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/auth/login": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":500,"message":"bad password"}`)) - case "/dav": - davSeen = true - w.WriteHeader(http.StatusMultiStatus) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "alice", "password": "bad"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - _, err = p.List(context.Background(), "") - if err == nil || !strings.Contains(err.Error(), "api login failed") || !strings.Contains(err.Error(), "bad password") { - t.Fatalf("list error = %v, want api login failure", err) - } - if davSeen { - t.Fatal("openlist API failure fell back to WebDAV") - } -} - -func TestOpenListRootURLDefaultsToDAV(t *testing.T) { - var gotPath string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - _, _ = w.Write([]byte(`/dav/`)) - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"url": srv.URL + "/"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - if _, err := p.List(context.Background(), ""); err != nil { - t.Fatalf("list: %v", err) - } - if gotPath != "/dav" { - t.Fatalf("path = %q, want /dav", gotPath) - } -} - -func TestOpenListURLForKeepsNonASCIIPathSingleEncoded(t *testing.T) { - p := newOpenList(map[string]any{"url": "http://example.test:5244/dav/"}, nil) - got := p.urlFor("/动画电影/爱宠大机密2 (2019) {tmdb-412117}") - if strings.Contains(got, "%25E") { - t.Fatalf("url is double-escaped: %s", got) - } - want := "http://example.test:5244/dav/%E5%8A%A8%E7%94%BB%E7%94%B5%E5%BD%B1/%E7%88%B1%E5%AE%A0%E5%A4%A7%E6%9C%BA%E5%AF%862%20%282019%29%20%7Btmdb-412117%7D" - if got != want { - t.Fatalf("url = %s, want %s", got, want) - } -} - -func TestOpenListDAVStatusErrorIncludesBodyHint(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusMethodNotAllowed) - _, _ = w.Write([]byte("请先填写有效Cookie并保存")) - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"url": srv.URL + "/dav"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - _, err = p.List(context.Background(), "") - if err == nil { - t.Fatal("want error") - } - if !strings.Contains(err.Error(), "请先填写有效Cookie并保存") || !strings.Contains(err.Error(), "WebDAV 地址") { - t.Fatalf("unexpected error: %v", err) - } -} diff --git a/internal/service/cloud/openlist_resolve_test.go b/internal/service/cloud/openlist_resolve_test.go deleted file mode 100644 index 2b9b976..0000000 --- a/internal/service/cloud/openlist_resolve_test.go +++ /dev/null @@ -1,204 +0,0 @@ -package cloud - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestOpenListResolveUsesAPIRawURLFor302Playback(t *testing.T) { - var gotPath, gotAuth string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - gotAuth = r.Header.Get("Authorization") - if r.Method != http.MethodPost || r.URL.Path != "/api/fs/get" { - t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"https://cdn.example.test/movie.mkv?sign=1"}}`)) - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv") - if err != nil { - t.Fatalf("resolve: %v", err) - } - if gotPath != "/api/fs/get" { - t.Fatalf("api path = %q, want /api/fs/get", gotPath) - } - if gotAuth != "alist-token" { - t.Fatalf("Authorization = %q, want token", gotAuth) - } - if link.URL != "https://cdn.example.test/movie.mkv?sign=1" { - t.Fatalf("url = %q", link.URL) - } - if link.Proxy { - t.Fatalf("openlist raw_url without required headers should be 302 playback") - } -} - -func TestOpenListResolveCollapsesHostedRawURLRedirectToCDN(t *testing.T) { - var probeSeen bool - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/fs/get": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"/d/Cloud/Movie.mkv?sign=1"}}`)) - case "/d/Cloud/Movie.mkv": - probeSeen = true - if r.Header.Get("Range") != "bytes=0-0" { - t.Fatalf("probe Range = %q", r.Header.Get("Range")) - } - http.Redirect(w, r, "https://cdn.example.test/movie.mkv?sign=cdn", http.StatusFound) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv") - if err != nil { - t.Fatalf("resolve: %v", err) - } - if !probeSeen { - t.Fatal("expected OpenList-hosted raw_url probe") - } - if link.URL != "https://cdn.example.test/movie.mkv?sign=cdn" || link.Proxy || len(link.Headers) != 0 { - t.Fatalf("link = %#v, want collapsed CDN 302 playback", link) - } -} - -func TestOpenListResolveLogsInWithUsernamePasswordForAPIRawURL(t *testing.T) { - var loginSeen bool - var gotAuth string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/auth/login": - loginSeen = true - var body map[string]string - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode login body: %v", err) - } - if body["username"] != "alice" || body["password"] != "secret" { - t.Fatalf("login body = %#v", body) - } - _, _ = w.Write([]byte(`{"code":200,"data":{"token":"api-token"}}`)) - case "/api/fs/get": - gotAuth = r.Header.Get("Authorization") - _, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"https://cdn.example.test/movie.mkv?sign=1"}}`)) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "username": "alice", "password": "secret"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv") - if err != nil { - t.Fatalf("resolve: %v", err) - } - if !loginSeen { - t.Fatalf("expected api login before fs/get") - } - if gotAuth != "api-token" { - t.Fatalf("Authorization = %q, want api-token", gotAuth) - } - if link.URL != "https://cdn.example.test/movie.mkv?sign=1" || link.Proxy { - t.Fatalf("link = %#v, want raw_url 302 playback", link) - } -} - -func TestOpenListResolveRejectsProxyWhenAPIRawURLNeedsHeaders(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/fs/get" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"/dav/Cloud/Movie.mkv","header":{"Cookie":"sid=abc"}}}`)) - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - _, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv") - if err == nil || !strings.Contains(err.Error(), "refusing WebDAV/proxy fallback") || !strings.Contains(err.Error(), "Cookie") { - t.Fatalf("resolve error = %v, want pure 302 refusal with header names", err) - } -} - -func TestOpenListResolveRejectsHostedRawURLWithoutCDNRedirect(t *testing.T) { - var probeSeen bool - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/fs/get": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"/d/Cloud/Movie.mkv?sign=1"}}`)) - case "/d/Cloud/Movie.mkv": - probeSeen = true - w.Header().Set("Content-Range", "bytes 0-0/10") - w.WriteHeader(http.StatusPartialContent) - _, _ = w.Write([]byte("x")) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - _, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv") - if err == nil || !strings.Contains(err.Error(), "OpenList-hosted raw_url") || !strings.Contains(err.Error(), "no CDN Location") { - t.Fatalf("resolve error = %v, want hosted raw_url refusal", err) - } - if !probeSeen { - t.Fatal("expected OpenList-hosted raw_url probe") - } -} - -func TestOpenListResolveDoesNotFallbackToWebDAVWhenAPIRawURLFails(t *testing.T) { - var davSeen bool - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/fs/get": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":500,"message":"driver cannot provide raw_url"}`)) - case "/dav/Cloud/Movie.mkv": - davSeen = true - w.WriteHeader(http.StatusOK) - default: - t.Fatalf("unexpected path %s", r.URL.Path) - } - })) - defer srv.Close() - - p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client()) - if err != nil { - t.Fatal(err) - } - _, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv") - if err == nil || !strings.Contains(err.Error(), "pure 302 playback requires OpenList raw_url") { - t.Fatalf("resolve error = %v, want raw_url requirement", err) - } - if davSeen { - t.Fatal("openlist video resolve fell back to WebDAV after raw_url failure") - } -} diff --git a/internal/service/cloud/pan115.go b/internal/service/cloud/pan115.go deleted file mode 100644 index b92194f..0000000 --- a/internal/service/cloud/pan115.go +++ /dev/null @@ -1,237 +0,0 @@ -package cloud - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" -) - -// pan115Provider implements 115 网盘 via cookie auth. -// -// 115 has removed its desktop clients, so cookies must come from the mobile -// app / web (115.com) or a QR-code login (see QR* helpers below). Directory -// listing uses the public web API; download resolves a file's pickcode to a -// CDN URL that, like Alist's default 115 behaviour, is served by 302 redirect. -type pan115Provider struct { - cookie string - ua string - webBase string // https://webapi.115.com (override in tests) - proBase string // https://proapi.115.com (override in tests) - client *http.Client - proxy bool - - // downURLPayload fetches and decrypts the app/chrome/downurl response for a - // pickcode, returning the raw JSON payload (map of file id -> info). It is a - // seam so tests can bypass the live 115 crypto/transport. - downURLPayload func(ctx context.Context, pickcode string) ([]byte, error) -} - -const ( - pan115WebBase = "https://webapi.115.com" - pan115ProBase = "https://proapi.115.com" -) - -func new115(cfg map[string]any, client *http.Client) *pan115Provider { - web := str(cfg["base"]) - if web == "" { - web = pan115WebBase - } - ua := str(cfg["ua"]) - if ua == "" { - ua = defaultUA - } - // 115 CDN download URLs work with a plain 302 (Alist's recommended mode), - // so offload by default. The global cloud playback setting decides whether - // clients receive a STRMURL entry or a /Videos stream entry. - proxy := false - pro := str(cfg["pro_base"]) - if pro == "" { - pro = pan115ProBase - } - p := &pan115Provider{ - cookie: str(cfg["cookie"]), - ua: ua, - webBase: strings.TrimRight(web, "/"), - proBase: strings.TrimRight(pro, "/"), - client: client, - proxy: proxy, - } - p.downURLPayload = p.fetchDownURLPayload - return p -} - -func (p *pan115Provider) Type() string { return Type115 } - -func (p *pan115Provider) get(ctx context.Context, u string) (*http.Response, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, err - } - req.Header.Set("Cookie", p.cookie) - req.Header.Set("User-Agent", p.ua) - req.Header.Set("Accept", "application/json, text/plain, */*") - return p.client.Do(req) -} - -func (p *pan115Provider) Ping(ctx context.Context) error { - if p.cookie == "" { - return fmt.Errorf("115: missing cookie") - } - _, err := p.List(ctx, "0") - return err -} - -func (p *pan115Provider) List(ctx context.Context, dirID string) ([]FileEntry, error) { - if dirID == "" { - dirID = "0" - } - const pageSize = 100 - out := make([]FileEntry, 0, pageSize) - for offset := 0; ; offset += pageSize { - q := url.Values{} - q.Set("aid", "1") - q.Set("cid", dirID) - q.Set("o", "user_ptime") - q.Set("asc", "0") - q.Set("offset", strconv.Itoa(offset)) - q.Set("show_dir", "1") - q.Set("limit", strconv.Itoa(pageSize)) - q.Set("format", "json") - resp, err := p.get(ctx, p.webBase+"/files?"+q.Encode()) - if err != nil { - return nil, err - } - var r struct { - State bool `json:"state"` - Error string `json:"error"` - Data []struct { - Fid string `json:"fid"` // file id (files only) - Cid string `json:"cid"` // category id (dirs use this) - N string `json:"n"` // name - S json.Number `json:"s"` // size - Pc string `json:"pc"` // pickcode - } `json:"data"` - } - err = json.NewDecoder(resp.Body).Decode(&r) - _ = resp.Body.Close() - if err != nil { - return nil, fmt.Errorf("115: decode list: %w", err) - } - if !r.State { - return nil, fmt.Errorf("115: list failed: %s", r.Error) - } - for _, it := range r.Data { - isDir := it.Fid == "" - id := it.Fid - if isDir { - id = it.Cid - } - size, _ := it.S.Int64() - out = append(out, FileEntry{ - ID: id, - Name: it.N, - IsDir: isDir, - Size: size, - PickCode: it.Pc, - }) - } - if len(r.Data) < pageSize { - break - } - } - return out, nil -} - -// Resolve accepts a pickcode (preferred) and returns the CDN download URL. -// -// 115 deprecated the plain web /files/download endpoint (it no longer returns -// file_url for ordinary cookies). We use the current app/chrome/downurl -// endpoint, which takes an m115-encrypted body and returns an m115-encrypted -// payload mapping the file id to a short-lived, OSS-signed CDN URL suitable for -// a 302 redirect (the same approach Alist's 115 driver uses). -func (p *pan115Provider) Resolve(ctx context.Context, pickcode string) (*DirectLink, error) { - if pickcode == "" { - return nil, fmt.Errorf("115: empty pickcode") - } - raw, err := p.downURLPayload(ctx, pickcode) - if err != nil { - return nil, err - } - var payload map[string]struct { - FileName string `json:"file_name"` - FileSize json.Number `json:"file_size"` - URL struct { - URL string `json:"url"` - } `json:"url"` - } - if err := json.Unmarshal(raw, &payload); err != nil { - return nil, fmt.Errorf("115: decode downurl: %w", err) - } - for _, info := range payload { - if info.URL.URL == "" { - continue - } - return &DirectLink{ - URL: info.URL.URL, - Headers: map[string]string{ - "User-Agent": p.ua, - "Cookie": p.cookie, - }, - Proxy: p.proxy, - }, nil - } - return nil, fmt.Errorf("115: download failed: no url") -} - -// fetchDownURLPayload performs the live encrypted app/chrome/downurl request and -// returns the decrypted JSON payload. -func (p *pan115Provider) fetchDownURLPayload(ctx context.Context, pickcode string) ([]byte, error) { - key := m115GenerateKey() - params, err := json.Marshal(map[string]string{"pickcode": pickcode}) - if err != nil { - return nil, err - } - form := url.Values{} - form.Set("data", m115Encode(params, key)) - u := fmt.Sprintf("%s/app/chrome/downurl?t=%d", p.proBase, nowUnix()) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(form.Encode())) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Cookie", p.cookie) - req.Header.Set("User-Agent", p.ua) - req.Header.Set("Accept", "application/json, text/plain, */*") - resp, err := p.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - var r struct { - State bool `json:"state"` - Error string `json:"error"` - Data string `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return nil, fmt.Errorf("115: decode downurl: %w", err) - } - if !r.State || r.Data == "" { - msg := r.Error - if msg == "" { - msg = "no data" - } - return nil, fmt.Errorf("115: download failed: %s", msg) - } - out, err := m115Decode(r.Data, key) - if err != nil { - return nil, fmt.Errorf("115: decrypt downurl: %w", err) - } - return out, nil -} - -// nowUnix is a seam for deterministic tests. -var nowUnix = func() int64 { return timeNow().Unix() } diff --git a/internal/service/cloud/pan115_crypto.go b/internal/service/cloud/pan115_crypto.go deleted file mode 100644 index 01ee8e9..0000000 --- a/internal/service/cloud/pan115_crypto.go +++ /dev/null @@ -1,184 +0,0 @@ -package cloud - -// 115 网盘的 app/chrome/downurl 下载接口要求请求体使用 115 自有的 "m115" 加密协议 -// (RSA + XOR 混淆),返回的直链也以同样方式加密。普通 web cookie 调用旧的 -// /files/download 接口已不再返回 file_url,必须改用该加密接口。 -// -// 下面的实现移植自 MIT 许可的 github.com/SheltonZhu/115driver -// (pkg/crypto/m115),alist 等项目同样采用此实现。仅做最小改动:函数加 m115 -// 前缀以归入本包命名空间。 -// -// Copyright (c) 115driver authors. MIT License. - -import ( - "bytes" - "crypto/rand" - "encoding/base64" - "io" - "math/big" -) - -// m115Key is the random 16-byte session key generated per request. -type m115Key [16]byte - -func m115GenerateKey() m115Key { - key := m115Key{} - _, _ = io.ReadFull(rand.Reader, key[:]) - return key -} - -// m115Encode encrypts request input for the downurl endpoint. -func m115Encode(input []byte, key m115Key) string { - buf := make([]byte, 16+len(input)) - copy(buf, key[:]) - copy(buf[16:], input) - m115XORTransform(buf[16:], m115XORDeriveKey(key[:], 4)) - m115ReverseBytes(buf[16:]) - m115XORTransform(buf[16:], m115XORClientKey) - return base64.StdEncoding.EncodeToString(m115RSAEncrypt(buf)) -} - -// m115Decode decrypts the base64 response payload using the request key. -func m115Decode(input string, key m115Key) ([]byte, error) { - data, err := base64.StdEncoding.DecodeString(input) - if err != nil { - return nil, err - } - data = m115RSADecrypt(data) - output := make([]byte, len(data)-16) - copy(output, data[16:]) - m115XORTransform(output, m115XORDeriveKey(data[:16], 12)) - m115ReverseBytes(output) - m115XORTransform(output, m115XORDeriveKey(key[:], 4)) - return output, nil -} - -func m115ReverseBytes(data []byte) { - for i, j := 0, len(data)-1; i < j; i, j = i+1, j-1 { - data[i], data[j] = data[j], data[i] - } -} - -// --- RSA layer --- - -var ( - m115N, _ = big.NewInt(0).SetString( - "8686980c0f5a24c4b9d43020cd2c22703ff3f450756529058b1cf88f09b86021"+ - "36477198a6e2683149659bd122c33592fdb5ad47944ad1ea4d36c6b172aad633"+ - "8c3bb6ac6227502d010993ac967d1aef00f0c8e038de2e4d3bc2ec368af2e9f1"+ - "0a6f1eda4f7262f136420c07c331b871bf139f74f3010e3c4fe57df3afb71683", 16) - m115E, _ = big.NewInt(0).SetString("10001", 16) - - m115KeyLength = m115N.BitLen() / 8 -) - -func m115RSAEncrypt(input []byte) []byte { - buf := &bytes.Buffer{} - for remainSize := len(input); remainSize > 0; { - sliceSize := m115KeyLength - 11 - if sliceSize > remainSize { - sliceSize = remainSize - } - m115RSAEncryptSlice(input[:sliceSize], buf) - input = input[sliceSize:] - remainSize -= sliceSize - } - return buf.Bytes() -} - -func m115RSAEncryptSlice(input []byte, w io.Writer) { - padSize := m115KeyLength - len(input) - 3 - padData := make([]byte, padSize) - _, _ = rand.Read(padData) - buf := make([]byte, m115KeyLength) - buf[0], buf[1] = 0, 2 - for i, b := range padData { - buf[2+i] = b%0xff + 0x01 - } - buf[padSize+2] = 0 - copy(buf[padSize+3:], input) - msg := big.NewInt(0).SetBytes(buf) - ret := big.NewInt(0).Exp(msg, m115E, m115N).Bytes() - if fillSize := m115KeyLength - len(ret); fillSize > 0 { - zeros := make([]byte, fillSize) - _, _ = w.Write(zeros) - } - _, _ = w.Write(ret) -} - -func m115RSADecrypt(input []byte) []byte { - buf := &bytes.Buffer{} - for remainSize := len(input); remainSize > 0; { - sliceSize := m115KeyLength - if sliceSize > remainSize { - sliceSize = remainSize - } - m115RSADecryptSlice(input[:sliceSize], buf) - input = input[sliceSize:] - remainSize -= sliceSize - } - return buf.Bytes() -} - -func m115RSADecryptSlice(input []byte, w io.Writer) { - msg := big.NewInt(0).SetBytes(input) - ret := big.NewInt(0).Exp(msg, m115E, m115N).Bytes() - for i, b := range ret { - if b == 0 && i != 0 { - _, _ = w.Write(ret[i+1:]) - break - } - } -} - -// --- XOR layer --- - -var ( - m115XORKeySeed = []byte{ - 0xf0, 0xe5, 0x69, 0xae, 0xbf, 0xdc, 0xbf, 0x8a, - 0x1a, 0x45, 0xe8, 0xbe, 0x7d, 0xa6, 0x73, 0xb8, - 0xde, 0x8f, 0xe7, 0xc4, 0x45, 0xda, 0x86, 0xc4, - 0x9b, 0x64, 0x8b, 0x14, 0x6a, 0xb4, 0xf1, 0xaa, - 0x38, 0x01, 0x35, 0x9e, 0x26, 0x69, 0x2c, 0x86, - 0x00, 0x6b, 0x4f, 0xa5, 0x36, 0x34, 0x62, 0xa6, - 0x2a, 0x96, 0x68, 0x18, 0xf2, 0x4a, 0xfd, 0xbd, - 0x6b, 0x97, 0x8f, 0x4d, 0x8f, 0x89, 0x13, 0xb7, - 0x6c, 0x8e, 0x93, 0xed, 0x0e, 0x0d, 0x48, 0x3e, - 0xd7, 0x2f, 0x88, 0xd8, 0xfe, 0xfe, 0x7e, 0x86, - 0x50, 0x95, 0x4f, 0xd1, 0xeb, 0x83, 0x26, 0x34, - 0xdb, 0x66, 0x7b, 0x9c, 0x7e, 0x9d, 0x7a, 0x81, - 0x32, 0xea, 0xb6, 0x33, 0xde, 0x3a, 0xa9, 0x59, - 0x34, 0x66, 0x3b, 0xaa, 0xba, 0x81, 0x60, 0x48, - 0xb9, 0xd5, 0x81, 0x9c, 0xf8, 0x6c, 0x84, 0x77, - 0xff, 0x54, 0x78, 0x26, 0x5f, 0xbe, 0xe8, 0x1e, - 0x36, 0x9f, 0x34, 0x80, 0x5c, 0x45, 0x2c, 0x9b, - 0x76, 0xd5, 0x1b, 0x8f, 0xcc, 0xc3, 0xb8, 0xf5, - } - - m115XORClientKey = []byte{ - 0x78, 0x06, 0xad, 0x4c, 0x33, 0x86, 0x5d, 0x18, - 0x4c, 0x01, 0x3f, 0x46, - } -) - -func m115XORDeriveKey(seed []byte, size int) []byte { - key := make([]byte, size) - for i := 0; i < size; i++ { - key[i] = (seed[i] + m115XORKeySeed[size*i]) & 0xff - key[i] ^= m115XORKeySeed[size*(size-i-1)] - } - return key -} - -func m115XORTransform(data []byte, key []byte) { - dataSize, keySize := len(data), len(key) - mod := dataSize % 4 - if mod > 0 { - for i := 0; i < mod; i++ { - data[i] ^= key[i%keySize] - } - } - for i := mod; i < dataSize; i++ { - data[i] ^= key[(i-mod)%keySize] - } -} diff --git a/internal/service/cloud/pan115_qr.go b/internal/service/cloud/pan115_qr.go deleted file mode 100644 index 8dc4ebc..0000000 --- a/internal/service/cloud/pan115_qr.go +++ /dev/null @@ -1,147 +0,0 @@ -package cloud - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" -) - -// QRSession is the handle returned by QRStart; the client renders QRImageURL -// and polls QRPoll until it returns a cookie. -type QRSession struct { - UID string `json:"uid"` - Time int64 `json:"time"` - Sign string `json:"sign"` - QRImageURL string `json:"qr_image_url"` -} - -// QR login hosts (overridable for tests). -var ( - qr115APIBase = "https://qrcodeapi.115.com" - qr115PassportBase = "https://passportapi.115.com" -) - -// QRStart obtains a 115 QR-code login token + image URL. -func QRStart(ctx context.Context, client *http.Client) (*QRSession, error) { - if client == nil { - client = http.DefaultClient - } - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, qr115APIBase+"/api/1.0/web/1.0/token/", nil) - req.Header.Set("User-Agent", defaultUA) - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - var r struct { - State int `json:"state"` - Data struct { - UID string `json:"uid"` - Time int64 `json:"time"` - Sign string `json:"sign"` - } `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return nil, fmt.Errorf("115 qr: decode token: %w", err) - } - if r.State != 1 || r.Data.UID == "" { - return nil, fmt.Errorf("115 qr: token request failed") - } - return &QRSession{ - UID: r.Data.UID, - Time: r.Data.Time, - Sign: r.Data.Sign, - QRImageURL: qr115APIBase + "/api/1.0/web/1.0/qrcode?uid=" + url.QueryEscape(r.Data.UID), - }, nil -} - -// QRStatus is the poll result. -type QRStatus struct { - // State is one of: "waiting" (not scanned), "scanned" (scanned, awaiting - // confirmation), "confirmed" (login approved; Cookie populated), - // "expired" (token expired/cancelled). - State string `json:"state"` - Cookie string `json:"cookie,omitempty"` -} - -// QRPoll checks the QR session status; on confirmation it exchanges the token -// for a session cookie via the passport API. -func QRPoll(ctx context.Context, client *http.Client, sess *QRSession) (*QRStatus, error) { - if client == nil { - client = http.DefaultClient - } - if sess == nil || sess.UID == "" { - return nil, fmt.Errorf("115 qr: nil session") - } - q := url.Values{} - q.Set("uid", sess.UID) - q.Set("time", strconv.FormatInt(sess.Time, 10)) - q.Set("sign", sess.Sign) - q.Set("_", strconv.FormatInt(timeNow().UnixMilli(), 10)) - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, qr115APIBase+"/get/status/?"+q.Encode(), nil) - req.Header.Set("User-Agent", defaultUA) - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - var r struct { - State int `json:"state"` - Data struct { - Status int `json:"status"` // 0 waiting, 1 scanned, 2 confirmed, -1/-2 expired - } `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return nil, fmt.Errorf("115 qr: decode status: %w", err) - } - switch r.Data.Status { - case 1: - return &QRStatus{State: "scanned"}, nil - case 2: - cookie, err := qr115Exchange(ctx, client, sess.UID) - if err != nil { - return nil, err - } - return &QRStatus{State: "confirmed", Cookie: cookie}, nil - case 0: - return &QRStatus{State: "waiting"}, nil - default: - return &QRStatus{State: "expired"}, nil - } -} - -// qr115Exchange swaps an approved uid for a session cookie. -func qr115Exchange(ctx context.Context, client *http.Client, uid string) (string, error) { - form := url.Values{} - form.Set("account", uid) - form.Set("app", "web") - req, _ := http.NewRequestWithContext(ctx, http.MethodPost, qr115PassportBase+"/app/1.0/web/1.0/login/qrcode/", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("User-Agent", defaultUA) - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - var r struct { - State int `json:"state"` - Data struct { - Cookie map[string]string `json:"cookie"` - } `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return "", fmt.Errorf("115 qr: decode login: %w", err) - } - if r.State != 1 || len(r.Data.Cookie) == 0 { - return "", fmt.Errorf("115 qr: login exchange failed") - } - parts := make([]string, 0, len(r.Data.Cookie)) - for k, v := range r.Data.Cookie { - parts = append(parts, k+"="+v) - } - return strings.Join(parts, "; "), nil -} diff --git a/internal/service/cloud_auto_category.go b/internal/service/cloud_auto_category.go deleted file mode 100644 index 707f769..0000000 --- a/internal/service/cloud_auto_category.go +++ /dev/null @@ -1,288 +0,0 @@ -package service - -import ( - "context" - "net/url" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -const cloudAutoCategoryQueryKey = "auto_category" - -func BuildCloudAutoCategoryLibraryPath(provider, displayDir string) string { - return BuildCloudAutoCategoryLibraryPathWithScanDir(provider, "", displayDir) -} - -func BuildCloudAutoCategoryLibraryPathWithScanDir(provider, scanDir, displayDir string) string { - base := BuildCloudLibraryPath(provider, scanDir, displayDir) - if base == "" || strings.TrimSpace(displayDir) == "" { - return "" - } - sep := "?" - if strings.Contains(base, "?") { - sep = "&" - } - return base + sep + cloudAutoCategoryQueryKey + "=1" -} - -func CloudLibraryAutoCategory(lib model.Library) bool { - u, err := url.Parse(strings.TrimSpace(lib.Path)) - if err != nil || strings.ToLower(u.Scheme) != "cloud" { - return false - } - switch strings.ToLower(strings.TrimSpace(u.Query().Get(cloudAutoCategoryQueryKey))) { - case "1", "true", "yes", "on": - return true - default: - return false - } -} - -func cloudRootMountNeedsAutoCategory(mount CloudMountInfo) bool { - return strings.TrimSpace(mount.DisplayDir) == "" && strings.TrimSpace(mount.ScanDir) == "" -} - -func cloudAutoCategoryDisplayDirForMediaPath(path string) string { - displayDir, _ := cloudAutoCategoryDirsForMediaPath(path) - return displayDir -} - -func cloudAutoCategoryDirsForMediaPath(path string) (string, string) { - info, ok := ParseCloudLibraryMount(path) - if !ok { - return "", "" - } - parts := strmSlashParts(info.DisplayDir) - if len(parts) <= 1 { - return "", "" - } - parts = parts[:len(parts)-1] - categoryParts, scanParts := cloudAutoCategoryParts(parts) - if len(categoryParts) == 0 { - return "", "" - } - return strings.Join(categoryParts, "/"), strings.Join(scanParts, "/") -} - -func cloudAutoCategoryParts(parts []string) ([]string, []string) { - for i, part := range parts { - root := strmCanonicalRoot(part) - if root != "" { - if i+1 >= len(parts) { - return nil, nil - } - category := strings.TrimSpace(parts[i+1]) - if cloudAutoCategoryRootMatches(root, category) { - return []string{root, strmCanonicalCategory(category)}, append([]string(nil), parts[:i+2]...) - } - return nil, nil - } - if root := strmCategoryRoot(part); root != "" { - return []string{root, strmCanonicalCategory(part)}, append([]string(nil), parts[:i+1]...) - } - } - return nil, nil -} - -func cloudAutoCategoryRootMatches(root, category string) bool { - category = strings.TrimSpace(category) - if category == "" { - return false - } - if strmCategoryRoot(category) == root { - return true - } - if root == "电影" { - return containsAnyText(strings.ToLower(category), "纪录片", "纪录", "documentary") - } - return false -} - -type cloudAutoCategoryTarget struct { - Library *model.Library - RootID string -} - -func (s *ScannerService) ensureCloudAutoCategoryTarget(ctx context.Context, rootLib *model.Library, provider, displayDir, scanDir string) (cloudAutoCategoryTarget, error) { - displayDir = normalizeCloudMountDir(provider, displayDir) - scanDir = normalizeCloudMountDir(provider, firstNonEmpty(scanDir, displayDir)) - if s == nil || s.repo == nil || s.repo.DB == nil || rootLib == nil || provider == "" || displayDir == "" { - return cloudAutoCategoryTarget{Library: rootLib}, nil - } - path := BuildCloudAutoCategoryLibraryPathWithScanDir(provider, scanDir, displayDir) - if path == "" { - return cloudAutoCategoryTarget{Library: rootLib}, nil - } - name := cloudMountDirBase(displayDir) - if name == "" { - name = displayDir - } - kind := InferCloudMountMediaType(displayDir, name) - target, existingAuto := s.findCloudAutoCategoryTarget(ctx, rootLib.ID, provider, displayDir, name, kind) - if target != nil { - root, err := s.ensureCloudLibraryRoot(ctx, target.ID, name, path) - if err != nil { - return cloudAutoCategoryTarget{}, err - } - if existingAuto != nil && existingAuto.ID != target.ID { - s.migrateCloudAutoCategoryLibrary(ctx, existingAuto, target, root) - } - return cloudAutoCategoryTarget{Library: target, RootID: libraryRootID(root)}, nil - } - if existingAuto != nil { - root, err := s.ensureCloudLibraryRoot(ctx, existingAuto.ID, name, path) - if err != nil { - return cloudAutoCategoryTarget{}, err - } - return cloudAutoCategoryTarget{Library: existingAuto, RootID: libraryRootID(root)}, nil - } - lib := &model.Library{ - Name: name, - Path: path, - Type: kind, - Enabled: true, - } - root := model.LibraryRoot{Name: name, Path: path, Enabled: true} - if err := s.repo.Library.CreateWithRoots(ctx, lib, []model.LibraryRoot{root}); err != nil { - _, existing := s.findCloudAutoCategoryTarget(ctx, rootLib.ID, provider, displayDir, name, kind) - if existing != nil { - ensuredRoot, rootErr := s.ensureCloudLibraryRoot(ctx, existing.ID, name, path) - if rootErr != nil { - return cloudAutoCategoryTarget{}, rootErr - } - return cloudAutoCategoryTarget{Library: existing, RootID: libraryRootID(ensuredRoot)}, nil - } - return cloudAutoCategoryTarget{}, err - } - if s.log != nil { - s.log.Info("created cloud auto category library", - zap.String("root_library_id", rootLib.ID), - zap.String("library_id", lib.ID), - zap.String("provider", provider), - zap.String("display_dir", displayDir)) - } - if len(lib.Roots) > 0 { - return cloudAutoCategoryTarget{Library: lib, RootID: lib.Roots[0].ID}, nil - } - return cloudAutoCategoryTarget{Library: lib}, nil -} - -func (s *ScannerService) findCloudAutoCategoryTarget(ctx context.Context, rootLibraryID, provider, displayDir, name, kind string) (*model.Library, *model.Library) { - if s == nil || s.repo == nil || s.repo.Library == nil { - return nil, nil - } - libs, err := s.repo.Library.List(ctx) - if err != nil { - if s.log != nil { - s.log.Warn("list libraries for cloud auto category failed", zap.Error(err)) - } - return nil, nil - } - displayDir = normalizeCloudMountDir(provider, displayDir) - targetKey, _ := CloudLibraryMergeKey(model.Library{Name: name, Type: kind}) - var target *model.Library - var existingAuto *model.Library - for _, lib := range libs { - info, ok := ParseCloudLibraryMount(lib.Path) - if ok && info.Provider == provider && normalizeCloudMountDir(provider, info.DisplayDir) == displayDir && CloudLibraryAutoCategory(lib) { - copy := lib - existingAuto = © - continue - } - if lib.ID == rootLibraryID || CloudLibraryAutoCategory(lib) || !lib.Enabled || targetKey == "" { - continue - } - key, ok := CloudLibraryMergeKey(lib) - if target == nil && ok && key == targetKey { - copy := lib - target = © - } - } - return target, existingAuto -} - -func (s *ScannerService) ensureCloudLibraryRoot(ctx context.Context, libraryID, name, pathValue string) (*model.LibraryRoot, error) { - if s == nil || s.repo == nil || s.repo.Library == nil { - return nil, nil - } - roots, err := s.repo.Library.ListRoots(ctx, libraryID) - if err != nil { - return nil, err - } - targetKey := libraryRootPathKey(pathValue) - for i := range roots { - if libraryRootPathKey(roots[i].Path) == targetKey { - if strings.TrimSpace(roots[i].Name) == "" && strings.TrimSpace(name) != "" { - _ = s.repo.Library.UpdateRoot(ctx, &roots[i], map[string]any{"name": strings.TrimSpace(name)}) - roots[i].Name = strings.TrimSpace(name) - } - return &roots[i], nil - } - } - root := &model.LibraryRoot{ - LibraryID: libraryID, - Name: strings.TrimSpace(name), - Path: pathValue, - Enabled: true, - SortOrder: len(roots), - } - if err := s.repo.Library.CreateRoot(ctx, root); err != nil { - return nil, err - } - return root, nil -} - -func (s *ScannerService) migrateCloudAutoCategoryLibrary(ctx context.Context, source, target *model.Library, root *model.LibraryRoot) { - if s == nil || s.repo == nil || s.repo.DB == nil || source == nil || target == nil || source.ID == "" || target.ID == "" { - return - } - updates := map[string]any{"library_id": target.ID} - if rootID := libraryRootID(root); rootID != "" { - updates["library_root_id"] = rootID - } - if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("library_id = ?", source.ID).Updates(updates).Error; err != nil { - if s.log != nil { - s.log.Warn("migrate cloud auto category media failed", - zap.String("from_library_id", source.ID), - zap.String("to_library_id", target.ID), - zap.Error(err)) - } - return - } - _ = hardDeleteLibraryRoots(ctx, s.repo.DB, source.ID) - if err := s.repo.Library.Delete(ctx, source.ID); err != nil && s.log != nil { - s.log.Warn("remove migrated cloud auto category library failed", - zap.String("library_id", source.ID), - zap.Error(err)) - } -} - -func (s *ScannerService) cloudScanLibraryScopeIDs(ctx context.Context, lib *model.Library, mount CloudMountInfo) []string { - if lib == nil { - return nil - } - ids := []string{lib.ID} - if !cloudRootMountNeedsAutoCategory(mount) || s == nil || s.repo == nil || s.repo.Library == nil { - return ids - } - libs, err := s.repo.Library.List(ctx) - if err != nil { - if s.log != nil { - s.log.Warn("list libraries for cloud scan scope failed", zap.String("library_id", lib.ID), zap.Error(err)) - } - return ids - } - for _, candidate := range libs { - if candidate.ID == lib.ID || !CloudLibraryAutoCategory(candidate) { - continue - } - info, ok := ParseCloudLibraryMount(candidate.Path) - if ok && info.Provider == mount.Provider { - ids = appendUniqueLibraryIDs(ids, candidate.ID) - } - } - return ids -} diff --git a/internal/service/cloud_library_display_stubs.go b/internal/service/cloud_library_display_stubs.go new file mode 100644 index 0000000..80222d0 --- /dev/null +++ b/internal/service/cloud_library_display_stubs.go @@ -0,0 +1,248 @@ +package service + +import ( + "context" + "net/url" + "path" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +// CloudMountInfo 是云盘挂载库的规范标识(因网盘后端已移除,仅保留类型以兼容 +// 既有调用点;实际不会有 cloud:// 路径)。 +type CloudMountInfo struct { + Provider string + DisplayDir string + ScanDir string + Path string +} + +// ParseCloudLibraryMount 原用于解析 cloud:// 挂载库路径。网盘后端已移除,恒 +// 返回 (空, false)。 +func ParseCloudLibraryMount(_ string) (CloudMountInfo, bool) { + return CloudMountInfo{}, false +} + +func cloudMountAncestor(_, _ string) bool { + return false +} + +func cloudRootMountNeedsAutoCategory(_ CloudMountInfo) bool { + return false +} + +func appendUniqueLibraryIDs(ids []string, values ...string) []string { + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + exists := false + for _, id := range ids { + if id == value { + exists = true + break + } + } + if !exists { + ids = append(ids, value) + } + } + return ids +} + +func compactLibraryIDs(ids ...string) []string { + out := make([]string, 0, len(ids)) + for _, id := range ids { + out = appendUniqueLibraryIDs(out, id) + } + return out +} + +// 云盘库展示/合并辅助函数。 +// +// 网盘后端(存储配置/云盘扫描/云播放)已随「存储配置」功能整体移除,因此 +// 库表里不再存在 cloud:// 挂载库。以下函数保留为空实现以保持媒体浏览、 +// 检索与 Emby 兼容层对原有调用点的兼容;在没有云盘库的前提下它们的 +// 语义等价于「不合并、不过滤、不开自动分类」。 + +// FilterDisplayCloudLibraries 原用于筛掉云盘库(展示时不单独列出)。现已无 +// 云盘库,原样返回。 +func FilterDisplayCloudLibraries(_ context.Context, _ *repository.Container, libs []model.Library) []model.Library { + return libs +} + +// MergedLibraryIDsForLibrary 原用于把合并展示的云盘库 ID 集合展开。现已无云盘 +// 库,仅返回目标库自身 ID。 +func MergedLibraryIDsForLibrary(_ context.Context, _ *repository.Container, libraryID string) ([]string, error) { + return []string{libraryID}, nil +} + +// ExpandMediaVisibilityForMergedCloudLibraries 原用于把用户可见范围展开到合并 +// 的云盘库。现已无云盘库,原样返回。 +func ExpandMediaVisibilityForMergedCloudLibraries(_ context.Context, _ *repository.Container, visibility MediaVisibility) MediaVisibility { + return visibility +} + +// CloudLibraryAutoCategory 原用于识别「自动分类」云盘库。现已无云盘库,恒为 +// false。 +func CloudLibraryAutoCategory(_ model.Library) bool { + return false +} + +// CloudLibraryMergeKey 原用于计算两个云盘库的合并键。现已无云盘库,返回 +// (空, false)。 +func CloudLibraryMergeKey(_ model.Library) (string, bool) { + return "", false +} + +// ShadowedCloudLibraryIDSet 原用于返回被合并/遮蔽的云盘库 ID 集合。现已无云盘 +// 库,返回空集合。 +func ShadowedCloudLibraryIDSet(_ []model.Library) map[string]bool { + return map[string]bool{} +} + +// NormalizeCloudLibraryDisplay 原用于归一化云盘库的展示名/类型。现已无云盘库, +// 原样返回。 +func NormalizeCloudLibraryDisplay(libs []model.Library) []model.Library { + return libs +} + +// normalizeRemotePath 归一化远程(云盘/STRM 目标)路径。属通用路径处理辅助, +// 保留供 STRM 目标路径使用。 +func normalizeRemotePath(p string) string { + p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/") + if p == "" || p == "." { + return "/" + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return path.Clean(p) +} + +// scanHasImportChanges 报告一次扫描是否产生了入库/变更。属通用扫描辅助。 +func scanHasImportChanges(res *ScanResult) bool { + return res != nil && (res.Added > 0 || res.Updated > 0 || res.Removed > 0) +} + +// cloneLocalMetadata 深拷贝 LocalMetadata 值(值类型浅拷贝即可)。属通用辅助。 +func cloneLocalMetadata(src *LocalMetadata) *LocalMetadata { + if src == nil { + return nil + } + cp := *src + return &cp +} + +// joinRemotePath 拼接远程路径片段(供 STRM 目标路径使用)。属通用路径辅助。 +func joinRemotePath(base, rel string) string { + parts := []string{normalizeRemotePath(base)} + for _, part := range strings.Split(strings.ReplaceAll(rel, "\\", "/"), "/") { + part = strings.TrimSpace(part) + if part != "" && part != "." { + parts = append(parts, part) + } + } + return path.Clean(path.Join(parts...)) +} +// 通用库路径辅助(原云盘库/STRM 生成逻辑使用;网盘后端移除后保留为纯工具函数, +// 供库路径构建与既有测试作为稳定夹具使用)。 + +const LegacyQuarkProvider = "quark" + +func BuildCloudLibraryPath(provider, scanDir, displayDir string) string { + provider = strings.TrimSpace(provider) + scanDir = normalizeCloudMountDir(provider, scanDir) + displayDir = normalizeCloudMountDir(provider, firstNonEmpty(displayDir, scanDir)) + if provider == "" { + return "" + } + base := "cloud://" + provider + if displayDir == "" { + if scanDir != "" { + return base + "?dir=" + url.QueryEscape(scanDir) + } + return base + } + pathStr := base + "/" + url.PathEscape(displayDir) + if scanDir != "" && scanDir != displayDir { + pathStr += "?dir=" + url.QueryEscape(scanDir) + } + return pathStr +} + +func BuildCloudAutoCategoryLibraryPath(provider, displayDir string) string { + return BuildCloudAutoCategoryLibraryPathWithScanDir(provider, "", displayDir) +} + +func BuildCloudAutoCategoryLibraryPathWithScanDir(provider, scanDir, displayDir string) string { + base := BuildCloudLibraryPath(provider, scanDir, displayDir) + if base == "" || strings.TrimSpace(displayDir) == "" { + return "" + } + sep := "?" + if strings.Contains(base, "?") { + sep = "&" + } + return base + sep + "auto_category=1" +} + +func normalizeCloudMountDir(provider, value string) string { + value = strings.TrimSpace(value) + if decoded, err := url.PathUnescape(value); err == nil { + value = decoded + } + if decoded, err := url.QueryUnescape(value); err == nil { + value = decoded + } + value = strings.ReplaceAll(value, "\\", "/") + value = strings.Trim(strings.TrimSpace(value), "/") + if value == "." || ((provider == "115" || provider == LegacyQuarkProvider) && value == "0") { + return "" + } + return value +} + +func cloudMountDirBase(dir string) string { + dir = strings.Trim(strings.TrimSpace(strings.ReplaceAll(dir, "\\", "/")), "/") + if dir == "" { + return "" + } + parts := strings.Split(dir, "/") + for i := len(parts) - 1; i >= 0; i-- { + if part := strings.TrimSpace(parts[i]); part != "" { + return part + } + } + return "" +} + +func CloudMountProviderLabel(provider string) string { + switch strings.TrimSpace(provider) { + case LegacyQuarkProvider: + return "已停用网盘" + case "115": + return "115 网盘" + case "clouddrive2": + return "CloudDrive2" + case "openlist": + return "OpenList" + default: + if strings.TrimSpace(provider) == "" { + return "网盘" + } + return strings.TrimSpace(provider) + } +} + +func CloudArtworkURL(typ, ref string) string { + typ = strings.Trim(strings.ReplaceAll(strings.TrimSpace(typ), "\\", "/"), "/") + ref = strings.TrimSpace(ref) + if typ == "" || ref == "" { + return "" + } + return "/api/img/cloud/" + url.PathEscape(typ) + "?ref=" + url.QueryEscape(ref) +} diff --git a/internal/service/cloud_metadata.go b/internal/service/cloud_metadata.go deleted file mode 100644 index 589e7ca..0000000 --- a/internal/service/cloud_metadata.go +++ /dev/null @@ -1,168 +0,0 @@ -package service - -import ( - "context" - "encoding/xml" - "path/filepath" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -type cloudSidecarSet struct { - nfoByName map[string]string - nfoByBase map[string]string - jsonByName map[string]string - jsonByBase map[string]string - imageByName map[string]string - imageByBase map[string]string -} - -func newCloudSidecarSet(typ string, entries []cloud.FileEntry) cloudSidecarSet { - set := cloudSidecarSet{ - nfoByName: make(map[string]string), - nfoByBase: make(map[string]string), - jsonByName: make(map[string]string), - jsonByBase: make(map[string]string), - imageByName: make(map[string]string), - imageByBase: make(map[string]string), - } - for _, entry := range entries { - if entry.IsDir { - continue - } - ref := cloudEntryRef(typ, entry.ID, entry.PickCode) - if ref == "" { - continue - } - name := strings.TrimSpace(entry.Name) - ext := strings.ToLower(filepath.Ext(name)) - base := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(name, ext))) - if name == "" || base == "" { - continue - } - switch ext { - case ".nfo": - set.nfoByName[strings.ToLower(name)] = ref - set.nfoByBase[base] = ref - case ".json": - set.jsonByName[strings.ToLower(name)] = ref - set.jsonByBase[base] = ref - case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn": - set.imageByName[strings.ToLower(name)] = ref - set.imageByBase[base] = ref - } - } - return set -} - -func (s *ScannerService) cloudDirectoryMetadata(ctx context.Context, typ, displayDir string, sidecars cloudSidecarSet, inherited *LocalMetadata) *LocalMetadata { - meta := cloneLocalMetadata(inherited) - if hinted, _ := pathHintMetadata(displayDir, true); hinted != nil { - meta = mergeCloudMetadata(meta, hinted) - } - for _, name := range cloudShowNFOCandidates(displayDir) { - ref := sidecars.nfoByName[strings.ToLower(name)] - if ref == "" { - ref = sidecars.nfoByBase[strings.ToLower(strings.TrimSuffix(name, filepath.Ext(name)))] - } - if ref == "" { - continue - } - if local, doc, err := s.readCloudNFO(ctx, typ, ref, true); err == nil && local != nil { - local = applyCloudNFOArtwork(typ, sidecars, local, doc) - meta = mergeCloudMetadata(meta, local) - break - } - } - for _, name := range cloudDirectoryJSONCandidates(displayDir) { - ref := cloudJSONRefByName(sidecars, name) - if ref == "" { - continue - } - if local, err := s.readCloudJSONMetadata(ctx, typ, ref, sidecars); err == nil && local != nil { - meta = mergeCloudMetadata(meta, local) - break - } - } - meta = applyCloudDirectoryArtwork(typ, displayDir, sidecars, meta) - if !cloudMetadataUseful(meta) { - return nil - } - return meta -} - -func (s *ScannerService) cloudFileMetadata(ctx context.Context, typ, displayPath, fileName string, sidecars cloudSidecarSet, inherited *LocalMetadata, seriesLike bool) *LocalMetadata { - season, episode := ParseEpisode(displayPath) - seriesLike = seriesLike || season > 0 || episode > 0 - meta := cloneLocalMetadata(inherited) - if hinted, _ := pathHintMetadata(displayPath, seriesLike); hinted != nil { - meta = mergeCloudPathHintMetadata(meta, hinted) - } - base := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(fileName, filepath.Ext(fileName)))) - if ref := sidecars.nfoByBase[base]; ref != "" { - if local, doc, err := s.readCloudNFO(ctx, typ, ref, seriesLike); err == nil && local != nil { - local = applyCloudNFOArtwork(typ, sidecars, local, doc) - if seriesLike && doc != nil { - if meta == nil { - meta = &LocalMetadata{} - } - mergeEpisodeMetadata(meta, local, doc) - meta.HasNFO = true - } else { - meta = mergeCloudMetadata(meta, local) - } - } - } - for _, name := range cloudFileJSONCandidates(fileName, base) { - ref := cloudJSONRefByName(sidecars, name) - if ref == "" { - continue - } - if local, err := s.readCloudJSONMetadata(ctx, typ, ref, sidecars); err == nil && local != nil { - if cloudFileJSONIsEpisodeMetadata(seriesLike, season, episode, local) { - meta = mergeCloudEpisodeMetadata(meta, local) - } else { - meta = mergeCloudMetadata(meta, local) - } - break - } - } - meta = applyCloudFileArtwork(typ, sidecars, displayPath, fileName, base, meta) - if !cloudMetadataUseful(meta) { - return nil - } - return meta -} - -func (s *ScannerService) readCloudJSONMetadata(ctx context.Context, typ, ref string, sidecars cloudSidecarSet) (*LocalMetadata, error) { - if s.storage == nil { - return nil, nil - } - body, err := s.storage.CloudReadText(ctx, typ, ref, 512<<10) - if err != nil { - return nil, err - } - meta, artwork := metadataFromCloudJSON([]byte(body)) - if meta == nil { - return nil, nil - } - meta = applyCloudJSONArtwork(typ, sidecars, meta, artwork) - return meta, nil -} - -func (s *ScannerService) readCloudNFO(ctx context.Context, typ, ref string, seriesLike bool) (*LocalMetadata, *nfoDocument, error) { - if s.storage == nil { - return nil, nil, nil - } - body, err := s.storage.CloudReadText(ctx, typ, ref, 512<<10) - if err != nil { - return nil, nil, err - } - var doc nfoDocument - if err := xml.Unmarshal([]byte(body), &doc); err != nil { - return nil, nil, err - } - meta := metadataFromDoc(&doc, "", seriesLike) - return meta, &doc, nil -} diff --git a/internal/service/cloud_metadata_artwork.go b/internal/service/cloud_metadata_artwork.go deleted file mode 100644 index 65201da..0000000 --- a/internal/service/cloud_metadata_artwork.go +++ /dev/null @@ -1,119 +0,0 @@ -package service - -import ( - "net/url" - "path" - "strings" -) - -func applyCloudNFOArtwork(typ string, sidecars cloudSidecarSet, meta *LocalMetadata, doc *nfoDocument) *LocalMetadata { - if meta == nil { - meta = &LocalMetadata{} - } - if doc == nil { - return meta - } - if ref := cloudImageRefFromNFOValues(sidecars, nfoPosterValues(doc)...); ref != "" { - meta.PosterURL = cloudPlaybackURL(typ, ref) - meta.HasArtwork = true - } - if ref := cloudImageRefFromNFOValues(sidecars, nfoBackdropValues(doc)...); ref != "" { - meta.BackdropURL = cloudPlaybackURL(typ, ref) - meta.HasArtwork = true - } - return meta -} - -func applyCloudDirectoryArtwork(typ, displayDir string, sidecars cloudSidecarSet, meta *LocalMetadata) *LocalMetadata { - if meta == nil { - meta = &LocalMetadata{} - } - if meta.PosterURL == "" { - if ref := firstCloudImageRef(sidecars, cloudPosterNameCandidates(cloudDirectoryArtworkBases(displayDir), "poster", "folder", "cover", "show", "tvshow")...); ref != "" { - meta.PosterURL = cloudPlaybackURL(typ, ref) - meta.HasArtwork = true - } - } - if meta.BackdropURL == "" { - if ref := firstCloudImageRef(sidecars, cloudBackdropNameCandidates(cloudDirectoryArtworkBases(displayDir), "fanart", "backdrop", "background", "landscape")...); ref != "" { - meta.BackdropURL = cloudPlaybackURL(typ, ref) - meta.HasArtwork = true - } - } - return meta -} - -func applyCloudFileArtwork(typ string, sidecars cloudSidecarSet, displayPath, fileName, base string, meta *LocalMetadata) *LocalMetadata { - if meta == nil { - meta = &LocalMetadata{} - } - bases := cloudFileArtworkBases(displayPath, fileName, base) - if meta.PosterURL == "" { - if ref := firstCloudImageRef(sidecars, cloudPosterNameCandidates(bases, "poster", "folder", "cover", "movie", "show", "thumb")...); ref != "" { - meta.PosterURL = cloudPlaybackURL(typ, ref) - meta.HasArtwork = true - } - } - if meta.BackdropURL == "" { - if ref := firstCloudImageRef(sidecars, cloudBackdropNameCandidates(bases, "fanart", "backdrop", "background", "landscape")...); ref != "" { - meta.BackdropURL = cloudPlaybackURL(typ, ref) - meta.HasArtwork = true - } - } - return meta -} - -func firstCloudImageRef(sidecars cloudSidecarSet, names ...string) string { - for _, name := range names { - if ref := cloudImageRefByName(sidecars, name); ref != "" { - return ref - } - } - return "" -} - -func cloudImageRefFromNFOValues(sidecars cloudSidecarSet, values ...string) string { - for _, value := range values { - if ref := cloudImageRefByName(sidecars, value); ref != "" { - return ref - } - } - return "" -} - -func cloudImageRefByName(sidecars cloudSidecarSet, value string) string { - name := normalizeCloudArtworkName(value) - if name == "" || isHTTPURL(name) { - return "" - } - if ref := sidecars.imageByName[strings.ToLower(name)]; ref != "" { - return ref - } - base := strings.TrimSuffix(name, path.Ext(name)) - if ref := sidecars.imageByBase[strings.ToLower(base)]; ref != "" { - return ref - } - return "" -} - -func normalizeCloudArtworkName(value string) string { - value = cleanXMLText(value) - if value == "" { - return "" - } - if isHTTPURL(value) { - return value - } - if unescaped, err := url.QueryUnescape(value); err == nil { - value = unescaped - } - value = strings.ReplaceAll(value, "\\", "/") - if idx := strings.IndexAny(value, "?#"); idx >= 0 { - value = value[:idx] - } - value = strings.Trim(strings.TrimSpace(value), "/") - if value == "" { - return "" - } - return path.Base(value) -} diff --git a/internal/service/cloud_metadata_candidates.go b/internal/service/cloud_metadata_candidates.go deleted file mode 100644 index 10d125f..0000000 --- a/internal/service/cloud_metadata_candidates.go +++ /dev/null @@ -1,122 +0,0 @@ -package service - -import ( - "path/filepath" - "strconv" - "strings" -) - -func cloudShowNFOCandidates(displayDir string) []string { - names := []string{"tvshow.nfo", "series.nfo", "show.nfo", "movie.nfo"} - base := strings.TrimSpace(pathBaseSlash(displayDir)) - if base != "" { - names = append(names, base+".nfo") - } - return names -} - -func cloudDirectoryJSONCandidates(displayDir string) []string { - names := []string{"movie.json", "metadata.json", "tvshow.json", "series.json", "show.json"} - base := strings.TrimSpace(pathBaseSlash(displayDir)) - if base != "" { - names = append(names, base+".json", base+"-metadata.json", base+".metadata.json", base+"-mediainfo.json", base+".mediainfo.json") - } - return names -} - -func cloudFileJSONCandidates(fileName, base string) []string { - if base == "" { - base = strings.ToLower(strings.TrimSpace(strings.TrimSuffix(fileName, filepath.Ext(fileName)))) - } - cleanBases := cloudCleanArtworkBases(fileName) - bases := uniqueCloudArtworkNames(append([]string{base}, cleanBases...)...) - out := make([]string, 0, len(bases)*5+2) - for _, value := range bases { - out = append(out, value+".json", value+"-metadata.json", value+".metadata.json", value+"-mediainfo.json", value+".mediainfo.json") - } - return append(out, "movie.json", "metadata.json") -} - -func cloudJSONRefByName(sidecars cloudSidecarSet, name string) string { - name = normalizeCloudArtworkName(name) - if name == "" || isHTTPURL(name) { - return "" - } - if ref := sidecars.jsonByName[strings.ToLower(name)]; ref != "" { - return ref - } - base := strings.TrimSuffix(name, filepath.Ext(name)) - return sidecars.jsonByBase[strings.ToLower(base)] -} - -func cloudFileArtworkBases(displayPath, fileName, base string) []string { - return uniqueCloudArtworkNames(append( - []string{base}, - append(cloudCleanArtworkBases(fileName), cloudDirectoryArtworkBases(pathDirSlash(displayPath))...)..., - )...) -} - -func cloudDirectoryArtworkBases(displayDir string) []string { - base := pathBaseSlash(displayDir) - return uniqueCloudArtworkNames(append([]string{base}, cloudCleanArtworkBases(base)...)...) -} - -func cloudCleanArtworkBases(value string) []string { - title, year := CleanQuery(value) - title = strings.TrimSpace(title) - if title == "" { - return nil - } - out := []string{title} - if year > 0 { - yearText := strconv.Itoa(year) - out = append(out, - title+" ("+yearText+")", - title+"."+yearText, - title+" "+yearText, - ) - } - return out -} - -func cloudPosterNameCandidates(bases []string, fallback ...string) []string { - out := make([]string, 0, len(bases)*7+len(fallback)) - for _, base := range bases { - base = strings.TrimSpace(base) - if base == "" { - continue - } - out = append(out, base, base+"-poster", base+".poster", base+"-cover", base+".cover", base+"-thumb", base+".thumb") - } - return append(out, fallback...) -} - -func cloudBackdropNameCandidates(bases []string, fallback ...string) []string { - out := make([]string, 0, len(bases)*6+len(fallback)) - for _, base := range bases { - base = strings.TrimSpace(base) - if base == "" { - continue - } - out = append(out, base+"-fanart", base+".fanart", base+"-backdrop", base+".backdrop", base+"-background", base+".background") - } - return append(out, fallback...) -} - -func uniqueCloudArtworkNames(values ...string) []string { - out := make([]string, 0, len(values)) - seen := map[string]struct{}{} - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" { - continue - } - key := strings.ToLower(value) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - out = append(out, value) - } - return out -} diff --git a/internal/service/cloud_metadata_episode.go b/internal/service/cloud_metadata_episode.go deleted file mode 100644 index 8627577..0000000 --- a/internal/service/cloud_metadata_episode.go +++ /dev/null @@ -1,83 +0,0 @@ -package service - -import "strings" - -func cloudFileJSONIsEpisodeMetadata(seriesLike bool, parsedSeason, parsedEpisode int, meta *LocalMetadata) bool { - if !seriesLike || meta == nil { - return false - } - return parsedSeason > 0 || - parsedEpisode > 0 || - meta.SeasonNum > 0 || - meta.EpisodeNum > 0 || - strings.TrimSpace(meta.EpisodeTitle) != "" -} - -func mergeCloudEpisodeMetadata(dst, episode *LocalMetadata) *LocalMetadata { - if episode == nil { - return dst - } - if dst == nil { - dst = &LocalMetadata{} - } - mergeCloudEpisodeIdentity(dst, episode) - mergeCloudEpisodeDisplay(dst, episode) - mergeCloudEpisodeNumbersAndTaxonomy(dst, episode) - dst.NSFW = dst.NSFW || episode.NSFW - dst.HasNFO = dst.HasNFO || episode.HasNFO - dst.HasArtwork = dst.HasArtwork || episode.HasArtwork - return dst -} - -func mergeCloudEpisodeIdentity(dst, episode *LocalMetadata) { - showTitle := "" - if episode.EpisodeTitle != "" && episode.Title != "" && !strings.EqualFold(episode.Title, episode.EpisodeTitle) { - showTitle = episode.Title - } - if showTitle != "" { - dst.Title = showTitle - } - episodeTitle := strings.TrimSpace(episode.EpisodeTitle) - if episodeTitle == "" && (episode.SeasonNum > 0 || episode.EpisodeNum > 0) { - episodeTitle = strings.TrimSpace(episode.Title) - } - if episodeTitle != "" && !strings.EqualFold(episodeTitle, strings.TrimSpace(dst.Title)) { - dst.EpisodeTitle = episodeTitle - } -} - -func mergeCloudEpisodeDisplay(dst, episode *LocalMetadata) { - if dst.Year == 0 && episode.Year > 0 { - dst.Year = episode.Year - } - if episode.Overview != "" { - dst.Overview = episode.Overview - } - if episode.Rating > 0 { - dst.Rating = episode.Rating - } - if episode.PosterURL != "" { - dst.PosterURL = episode.PosterURL - } - if episode.BackdropURL != "" { - dst.BackdropURL = episode.BackdropURL - } -} - -func mergeCloudEpisodeNumbersAndTaxonomy(dst, episode *LocalMetadata) { - if episode.SeasonNum > 0 { - dst.SeasonNum = episode.SeasonNum - } - if episode.EpisodeNum > 0 { - dst.EpisodeNum = episode.EpisodeNum - } - if dst.Genres == "" && episode.Genres != "" { - dst.Genres = episode.Genres - } - if dst.Countries == "" && episode.Countries != "" { - dst.Countries = episode.Countries - } - if dst.Languages == "" && episode.Languages != "" { - dst.Languages = episode.Languages - } -} diff --git a/internal/service/cloud_metadata_json.go b/internal/service/cloud_metadata_json.go deleted file mode 100644 index 26907a0..0000000 --- a/internal/service/cloud_metadata_json.go +++ /dev/null @@ -1,265 +0,0 @@ -package service - -import ( - "encoding/json" - "strconv" - "strings" -) - -type cloudJSONArtwork struct { - posterValues []string - backdropValues []string -} - -func metadataFromCloudJSON(body []byte) (*LocalMetadata, cloudJSONArtwork) { - var raw any - if err := json.Unmarshal(body, &raw); err != nil { - return nil, cloudJSONArtwork{} - } - obj := firstMetadataJSONObject(raw) - if len(obj) == 0 { - return nil, cloudJSONArtwork{} - } - meta := &LocalMetadata{ - Title: firstJSONString(obj, "title", "name", "showtitle", "show_title"), - OriginalName: firstJSONString(obj, "original_title", "originaltitle", "original_name", "originalname", "sorttitle"), - EpisodeTitle: firstJSONString(obj, "episode_title", "episodetitle", "episode_name", "episodename"), - Year: firstJSONInt(obj, "year"), - ReleaseDate: normalizeReleaseDate(firstJSONString(obj, "release_date", "releasedate", "premiered", "aired", "date")), - Overview: firstJSONString(obj, "overview", "plot", "outline", "summary", "description"), - Rating: firstJSONFloat(obj, "rating", "vote_average", "score"), - TMDbID: firstJSONInt(obj, "tmdb_id", "tmdbid", "tmdb"), - BangumiID: firstJSONInt(obj, "bangumi_id", "bangumiid", "bgm_id"), - DoubanID: firstJSONString(obj, "douban_id", "doubanid"), - TheTVDBID: firstJSONString(obj, "thetvdb_id", "tvdb_id", "thetvdbid", "tvdbid"), - SeasonNum: firstJSONInt(obj, "season", "season_num", "season_number"), - EpisodeNum: firstJSONInt(obj, "episode", "episode_num", "episode_number"), - Genres: firstJSONList(obj, "genres", "genre", "tags"), - Countries: firstJSONList(obj, "countries", "country", "production_countries"), - Languages: firstJSONList(obj, "languages", "language", "spoken_languages"), - } - if showTitle := firstJSONString(obj, "showtitle", "show_title", "series_title", "series_name"); showTitle != "" { - if meta.EpisodeTitle == "" && meta.Title != "" && !strings.EqualFold(strings.TrimSpace(meta.Title), strings.TrimSpace(showTitle)) { - meta.EpisodeTitle = meta.Title - } - meta.Title = showTitle - } - if meta.Year == 0 { - meta.Year = yearFromDate(meta.ReleaseDate) - } - artwork := cloudJSONArtwork{ - posterValues: firstJSONStrings(obj, - "poster_url", "poster", "poster_path", "cover", "cover_url", "thumb", "thumbnail", "image"), - backdropValues: firstJSONStrings(obj, - "backdrop_url", "backdrop", "backdrop_path", "fanart", "fanart_url", "background", "landscape"), - } - if images, ok := jsonObject(obj["images"]); ok { - artwork.posterValues = append(artwork.posterValues, firstJSONStrings(images, "poster", "large", "common", "medium", "small", "cover")...) - artwork.backdropValues = append(artwork.backdropValues, firstJSONStrings(images, "backdrop", "fanart", "background", "landscape")...) - } - if art, ok := jsonObject(obj["art"]); ok { - artwork.posterValues = append(artwork.posterValues, firstJSONStrings(art, "poster", "thumb", "cover")...) - artwork.backdropValues = append(artwork.backdropValues, firstJSONStrings(art, "fanart", "backdrop", "background", "landscape")...) - } - if len(artwork.posterValues) > 0 { - meta.PosterURL = firstHTTPJSONValue(artwork.posterValues) - } - if len(artwork.backdropValues) > 0 { - meta.BackdropURL = firstHTTPJSONValue(artwork.backdropValues) - } - if meta.PosterURL != "" || meta.BackdropURL != "" { - meta.HasArtwork = true - } - if localHasDescriptiveMetadata(meta) || meta.HasArtwork || len(artwork.posterValues) > 0 || len(artwork.backdropValues) > 0 { - meta.HasNFO = true - return meta, artwork - } - return nil, cloudJSONArtwork{} -} - -func applyCloudJSONArtwork(typ string, sidecars cloudSidecarSet, meta *LocalMetadata, artwork cloudJSONArtwork) *LocalMetadata { - if meta == nil { - meta = &LocalMetadata{} - } - if meta.PosterURL == "" { - if ref := cloudImageRefFromNFOValues(sidecars, artwork.posterValues...); ref != "" { - meta.PosterURL = cloudPlaybackURL(typ, ref) - meta.HasArtwork = true - } - } - if meta.BackdropURL == "" { - if ref := cloudImageRefFromNFOValues(sidecars, artwork.backdropValues...); ref != "" { - meta.BackdropURL = cloudPlaybackURL(typ, ref) - meta.HasArtwork = true - } - } - return meta -} - -func firstMetadataJSONObject(raw any) map[string]any { - obj, ok := jsonObject(raw) - if !ok { - return nil - } - for _, key := range []string{"movie", "media", "metadata", "item", "data"} { - if nested, ok := jsonObject(obj[key]); ok && jsonObjectLooksLikeMetadata(nested) { - return nested - } - } - return obj -} - -func jsonObjectLooksLikeMetadata(obj map[string]any) bool { - for _, key := range []string{"title", "name", "overview", "plot", "tmdb_id", "tmdbid", "poster", "poster_url", "poster_path", "backdrop", "backdrop_path"} { - if _, ok := obj[key]; ok { - return true - } - } - return false -} - -func jsonObject(raw any) (map[string]any, bool) { - obj, ok := raw.(map[string]any) - return obj, ok -} - -func firstJSONString(obj map[string]any, keys ...string) string { - values := firstJSONStrings(obj, keys...) - if len(values) == 0 { - return "" - } - return values[0] -} - -func firstJSONStrings(obj map[string]any, keys ...string) []string { - out := []string{} - for _, key := range keys { - value, ok := lookupJSONKey(obj, key) - if !ok { - continue - } - out = append(out, jsonStrings(value)...) - if len(out) > 0 { - return out - } - } - return out -} - -func firstJSONInt(obj map[string]any, keys ...string) int { - for _, key := range keys { - value, ok := lookupJSONKey(obj, key) - if !ok { - continue - } - if i := jsonInt(value); i > 0 { - return i - } - } - return 0 -} - -func firstJSONFloat(obj map[string]any, keys ...string) float32 { - for _, key := range keys { - value, ok := lookupJSONKey(obj, key) - if !ok { - continue - } - if f := jsonFloat(value); f > 0 { - return f - } - } - return 0 -} - -func firstJSONList(obj map[string]any, keys ...string) string { - seen := map[string]struct{}{} - out := []string{} - for _, key := range keys { - value, ok := lookupJSONKey(obj, key) - if !ok { - continue - } - for _, part := range jsonStrings(value) { - for _, item := range strings.Split(part, ",") { - item = strings.TrimSpace(item) - if item == "" { - continue - } - dedupeKey := strings.ToLower(item) - if _, exists := seen[dedupeKey]; exists { - continue - } - seen[dedupeKey] = struct{}{} - out = append(out, item) - } - } - if len(out) > 0 { - return strings.Join(out, ",") - } - } - return "" -} - -func lookupJSONKey(obj map[string]any, key string) (any, bool) { - for existing, value := range obj { - if strings.EqualFold(strings.TrimSpace(existing), key) { - return value, true - } - } - return nil, false -} - -func jsonStrings(value any) []string { - switch v := value.(type) { - case string: - if text := strings.TrimSpace(v); text != "" { - return []string{text} - } - case []any: - out := make([]string, 0, len(v)) - for _, item := range v { - out = append(out, jsonStrings(item)...) - } - return out - case map[string]any: - return firstJSONStrings(v, "name", "title", "value", "iso_3166_1", "iso_639_1") - case float64: - if v > 0 { - return []string{strconv.Itoa(int(v))} - } - } - return nil -} - -func jsonInt(value any) int { - switch v := value.(type) { - case float64: - return int(v) - case string: - i, _ := strconv.Atoi(strings.TrimSpace(v)) - return i - } - return 0 -} - -func jsonFloat(value any) float32 { - switch v := value.(type) { - case float64: - return float32(v) - case string: - f, _ := strconv.ParseFloat(strings.TrimSpace(v), 32) - return float32(f) - } - return 0 -} - -func firstHTTPJSONValue(values []string) string { - for _, value := range values { - value = strings.TrimSpace(value) - if isHTTPURL(value) { - return value - } - } - return "" -} diff --git a/internal/service/cloud_metadata_merge.go b/internal/service/cloud_metadata_merge.go deleted file mode 100644 index e3b06b9..0000000 --- a/internal/service/cloud_metadata_merge.go +++ /dev/null @@ -1,159 +0,0 @@ -package service - -import "strings" - -func mergeCloudMetadata(dst, src *LocalMetadata) *LocalMetadata { - if src == nil { - return dst - } - if dst == nil { - return cloneLocalMetadata(src) - } - if src.Title != "" { - dst.Title = src.Title - } - if src.OriginalName != "" { - dst.OriginalName = src.OriginalName - } - if src.EpisodeTitle != "" { - dst.EpisodeTitle = src.EpisodeTitle - } - if src.AdultCode != "" { - dst.AdultCode = src.AdultCode - } - if src.Year > 0 { - dst.Year = src.Year - } - if src.ReleaseDate != "" { - dst.ReleaseDate = src.ReleaseDate - } - if src.Overview != "" { - dst.Overview = src.Overview - } - if src.Rating > 0 { - dst.Rating = src.Rating - } - if src.PosterURL != "" { - dst.PosterURL = src.PosterURL - } - if src.BackdropURL != "" { - dst.BackdropURL = src.BackdropURL - } - if src.TMDbID > 0 { - dst.TMDbID = src.TMDbID - } - if src.BangumiID > 0 { - dst.BangumiID = src.BangumiID - } - if src.DoubanID != "" { - dst.DoubanID = src.DoubanID - } - if src.TheTVDBID != "" { - dst.TheTVDBID = src.TheTVDBID - } - if src.SeasonNum > 0 || src.EpisodeNum > 0 { - dst.SeasonNum = src.SeasonNum - } - if src.EpisodeNum > 0 { - dst.EpisodeNum = src.EpisodeNum - } - if src.Genres != "" { - dst.Genres = src.Genres - } - if src.Countries != "" { - dst.Countries = src.Countries - } - if src.Languages != "" { - dst.Languages = src.Languages - } - dst.NSFW = dst.NSFW || src.NSFW - dst.HasNFO = dst.HasNFO || src.HasNFO - dst.HasArtwork = dst.HasArtwork || src.HasArtwork - dst.PathHint = dst.PathHint || src.PathHint - return dst -} - -func mergeCloudPathHintMetadata(dst, hint *LocalMetadata) *LocalMetadata { - if hint == nil { - return dst - } - if dst == nil || !dst.HasNFO { - return mergeCloudMetadata(dst, hint) - } - if dst.Title == "" { - dst.Title = hint.Title - } - if dst.OriginalName == "" { - dst.OriginalName = hint.OriginalName - } - if dst.Year == 0 { - dst.Year = hint.Year - } - if dst.ReleaseDate == "" { - dst.ReleaseDate = hint.ReleaseDate - } - if dst.TMDbID == 0 { - dst.TMDbID = hint.TMDbID - } - if dst.BangumiID == 0 { - dst.BangumiID = hint.BangumiID - } - if dst.DoubanID == "" { - dst.DoubanID = hint.DoubanID - } - if dst.TheTVDBID == "" { - dst.TheTVDBID = hint.TheTVDBID - } - dst.PathHint = dst.PathHint || hint.PathHint - return dst -} - -func cloneLocalMetadata(src *LocalMetadata) *LocalMetadata { - if src == nil { - return nil - } - cp := *src - return &cp -} - -func cloudMetadataUseful(meta *LocalMetadata) bool { - return meta != nil && (meta.HasNFO || meta.HasArtwork || localHasDescriptiveMetadata(meta)) -} - -func cloudPlaybackURL(typ, ref string) string { - return CloudArtworkURL(typ, ref) -} - -func joinCloudDisplayPath(parent, child string) string { - parent = strings.Trim(strings.ReplaceAll(strings.TrimSpace(parent), "\\", "/"), "/") - child = strings.Trim(strings.ReplaceAll(strings.TrimSpace(child), "\\", "/"), "/") - switch { - case parent == "": - return child - case child == "": - return parent - default: - return parent + "/" + child - } -} - -func pathBaseSlash(value string) string { - value = strings.Trim(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"), "/") - if value == "" { - return "" - } - parts := strings.Split(value, "/") - return parts[len(parts)-1] -} - -func pathDirSlash(value string) string { - value = strings.Trim(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"), "/") - if value == "" { - return "" - } - idx := strings.LastIndex(value, "/") - if idx < 0 { - return "" - } - return value[:idx] -} diff --git a/internal/service/cloud_mount.go b/internal/service/cloud_mount.go deleted file mode 100644 index bca4dc9..0000000 --- a/internal/service/cloud_mount.go +++ /dev/null @@ -1,143 +0,0 @@ -package service - -import ( - "net/url" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// CloudMountInfo is the canonical identity of a mounted cloud library. ScanDir -// is the provider id/path used for listing. DisplayDir is a hierarchical path -// used to prevent mounting both a parent and its child as separate libraries. -type CloudMountInfo struct { - Provider string - DisplayDir string - ScanDir string - Path string -} - -type CloudMountConflict struct { - Library model.Library `json:"library"` - Exact bool `json:"exact"` - Nested bool `json:"nested"` - ExistingIsAncestor bool `json:"existing_is_ancestor"` -} - -func BuildCloudLibraryPath(provider, scanDir, displayDir string) string { - provider = strings.TrimSpace(provider) - scanDir = normalizeCloudMountDir(provider, scanDir) - displayDir = normalizeCloudMountDir(provider, firstNonEmpty(displayDir, scanDir)) - if provider == "" { - return "" - } - base := "cloud://" + provider - if displayDir == "" { - if scanDir != "" { - return base + "?dir=" + url.QueryEscape(scanDir) - } - return base - } - path := base + "/" + url.PathEscape(displayDir) - if scanDir != "" && scanDir != displayDir { - path += "?dir=" + url.QueryEscape(scanDir) - } - return path -} - -func ParseCloudLibraryMount(raw string) (CloudMountInfo, bool) { - raw = strings.TrimSpace(raw) - if !strings.HasPrefix(strings.ToLower(raw), "cloud://") { - return CloudMountInfo{}, false - } - u, err := url.Parse(raw) - if err != nil || strings.ToLower(u.Scheme) != "cloud" { - return CloudMountInfo{}, false - } - provider := strings.TrimSpace(u.Host) - if provider == "" { - return CloudMountInfo{}, false - } - displayDir := strings.Trim(strings.TrimSpace(u.Path), "/") - if decoded, err := url.PathUnescape(displayDir); err == nil { - displayDir = decoded - } - scanDir := displayDir - if qDir := strings.TrimSpace(u.Query().Get("dir")); qDir != "" { - if decoded, err := url.QueryUnescape(qDir); err == nil { - qDir = decoded - } - scanDir = qDir - } - displayDir = normalizeCloudMountDir(provider, displayDir) - scanDir = normalizeCloudMountDir(provider, scanDir) - return CloudMountInfo{ - Provider: provider, - DisplayDir: displayDir, - ScanDir: scanDir, - Path: raw, - }, true -} - -func FindCloudMountConflict(libs []model.Library, provider, scanDir, displayDir string) *CloudMountConflict { - candidate := CloudMountInfo{ - Provider: strings.TrimSpace(provider), - DisplayDir: normalizeCloudMountDir(provider, firstNonEmpty(displayDir, scanDir)), - ScanDir: normalizeCloudMountDir(provider, scanDir), - } - for _, lib := range libs { - if CloudLibraryAutoCategory(lib) { - continue - } - existing, ok := ParseCloudLibraryMount(lib.Path) - if !ok || existing.Provider != candidate.Provider { - continue - } - if existing.DisplayDir == candidate.DisplayDir { - return &CloudMountConflict{Library: lib, Exact: true} - } - if existing.ScanDir != "" && candidate.ScanDir != "" && existing.ScanDir == candidate.ScanDir { - return &CloudMountConflict{Library: lib, Exact: true} - } - if cloudMountAncestor(candidate.DisplayDir, existing.DisplayDir) { - return &CloudMountConflict{Library: lib, Nested: true} - } - } - return nil -} - -func CloudLibraryShadowed(libs []model.Library, lib model.Library) *CloudMountConflict { - current, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - return nil - } - for _, existing := range libs { - if existing.ID == lib.ID || !existing.Enabled { - continue - } - if CloudLibraryAutoCategory(existing) { - continue - } - info, ok := ParseCloudLibraryMount(existing.Path) - if !ok || info.Provider != current.Provider { - continue - } - if info.DisplayDir == current.DisplayDir && existing.CreatedAt.Before(lib.CreatedAt) { - return &CloudMountConflict{Library: existing, Exact: true} - } - if cloudMountAncestor(current.DisplayDir, info.DisplayDir) { - return &CloudMountConflict{Library: existing, Nested: true} - } - } - return nil -} - -func FilterShadowedCloudLibraries(libs []model.Library) []model.Library { - out := make([]model.Library, 0, len(libs)) - for _, lib := range libs { - if CloudLibraryShadowed(libs, lib) == nil { - out = append(out, lib) - } - } - return out -} diff --git a/internal/service/cloud_mount_counts.go b/internal/service/cloud_mount_counts.go deleted file mode 100644 index 7c23268..0000000 --- a/internal/service/cloud_mount_counts.go +++ /dev/null @@ -1,38 +0,0 @@ -package service - -import ( - "context" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func cloudLibraryMediaCounts(ctx context.Context, repo *repository.Container, libs []model.Library) map[string]int64 { - counts := make(map[string]int64, len(libs)) - if repo == nil || repo.DB == nil || len(libs) == 0 { - return counts - } - ids := make([]string, 0, len(libs)) - for _, lib := range libs { - ids = append(ids, lib.ID) - } - if len(ids) == 0 { - return counts - } - var rows []struct { - LibraryID string - Count int64 - } - if err := repo.DB.WithContext(ctx). - Model(&model.Media{}). - Select("library_id, COUNT(*) AS count"). - Where("library_id IN ? AND deleted_at IS NULL", ids). - Group("library_id"). - Scan(&rows).Error; err != nil { - return counts - } - for _, row := range rows { - counts[row.LibraryID] = row.Count - } - return counts -} diff --git a/internal/service/cloud_mount_dedupe.go b/internal/service/cloud_mount_dedupe.go deleted file mode 100644 index 3bc9b96..0000000 --- a/internal/service/cloud_mount_dedupe.go +++ /dev/null @@ -1,122 +0,0 @@ -package service - -import ( - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func betterDisplayCloudLibrary(candidate, current model.Library, counts map[string]int64) bool { - candidateCount := counts[candidate.ID] - currentCount := counts[current.ID] - if (candidateCount > 0) != (currentCount > 0) { - return candidateCount > 0 - } - if candidate.Enabled != current.Enabled { - return candidate.Enabled - } - candidateCanonical := cloudLibraryPathIsCanonical(candidate) - currentCanonical := cloudLibraryPathIsCanonical(current) - if candidateCanonical != currentCanonical { - return candidateCanonical - } - if !candidate.CreatedAt.Equal(current.CreatedAt) { - return candidate.CreatedAt.After(current.CreatedAt) - } - return candidate.ID > current.ID -} - -func mergeDisplayCloudLibraries(libs []model.Library) []model.Library { - if len(libs) == 0 { - return libs - } - localByKey := make(map[string]struct{}, len(libs)) - for _, lib := range libs { - if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled { - continue - } - if key, ok := CloudLibraryMergeKey(lib); ok { - localByKey[key] = struct{}{} - } - } - out := make([]model.Library, 0, len(libs)) - for _, lib := range libs { - if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" { - lib.Name = displayName - if key, ok := CloudLibraryMergeKey(lib); ok { - if _, exists := localByKey[key]; exists && !CloudLibraryAutoCategory(lib) { - continue - } - } - } else if displayName := CanonicalLibraryDisplayName(lib); displayName != "" { - lib.Name = displayName - } - out = append(out, lib) - } - return out -} - -func dedupeDisplayLibrariesByMergeKey(libs []model.Library, counts map[string]int64) []model.Library { - if len(libs) == 0 { - return libs - } - out := make([]model.Library, 0, len(libs)) - byKey := make(map[string]int, len(libs)) - for _, lib := range libs { - if displayName := CanonicalLibraryDisplayName(lib); displayName != "" { - lib.Name = displayName - } - if CloudLibraryAutoCategory(lib) { - out = append(out, lib) - continue - } - key, ok := CloudLibraryMergeKey(lib) - if !ok { - out = append(out, lib) - continue - } - if prev, exists := byKey[key]; exists { - if betterCanonicalDisplayLibrary(lib, out[prev], counts) { - out[prev] = lib - } - continue - } - byKey[key] = len(out) - out = append(out, lib) - } - return out -} - -func betterCanonicalDisplayLibrary(candidate, current model.Library, counts map[string]int64) bool { - candidateScore := canonicalDisplayLibraryScore(candidate) - currentScore := canonicalDisplayLibraryScore(current) - if candidateScore != currentScore { - return candidateScore > currentScore - } - candidateCount := counts[candidate.ID] - currentCount := counts[current.ID] - if (candidateCount > 0) != (currentCount > 0) { - return candidateCount > 0 - } - if candidate.Enabled != current.Enabled { - return candidate.Enabled - } - if !candidate.CreatedAt.Equal(current.CreatedAt) { - return candidate.CreatedAt.After(current.CreatedAt) - } - return candidate.ID > current.ID -} - -func canonicalDisplayLibraryScore(lib model.Library) int { - score := 0 - if canonical := CanonicalLibraryDisplayName(lib); canonical == "" || strings.EqualFold(strings.TrimSpace(lib.Name), canonical) { - score += 4 - } - if canonical := canonicalLibraryCategoryName(lib.Type, pathBaseSlash(lib.Path)); canonical == "" { - score += 2 - } - if _, ok := ParseCloudLibraryMount(lib.Path); !ok { - score++ - } - return score -} diff --git a/internal/service/cloud_mount_display.go b/internal/service/cloud_mount_display.go deleted file mode 100644 index e7fbd6f..0000000 --- a/internal/service/cloud_mount_display.go +++ /dev/null @@ -1,234 +0,0 @@ -package service - -import ( - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func NormalizeCloudLibraryDisplayNames(libs []model.Library) []model.Library { - out := make([]model.Library, 0, len(libs)) - for _, lib := range libs { - if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" { - lib.Name = displayName - } else if displayName := CanonicalLibraryDisplayName(lib); displayName != "" { - lib.Name = displayName - } - out = append(out, lib) - } - return out -} - -func NormalizeCloudLibraryDisplay(libs []model.Library) []model.Library { - return normalizeDisplayLibraries(libs) -} - -func normalizeDisplayLibraries(libs []model.Library) []model.Library { - out := make([]model.Library, 0, len(libs)) - for _, lib := range libs { - if displayName, ok := CloudLibraryDisplayName(lib); ok && displayName != "" { - lib.Name = displayName - } else if displayName := CanonicalLibraryDisplayName(lib); displayName != "" { - lib.Name = displayName - } - if displayType := CanonicalLibraryDisplayType(lib); displayType != "" { - lib.Type = displayType - } - if displayPath := CanonicalLibraryDisplayPath(lib); displayPath != "" { - lib.Path = displayPath - } - out = append(out, lib) - } - return out -} - -func CloudLibraryDisplayName(lib model.Library) (string, bool) { - info, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - return "", false - } - name := stripCloudProviderDisplayPrefix(strings.TrimSpace(lib.Name), info.Provider) - dir := firstNonEmpty(info.DisplayDir, info.ScanDir) - if name == "" || strings.EqualFold(name, CloudMountProviderLabel(info.Provider)) { - if base := cloudMountDirBase(dir); base != "" { - name = base - } - } - if name == "" { - name = CloudMountProviderLabel(info.Provider) - } - if canonical := canonicalLibraryCategoryName(lib.Type, name); canonical != "" { - name = canonical - } else if canonical := canonicalLibraryCategoryNameAny(name); canonical != "" { - name = canonical - } - return name, true -} - -func CanonicalLibraryDisplayName(lib model.Library) string { - if canonical := canonicalLibraryCategoryName(lib.Type, lib.Name); canonical != "" { - return canonical - } - return canonicalLibraryCategoryNameAny(lib.Name) -} - -func CanonicalLibraryDisplayType(lib model.Library) string { - if displayName, ok := CloudLibraryDisplayName(lib); ok { - if typ := canonicalLibraryCategoryDisplayType(displayName); typ != "" { - return typ - } - } - if typ := canonicalLibraryCategoryDisplayType(lib.Name); typ != "" { - return typ - } - return canonicalLibraryCategoryDisplayType(pathBaseSlash(lib.Path)) -} - -func CanonicalLibraryDisplayPath(lib model.Library) string { - raw := strings.TrimSpace(lib.Path) - if raw == "" { - return "" - } - if info, ok := ParseCloudLibraryMount(raw); ok { - dir := firstNonEmpty(info.DisplayDir, info.ScanDir) - displayDir := canonicalLibraryDisplayDir(dir) - if displayDir == "" { - return raw - } - if CloudLibraryAutoCategory(lib) { - return BuildCloudAutoCategoryLibraryPathWithScanDir(info.Provider, info.ScanDir, displayDir) - } - return BuildCloudLibraryPath(info.Provider, info.ScanDir, displayDir) - } - return canonicalLocalLibraryDisplayPath(raw) -} - -func canonicalLibraryCategoryName(libraryType, name string) string { - typeKey := cloudLibraryMergeTypeKey(libraryType) - name = normalizeLibraryMergeName(name) - switch typeKey { - case "movie": - switch name { - case "国产电影", "大陆电影": - return "华语电影" - case "外语电影", "外国电影": - return "欧美电影" - case "日本电影", "韩国电影": - return "日韩电影" - case "音乐会", "concert": - return "演唱会" - case "纪录": - return "纪录片" - case "动漫电影": - return "动画电影" - } - case "tvshows": - switch name { - case "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "港剧", "台剧", "港台剧": - return "国产剧" - case "欧美电视剧", "美剧", "英剧", "未分类", "uncategorized": - return "欧美剧" - case "日韩电视剧", "日剧", "韩剧", "泰剧": - return "日韩剧" - case "真人秀": - return "综艺" - case "纪录": - return "纪录片" - case "少儿": - return "儿童" - case "国产动漫", "国产动画": - return "国漫" - case "日漫", "番剧", "日本动漫", "日本动画": - return "日番" - case "韩国动漫", "韩国动画": - return "韩漫" - case "欧美动漫", "欧美动画", "西方动画": - return "美漫" - case "其他动漫", "其它动漫", "other": - return "其他" - } - case "adult": - switch name { - case "9kg", "番号", "jav", "nsfw", "adult": - return "成人" - } - } - return "" -} - -func canonicalLibraryCategoryNameAny(name string) string { - for _, libraryType := range []string{"movie", "tv", "anime", "adult"} { - if canonical := canonicalLibraryCategoryName(libraryType, name); canonical != "" { - return canonical - } - } - return "" -} - -func canonicalLibraryDisplayDir(raw string) string { - parts := strmSlashParts(raw) - if len(parts) == 0 { - return "" - } - return strings.Join(canonicalLibraryDisplayParts(parts), "/") -} - -func canonicalLocalLibraryDisplayPath(raw string) string { - value := strings.TrimSpace(raw) - if value == "" { - return "" - } - sep := "/" - if strings.Contains(value, "\\") { - sep = "\\" - } - slash := strings.ReplaceAll(value, "\\", "/") - prefix := "" - for strings.HasPrefix(slash, "/") { - prefix += "/" - slash = strings.TrimPrefix(slash, "/") - } - parts := strings.Split(slash, "/") - canonical := canonicalLibraryDisplayParts(parts) - if len(canonical) == 0 { - return raw - } - out := prefix + strings.Join(canonical, "/") - if sep == "\\" { - out = strings.ReplaceAll(out, "/", "\\") - } - return out -} - -func canonicalLibraryDisplayParts(parts []string) []string { - out := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" || part == "." { - continue - } - if canonical := canonicalLibraryCategoryNameAny(part); canonical != "" { - part = canonical - } - if len(out) > 0 && normalizeLibraryMergeName(out[len(out)-1]) == normalizeLibraryMergeName(part) { - continue - } - out = append(out, part) - } - return out -} - -func canonicalLibraryCategoryDisplayType(name string) string { - switch normalizeLibraryMergeName(name) { - case "演唱会", "音乐会", "动画电影", "动漫电影", "华语电影", "国产电影", "大陆电影", "欧美电影", "外语电影", "外国电影", "日韩电影", "日本电影", "韩国电影": - return "movie" - case "国产剧", "国剧", "大陆剧", "华语剧", "国产电视剧", "大陆电视剧", "华语电视剧", "港剧", "台剧", "港台剧", "欧美剧", "欧美电视剧", "美剧", "英剧", "未分类", "uncategorized", "日韩剧", "日韩电视剧", "日剧", "韩剧", "泰剧", "综艺", "真人秀", "儿童", "少儿": - return "tv" - case "国漫", "国产动漫", "国产动画", "日番", "日漫", "番剧", "日本动漫", "日本动画", "韩漫", "韩国动漫", "韩国动画", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他", "其他动漫", "其它动漫", "other": - return "anime" - case "成人", "9kg", "番号", "jav", "nsfw", "adult": - return "adult" - default: - return "" - } -} diff --git a/internal/service/cloud_mount_filter.go b/internal/service/cloud_mount_filter.go deleted file mode 100644 index dff884a..0000000 --- a/internal/service/cloud_mount_filter.go +++ /dev/null @@ -1,123 +0,0 @@ -package service - -import ( - "context" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func FilterDisplayCloudLibraries(ctx context.Context, repo *repository.Container, libs []model.Library) []model.Library { - if len(libs) == 0 { - return libs - } - libs = FilterDeprecatedNativeCloudLibraries(libs) - libs = FilterMergedCloudAutoCategoryLibraries(libs) - counts := cloudLibraryMediaCounts(ctx, repo, libs) - collapsed := make([]model.Library, 0, len(libs)) - byKey := make(map[string]int, len(libs)) - for _, lib := range libs { - key, ok := cloudLibraryDisplayKey(lib) - if !ok { - collapsed = append(collapsed, lib) - continue - } - if prevIndex, exists := byKey[key]; exists { - if betterDisplayCloudLibrary(lib, collapsed[prevIndex], counts) { - collapsed[prevIndex] = lib - } - continue - } - byKey[key] = len(collapsed) - collapsed = append(collapsed, lib) - } - collapsed = FilterShadowedCloudLibraries(collapsed) - return normalizeDisplayLibraries(dedupeDisplayLibrariesByMergeKey(mergeDisplayCloudLibraries(collapsed), counts)) -} - -func FilterInternalCloudAutoCategoryLibraries(libs []model.Library) []model.Library { - if len(libs) == 0 { - return libs - } - out := make([]model.Library, 0, len(libs)) - for _, lib := range libs { - if CloudLibraryAutoCategory(lib) { - continue - } - out = append(out, lib) - } - return out -} - -func FilterMergedCloudAutoCategoryLibraries(libs []model.Library) []model.Library { - if len(libs) == 0 { - return libs - } - nonAutoKeys := make(map[string]struct{}, len(libs)) - for _, lib := range libs { - if CloudLibraryAutoCategory(lib) { - continue - } - if key, ok := CloudLibraryMergeKey(lib); ok { - nonAutoKeys[key] = struct{}{} - } - } - out := make([]model.Library, 0, len(libs)) - for _, lib := range libs { - if CloudLibraryAutoCategory(lib) { - if key, ok := CloudLibraryMergeKey(lib); ok { - if _, merged := nonAutoKeys[key]; merged { - continue - } - } - } - out = append(out, lib) - } - return out -} - -func FilterScannableCloudLibraries(ctx context.Context, repo *repository.Container, libs []model.Library) []model.Library { - if len(libs) == 0 { - return libs - } - counts := cloudLibraryMediaCounts(ctx, repo, libs) - collapsed := make([]model.Library, 0, len(libs)) - byKey := make(map[string]int, len(libs)) - for _, lib := range libs { - if CloudLibraryAutoCategory(lib) { - continue - } - if info, ok := ParseCloudLibraryMount(lib.Path); ok && IsDeprecatedNativeCloudProvider(info.Provider) { - continue - } - key, ok := cloudLibraryDisplayKey(lib) - if !ok { - collapsed = append(collapsed, lib) - continue - } - if prevIndex, exists := byKey[key]; exists { - if betterDisplayCloudLibrary(lib, collapsed[prevIndex], counts) { - collapsed[prevIndex] = lib - } - continue - } - byKey[key] = len(collapsed) - collapsed = append(collapsed, lib) - } - return FilterShadowedCloudLibraries(collapsed) -} - -func FilterDeprecatedNativeCloudLibraries(libs []model.Library) []model.Library { - if len(libs) == 0 { - return libs - } - out := make([]model.Library, 0, len(libs)) - for _, lib := range libs { - info, ok := ParseCloudLibraryMount(lib.Path) - if ok && IsDeprecatedNativeCloudProvider(info.Provider) { - continue - } - out = append(out, lib) - } - return out -} diff --git a/internal/service/cloud_mount_filter_test.go b/internal/service/cloud_mount_filter_test.go deleted file mode 100644 index 569ea3e..0000000 --- a/internal/service/cloud_mount_filter_test.go +++ /dev/null @@ -1,532 +0,0 @@ -package service - -import ( - "path/filepath" - "slices" - "strings" - "testing" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestFilterDisplayCloudLibrariesPrefersPopulatedCanonicalDuplicate(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - now := time.Now() - oldEmpty := model.Library{ - Base: model.Base{ID: "old-empty", CreatedAt: now.Add(-time.Hour)}, - Name: "OpenList · 国产剧", - Path: "cloud://openlist/%2F国产剧", - Type: "tv", - Enabled: true, - } - newPopulated := model.Library{ - Base: model.Base{ID: "new-populated", CreatedAt: now}, - Name: "OpenList · 国产剧", - Path: BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), - Type: "tv", - Enabled: true, - } - if err := repos.Library.Create(t.Context(), &oldEmpty); err != nil { - t.Fatal(err) - } - if err := repos.Library.Create(t.Context(), &newPopulated); err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.Media{ - LibraryID: newPopulated.ID, - Title: "剧集", - Path: "cloud://openlist/国产剧/剧集.mkv", - }).Error; err != nil { - t.Fatal(err) - } - - filtered := FilterDisplayCloudLibraries(t.Context(), repos, []model.Library{oldEmpty, newPopulated}) - if len(filtered) != 1 || filtered[0].ID != newPopulated.ID { - t.Fatalf("filtered = %#v, want only populated canonical duplicate", filtered) - } - - scanner := NewScannerService(nil, zap.NewNop(), repos, nil, nil, nil) - if conflict := scanner.shadowedCloudLibrary(t.Context(), &oldEmpty); conflict == nil || conflict.Library.ID != newPopulated.ID { - t.Fatalf("old duplicate scan conflict = %#v, want populated canonical library", conflict) - } -} - -func TestFilterDisplayCloudLibrariesMergesCloudMountIntoExistingLibrary(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - local := model.Library{Name: "国产剧", Path: "/media/国产剧", Type: "tv", Enabled: true} - cloud := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), Type: "tv", Enabled: true} - movieCloud := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/电影/国产剧", "/电影/国产剧"), Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud, &movieCloud} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - - filtered := FilterDisplayCloudLibraries(t.Context(), repos, []model.Library{local, cloud, movieCloud}) - if got := libraryNames(filtered); !slices.Equal(got, []string{"国产剧", "国产剧"}) { - t.Fatalf("filtered names = %#v, want local tv plus stripped movie cloud", got) - } - if filtered[0].ID != local.ID { - t.Fatalf("first filtered library = %s, want existing local library %s", filtered[0].ID, local.ID) - } - if filtered[1].ID != movieCloud.ID { - t.Fatalf("movie cloud library should stay separate when type differs: %#v", filtered) - } - - merged := MergedLibraryIDs([]model.Library{local, cloud, movieCloud}, local) - if !slices.Equal(merged, []string{local.ID, cloud.ID}) { - t.Fatalf("merged ids = %#v, want local+same-type cloud", merged) - } -} - -func TestFilterDisplayCloudLibrariesMergesEpisodicTypeAliases(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - local := model.Library{Name: "国漫", Path: "/media/动漫/国漫", Type: "tv", Enabled: true} - cloud := model.Library{Name: "OpenList · 国漫", Path: BuildCloudLibraryPath("openlist", "/国漫", "/国漫"), Type: "anime", Enabled: true} - movie := model.Library{Name: "国漫", Path: BuildCloudLibraryPath("openlist", "/电影/国漫", "/电影/国漫"), Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud, &movie} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - - filtered := FilterDisplayCloudLibraries(t.Context(), repos, []model.Library{local, cloud, movie}) - if got := libraryNames(filtered); !slices.Equal(got, []string{"国漫", "国漫"}) { - t.Fatalf("filtered names = %#v, want local episodic plus separate movie library", got) - } - if filtered[0].ID != local.ID || filtered[1].ID != movie.ID { - t.Fatalf("filtered libraries = %#v, want anime cloud merged into local tv but movie kept", filtered) - } - - merged := MergedLibraryIDs([]model.Library{local, cloud, movie}, local) - if !slices.Equal(merged, []string{local.ID, cloud.ID}) { - t.Fatalf("merged ids = %#v, want local tv + cloud anime only", merged) - } -} - -func TestFilterDisplayCloudLibrariesMergesCategoryNameAliases(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - foreignMovie := model.Library{Name: "外语电影", Path: "/media/电影/外语电影", Type: "movie", Enabled: true} - westernMovie := model.Library{Name: "OpenList · 欧美电影", Path: BuildCloudLibraryPath("openlist", "/欧美电影", "/欧美电影"), Type: "movie", Enabled: true} - eastAsianMovie := model.Library{Name: "OpenList · 日韩电影", Path: BuildCloudLibraryPath("openlist", "/日韩电影", "/日韩电影"), Type: "movie", Enabled: true} - jpAnime := model.Library{Name: "日番", Path: "/media/动漫/日番", Type: "tv", Enabled: true} - jpAnimeCloud := model.Library{Name: "OpenList · 日漫", Path: BuildCloudLibraryPath("openlist", "/日漫", "/日漫"), Type: "anime", Enabled: true} - for _, lib := range []*model.Library{&foreignMovie, &westernMovie, &eastAsianMovie, &jpAnime, &jpAnimeCloud} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - - filtered := FilterDisplayCloudLibraries(t.Context(), repos, []model.Library{foreignMovie, westernMovie, eastAsianMovie, jpAnime, jpAnimeCloud}) - if got := libraryNames(filtered); !slices.Equal(got, []string{"欧美电影", "日韩电影", "日番"}) { - t.Fatalf("filtered names = %#v, want legacy foreign movie merged into western movie plus anime aliases", got) - } - - movieMerged := MergedLibraryIDs([]model.Library{foreignMovie, westernMovie, eastAsianMovie, jpAnime, jpAnimeCloud}, foreignMovie) - if !slices.Equal(movieMerged, []string{foreignMovie.ID, westernMovie.ID}) { - t.Fatalf("movie merged ids = %#v, want legacy foreign movie merged with western movie", movieMerged) - } - animeMerged := MergedLibraryIDs([]model.Library{foreignMovie, westernMovie, eastAsianMovie, jpAnime, jpAnimeCloud}, jpAnime) - if !slices.Equal(animeMerged, []string{jpAnime.ID, jpAnimeCloud.ID}) { - t.Fatalf("anime merged ids = %#v, want jp anime aliases", animeMerged) - } -} - -func TestFilterDisplayCloudLibrariesCanonicalizesLegacyDisplayPaths(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - westernAnimation := model.Library{Name: "欧美动漫", Path: `F:\media\动漫\欧美动漫`, Type: "tv", Enabled: true} - uncategorizedCloud := model.Library{Name: "OpenList · 未分类", Path: BuildCloudLibraryPath("openlist", "/未分类", "/未分类"), Type: "movie", Enabled: true} - adult := model.Library{Name: "9KG", Path: `F:\media\成人\9KG`, Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&westernAnimation, &uncategorizedCloud, &adult} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - - filtered := FilterDisplayCloudLibraries(t.Context(), repos, []model.Library{westernAnimation, uncategorizedCloud, adult}) - if got := libraryNames(filtered); !slices.Equal(got, []string{"美漫", "欧美剧", "成人"}) { - t.Fatalf("filtered names = %#v, want canonical category names", got) - } - if got := []string{filtered[0].Type, filtered[1].Type, filtered[2].Type}; !slices.Equal(got, []string{"anime", "tv", "adult"}) { - t.Fatalf("filtered types = %#v, want canonical display types", got) - } - combined := strings.Join([]string{filtered[0].Path, filtered[1].Path, filtered[2].Path}, "\n") - for _, legacy := range []string{"欧美动漫", "未分类", "9KG"} { - if strings.Contains(combined, legacy) { - t.Fatalf("display paths contain legacy category %q: %s", legacy, combined) - } - } -} - -func TestCanonicalLibraryDisplayPathPreservesAutoCategoryScanDir(t *testing.T) { - raw := BuildCloudAutoCategoryLibraryPathWithScanDir("openlist", "国漫", "动漫/国产动漫") - - got := CanonicalLibraryDisplayPath(model.Library{Name: "国漫", Path: raw, Type: "anime", Enabled: true}) - info, ok := ParseCloudLibraryMount(got) - if !ok { - t.Fatalf("canonical path did not parse: %q", got) - } - if !CloudLibraryAutoCategory(model.Library{Path: got}) { - t.Fatalf("canonical path lost auto_category flag: %q", got) - } - if info.ScanDir != "国漫" || info.DisplayDir != "动漫/国漫" { - t.Fatalf("canonical path info = %#v, want scan 国漫 and canonical display 动漫/国漫", info) - } -} - -func TestListMediaVisibleDoesNotMergeDistinctMovieRegionLibraries(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - foreignMovie := model.Library{Name: "外语电影", Path: "/media/电影/外语电影", Type: "movie", Enabled: true} - westernMovie := model.Library{Name: "OpenList · 欧美电影", Path: BuildCloudLibraryPath("openlist", "/欧美电影", "/欧美电影"), Type: "movie", Enabled: true} - eastAsianMovie := model.Library{Name: "OpenList · 日韩电影", Path: BuildCloudLibraryPath("openlist", "/日韩电影", "/日韩电影"), Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&foreignMovie, &westernMovie, &eastAsianMovie} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - if err := repos.DB.Create(&model.Media{ - LibraryID: westernMovie.ID, - Title: "Western Movie", - Path: "cloud://openlist/欧美电影/Western.Movie.2026.mkv", - }).Error; err != nil { - t.Fatal(err) - } - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - - items, total, err := svc.ListMediaVisible(t.Context(), foreignMovie.ID, 1, 20, MediaVisibility{IncludeNSFW: true}) - if err != nil { - t.Fatal(err) - } - if total != 1 || !slices.Equal(mediaTitles(items), []string{"Western Movie"}) { - t.Fatalf("legacy foreign movie items total=%d items=%#v, want merged western media", total, mediaTitles(items)) - } - - items, total, err = svc.ListMediaVisible(t.Context(), eastAsianMovie.ID, 1, 20, MediaVisibility{IncludeNSFW: true}) - if err != nil { - t.Fatal(err) - } - if total != 0 || len(items) != 0 { - t.Fatalf("east asian movie items total=%d items=%#v, want empty isolated library", total, mediaTitles(items)) - } - - items, total, err = svc.ListMediaVisible(t.Context(), westernMovie.ID, 1, 20, MediaVisibility{IncludeNSFW: true}) - if err != nil { - t.Fatal(err) - } - if total != 1 || !slices.Equal(mediaTitles(items), []string{"Western Movie"}) { - t.Fatalf("western movie items total=%d items=%#v, want own media only", total, mediaTitles(items)) - } -} - -func TestFilterDeprecatedNativeCloudLibrariesHidesPopulatedHistory(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - emptyQuark := model.Library{Name: "旧 Quark 空库", Path: "cloud://quark/0", Type: "movie", Enabled: true} - populatedQuark := model.Library{Name: "旧 Quark 有数据", Path: "cloud://quark/archive", Type: "movie", Enabled: true} - openList := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&emptyQuark, &populatedQuark, &openList} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - if err := repos.DB.Create(&model.Media{ - LibraryID: populatedQuark.ID, - Title: "历史媒体", - Path: "cloud://quark/archive/movie.mkv", - }).Error; err != nil { - t.Fatal(err) - } - - filtered := FilterDeprecatedNativeCloudLibraries([]model.Library{emptyQuark, populatedQuark, openList}) - if got := libraryNames(filtered); !slices.Equal(got, []string{"OpenList"}) { - t.Fatalf("filtered names = %#v, want only supported cloud libraries", got) - } - - displayed := FilterDisplayCloudLibraries(t.Context(), repos, []model.Library{emptyQuark, populatedQuark, openList}) - if got := libraryNames(displayed); !slices.Equal(got, []string{"OpenList"}) { - t.Fatalf("display names = %#v, want deprecated cloud hidden", got) - } -} - -func TestListMediaVisibleIncludesMergedCloudLibraryItems(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - local := model.Library{Name: "国产剧", Path: "/media/国产剧", Type: "tv", Enabled: true} - cloud := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), Type: "tv", Enabled: true} - other := model.Library{Name: "欧美剧", Path: BuildCloudLibraryPath("openlist", "/欧美剧", "/欧美剧"), Type: "tv", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud, &other} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - if err := repos.DB.Create(&[]model.Media{ - {LibraryID: local.ID, Title: "本地剧", Path: "/media/国产剧/local.mkv"}, - {LibraryID: cloud.ID, Title: "云盘剧", Path: "cloud://openlist/国产剧/cloud.mkv"}, - {LibraryID: other.ID, Title: "其他剧", Path: "cloud://openlist/欧美剧/other.mkv"}, - }).Error; err != nil { - t.Fatal(err) - } - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - - items, total, err := svc.ListMediaVisible(t.Context(), local.ID, 1, 20, MediaVisibility{IncludeNSFW: true}) - if err != nil { - t.Fatal(err) - } - if total != 2 { - t.Fatalf("total = %d, want merged local+cloud items", total) - } - if got := mediaTitles(items); !slices.Equal(got, []string{"云盘剧", "本地剧"}) { - t.Fatalf("items = %#v, want local+cloud only", got) - } - if cloudItem := mediaByTitle(items, "云盘剧"); cloudItem == nil || cloudItem.DisplayLibraryID != local.ID { - t.Fatalf("cloud item display library = %#v, want merged local library %s", cloudItem, local.ID) - } - - items, total, err = svc.ListMediaVisible(t.Context(), local.ID, 1, 20, MediaVisibility{ - IncludeNSFW: true, - AllowedLibraryIDs: []string{local.ID}, - }) - if err != nil { - t.Fatal(err) - } - if total != 2 || !slices.Equal(mediaTitles(items), []string{"云盘剧", "本地剧"}) { - t.Fatalf("profile-limited merged list total=%d items=%#v", total, mediaTitles(items)) - } - - searchItems, err := svc.SearchMediaVisible(t.Context(), "剧", 20, MediaVisibility{ - IncludeNSFW: true, - AllowedLibraryIDs: []string{local.ID}, - }) - if err != nil { - t.Fatal(err) - } - if got := mediaTitles(searchItems); !slices.Equal(got, []string{"云盘剧", "本地剧"}) { - t.Fatalf("profile-limited merged search items=%#v, want local+hidden cloud", got) - } - if cloudItem := mediaByTitle(searchItems, "云盘剧"); cloudItem == nil || cloudItem.DisplayLibraryID != local.ID { - t.Fatalf("search cloud item display library = %#v, want merged local library %s", cloudItem, local.ID) - } -} - -func TestListMediaVisibleUsesSpecificCloudChildLibraryAsDisplayTarget(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "tv", Enabled: true} - child := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), Type: "tv", Enabled: true} - for _, lib := range []*model.Library{&root, &child} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - if err := repos.DB.Create(&model.Media{ - LibraryID: root.ID, - Title: "折腰", - Path: "cloud://openlist/国产剧/折腰 (2025)/Season 1/折腰.S01E01.mkv", - }).Error; err != nil { - t.Fatal(err) - } - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - - items, total, err := svc.ListMediaVisible(t.Context(), root.ID, 1, 20, MediaVisibility{IncludeNSFW: true}) - if err != nil { - t.Fatal(err) - } - if total != 1 || len(items) != 1 { - t.Fatalf("items total=%d len=%d, want one root cloud item", total, len(items)) - } - if items[0].DisplayLibraryID != child.ID { - t.Fatalf("display library = %q, want child cloud library %q", items[0].DisplayLibraryID, child.ID) - } - if items[0].DisplayLibraryPath != child.Path { - t.Fatalf("display library path = %q, want %q", items[0].DisplayLibraryPath, child.Path) - } -} - -func TestGetMediaUsesMappedLocalCategoryLibraryAsDisplayTarget(t *testing.T) { - containerRoot := filepath.Join(t.TempDir(), "media") - t.Setenv("MEDIASTATION_MEDIA_CONTAINER_DIR", containerRoot) - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - parent := model.Library{Name: "电视剧", Path: containerRoot, Type: "tv", Enabled: true} - child := model.Library{Name: "国产剧", Path: filepath.Join("media", "电视剧", "国产剧"), Type: "tv", Enabled: true} - for _, lib := range []*model.Library{&parent, &child} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - mediaPath := filepath.Join(containerRoot, "电视剧", "国产剧", "剧集", "Season 01", "剧集 - S01E01.mkv") - if err := repos.DB.Create(&model.Media{ - Base: model.Base{ID: "media-1"}, - LibraryID: parent.ID, - Title: "剧集", - Path: mediaPath, - SeasonNum: 1, - EpisodeNum: 1, - }).Error; err != nil { - t.Fatal(err) - } - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - - got, err := svc.GetMedia(t.Context(), "media-1") - if err != nil { - t.Fatal(err) - } - if got.DisplayLibraryID != child.ID { - t.Fatalf("display library = %q, want mapped child library %q", got.DisplayLibraryID, child.ID) - } - if got.DisplayLibraryPath != filepath.Clean(filepath.Join(containerRoot, "电视剧", "国产剧")) { - t.Fatalf("display library path = %q, want mapped child path", got.DisplayLibraryPath) - } -} - -func TestStartAllCloudLibraryScansIncludesMergedCloudMounts(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - local := model.Library{Name: "国产剧", Path: "/media/国产剧", Type: "tv", Enabled: true} - cloud := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), Type: "tv", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - - statuses, err := scanner.StartAllCloudLibraryScans() - if err != nil { - t.Fatal(err) - } - if len(statuses) != 1 || statuses[0].LibraryID != cloud.ID { - t.Fatalf("scan-all statuses = %#v, want merged cloud library queued", statuses) - } -} - -func TestAutoCategoryCloudLibrariesMergeIntoExistingDisplayLibrary(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - local := model.Library{Name: "欧美剧", Path: "/media/电视剧/欧美剧", Type: "tv", Enabled: true} - root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - auto := model.Library{Name: "欧美剧", Path: BuildCloudAutoCategoryLibraryPath("openlist", "电视剧/欧美剧"), Type: "tv", Enabled: true} - for _, lib := range []*model.Library{&local, &root, &auto} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - - libs, err := repos.Library.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if shadow := CloudLibraryShadowed(libs, root); shadow != nil { - t.Fatalf("auto category should not shadow root scan: %#v", shadow) - } - display := FilterDisplayCloudLibraries(t.Context(), repos, libs) - if got := libraryNames(display); !slices.Equal(got, []string{"欧美剧", "OpenList"}) { - t.Fatalf("display libraries = %#v, want local library and user-mounted root only", got) - } - scannable := FilterScannableCloudLibraries(t.Context(), repos, libs) - if got := libraryNames(scannable); !slices.Equal(got, []string{"欧美剧", "OpenList"}) { - t.Fatalf("scannable libraries = %#v, want local library and root only", got) - } - - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - statuses, err := scanner.StartAllCloudLibraryScans() - if err != nil { - t.Fatal(err) - } - if len(statuses) != 1 || statuses[0].LibraryID != root.ID { - t.Fatalf("scan-all statuses = %#v, want only cloud root queued", statuses) - } -} - -func TestRootCloudLibraryIncludesAutoCategoryMedia(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - auto := model.Library{Name: "欧美剧", Path: BuildCloudAutoCategoryLibraryPath("openlist", "电视剧/欧美剧"), Type: "tv", Enabled: true} - for _, lib := range []*model.Library{&root, &auto} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - if err := repos.DB.Create(&model.Media{ - LibraryID: auto.ID, - Title: "The Show", - Path: "cloud://openlist/电视剧/欧美剧/The Show/The.Show.S01E01.mkv", - }).Error; err != nil { - t.Fatal(err) - } - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - - items, total, err := svc.ListMediaVisible(t.Context(), root.ID, 1, 20, MediaVisibility{IncludeNSFW: true}) - if err != nil { - t.Fatal(err) - } - if total != 1 || len(items) != 1 { - t.Fatalf("root cloud items total=%d len=%d, want auto-category media", total, len(items)) - } - if items[0].LibraryName != auto.Name || items[0].LibraryPath != auto.Path { - t.Fatalf("media library metadata = (%q, %q), want auto category", items[0].LibraryName, items[0].LibraryPath) - } - if items[0].DisplayLibraryID != auto.ID || items[0].DisplayLibraryPath != auto.Path { - t.Fatalf("display library = (%q, %q), want auto category", items[0].DisplayLibraryID, items[0].DisplayLibraryPath) - } -} - -func TestStartAllCloudLibraryScansSkipsDeprecatedQuarkMounts(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - quark := model.Library{Name: "旧 Quark", Path: "cloud://quark/0", Type: "movie", Enabled: true} - openList := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&quark, &openList} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - - statuses, err := scanner.StartAllCloudLibraryScans() - if err != nil { - t.Fatal(err) - } - if len(statuses) != 1 || statuses[0].Provider != "openlist" { - t.Fatalf("scan-all statuses = %#v, want only openlist", statuses) - } -} - -func libraryNames(libs []model.Library) []string { - out := make([]string, 0, len(libs)) - for _, lib := range libs { - out = append(out, lib.Name) - } - return out -} - -func mediaTitles(items []model.Media) []string { - out := make([]string, 0, len(items)) - for _, item := range items { - out = append(out, item.Title) - } - slices.Sort(out) - return out -} - -func mediaByTitle(items []model.Media, title string) *model.Media { - for i := range items { - if items[i].Title == title { - return &items[i] - } - } - return nil -} diff --git a/internal/service/cloud_mount_label.go b/internal/service/cloud_mount_label.go deleted file mode 100644 index 7fbd55e..0000000 --- a/internal/service/cloud_mount_label.go +++ /dev/null @@ -1,136 +0,0 @@ -package service - -import ( - "net/url" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -func CloudMountProviderLabel(provider string) string { - switch strings.TrimSpace(provider) { - case LegacyQuarkProvider: - return "已停用网盘" - case cloud.Type115: - return "115 网盘" - case cloud.TypeCloudDrive2: - return "CloudDrive2" - case cloud.TypeOpenList: - return "OpenList" - default: - if strings.TrimSpace(provider) == "" { - return "网盘" - } - return strings.TrimSpace(provider) - } -} - -func stripCloudProviderDisplayPrefix(name, provider string) string { - name = strings.TrimSpace(name) - if name == "" { - return "" - } - for _, label := range []string{CloudMountProviderLabel(provider), strings.TrimSpace(provider)} { - label = strings.TrimSpace(label) - if label == "" || len(name) < len(label) || !strings.EqualFold(name[:len(label)], label) { - continue - } - rest := strings.TrimSpace(name[len(label):]) - rest = strings.TrimLeft(rest, " \t\r\n·・-—–||:/\\") - if rest != "" { - return strings.TrimSpace(rest) - } - if strings.EqualFold(name, label) { - return "" - } - } - return name -} - -func cloudMountDirBase(dir string) string { - dir = strings.Trim(strings.TrimSpace(strings.ReplaceAll(dir, "\\", "/")), "/") - if dir == "" { - return "" - } - parts := strings.Split(dir, "/") - for i := len(parts) - 1; i >= 0; i-- { - if part := strings.TrimSpace(parts[i]); part != "" { - return part - } - } - return "" -} - -func normalizeLibraryMergeName(name string) string { - name = strings.ToLower(strings.TrimSpace(name)) - if name == "" { - return "" - } - return strings.Join(strings.Fields(name), " ") -} - -func ShadowedCloudLibraryIDSet(libs []model.Library) map[string]bool { - out := make(map[string]bool) - for _, lib := range libs { - if CloudLibraryShadowed(libs, lib) != nil { - out[lib.ID] = true - } - } - return out -} - -func InferCloudMountMediaType(dir, name string) string { - text := strings.ToLower(dir + " " + name) - switch { - case strings.Contains(text, "成人") || strings.Contains(text, "adult") || strings.Contains(text, "jav") || strings.Contains(text, "9kg"): - return "adult" - case containsAny(text, "动画电影", "华语电影", "外语电影", "外国电影", "欧美电影", "日韩电影", "韩国电影", "日本电影", "港台电影", "香港电影", "台湾电影", "大陆电影", "国产电影", "纪录片", "演唱会", "音乐会", "电影", "movie", "movies", "film", "films", "documentary", "concert"): - return "movie" - case containsAny(text, "综艺", "真人秀", "脱口秀", "晚会", "variety"): - return "variety" - case containsAny(text, "国漫", "日漫", "日番", "韩漫", "美漫", "番剧", "动漫", "欧美动漫", "动画剧集", "anime"): - return "anime" - case containsAny(text, "国产剧", "大陆剧", "华语剧", "欧美剧", "日韩剧", "韩剧", "日剧", "港剧", "台剧", "泰剧", "英剧", "美剧", "短剧", "电视剧", "剧集", "连续剧", "series", "tv", "shows"): - return "tv" - default: - return "movie" - } -} - -func containsAny(text string, values ...string) bool { - for _, value := range values { - if strings.Contains(text, value) { - return true - } - } - return false -} - -func cloudMountAncestor(parent, child string) bool { - parent = strings.Trim(parent, "/") - child = strings.Trim(child, "/") - if parent == child { - return false - } - if parent == "" { - return child != "" - } - return strings.HasPrefix(child, parent+"/") -} - -func normalizeCloudMountDir(provider, value string) string { - value = strings.TrimSpace(value) - if decoded, err := url.PathUnescape(value); err == nil { - value = decoded - } - if decoded, err := url.QueryUnescape(value); err == nil { - value = decoded - } - value = strings.ReplaceAll(value, "\\", "/") - value = strings.Trim(strings.TrimSpace(value), "/") - if value == "." || ((provider == cloud.Type115 || provider == LegacyQuarkProvider) && value == "0") { - return "" - } - return value -} diff --git a/internal/service/cloud_mount_merge.go b/internal/service/cloud_mount_merge.go deleted file mode 100644 index f7e8b65..0000000 --- a/internal/service/cloud_mount_merge.go +++ /dev/null @@ -1,90 +0,0 @@ -package service - -import ( - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func cloudLibraryDisplayKey(lib model.Library) (string, bool) { - info, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - return "", false - } - dir := firstNonEmpty(info.DisplayDir, info.ScanDir) - return info.Provider + "\x00" + dir, true -} - -func cloudLibraryPathIsCanonical(lib model.Library) bool { - info, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - return false - } - return BuildCloudLibraryPath(info.Provider, info.ScanDir, info.DisplayDir) == strings.TrimSpace(lib.Path) -} - -func CloudLibraryMergeKey(lib model.Library) (string, bool) { - name := strings.TrimSpace(lib.Name) - if displayName, ok := CloudLibraryDisplayName(lib); ok { - name = displayName - } - name = normalizeLibraryMergeName(name) - if name == "" { - return "", false - } - typeKey := cloudLibraryMergeTypeKey(lib.Type) - return typeKey + "\x00" + cloudLibraryMergeNameKey(typeKey, name), true -} - -func cloudLibraryMergeTypeKey(libraryType string) string { - switch strings.ToLower(strings.TrimSpace(libraryType)) { - case "tv", "anime", "variety": - return "tvshows" - default: - return strings.ToLower(strings.TrimSpace(libraryType)) - } -} - -func cloudLibraryMergeNameKey(typeKey, name string) string { - switch typeKey { - case "movie": - switch name { - case "国产电影", "大陆电影": - return "华语电影" - case "华语电影": - return "华语电影" - case "外语电影", "外国电影", "欧美电影": - return "欧美电影" - case "日韩电影", "日本电影", "韩国电影": - return "日韩电影" - case "纪录", "纪录片": - return "纪录片" - case "演唱会", "concert": - return "演唱会" - case "动画电影", "动漫电影": - return "动画电影" - } - case "tvshows": - switch name { - case "国产剧", "大陆剧", "华语剧", "国剧", "国产电视剧", "大陆电视剧", "华语电视剧", "港剧", "台剧", "港台剧": - return "国产剧" - case "欧美剧", "欧美电视剧", "美剧", "英剧": - return "欧美剧" - case "日韩剧", "日韩电视剧", "日剧", "韩剧", "泰剧": - return "日韩剧" - case "国漫", "国产动漫", "国产动画": - return "国漫" - case "日番", "日漫", "番剧", "日本动漫", "日本动画": - return "日番" - case "韩漫", "韩国动漫", "韩国动画": - return "韩漫" - case "美漫", "欧美动漫", "欧美动画", "西方动画": - return "美漫" - case "其他", "其他动漫", "其它动漫", "other": - return "其他" - case "纪录", "纪录片": - return "纪录片" - } - } - return name -} diff --git a/internal/service/cloud_mount_scope.go b/internal/service/cloud_mount_scope.go deleted file mode 100644 index 9fe8505..0000000 --- a/internal/service/cloud_mount_scope.go +++ /dev/null @@ -1,152 +0,0 @@ -package service - -import ( - "context" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func MergedLibraryIDsForLibrary(ctx context.Context, repo *repository.Container, libraryID string) ([]string, error) { - libraryID = strings.TrimSpace(libraryID) - if libraryID == "" || repo == nil || repo.Library == nil { - return []string{libraryID}, nil - } - lib, err := repo.Library.FindByID(ctx, libraryID) - if err != nil { - return nil, err - } - if lib == nil { - return []string{libraryID}, nil - } - libs, err := repo.Library.List(ctx) - if err != nil { - return nil, err - } - return MergedLibraryIDs(libs, *lib), nil -} - -func MergedLibraryIDs(libs []model.Library, target model.Library) []string { - ids := appendUniqueLibraryIDs(nil, target.ID) - if rootAutoIDs := cloudRootAutoCategoryLibraryIDs(libs, target); len(rootAutoIDs) > 0 { - ids = appendUniqueLibraryIDs(ids, rootAutoIDs...) - } - targetKey, hasTargetKey := CloudLibraryMergeKey(target) - if !hasTargetKey { - return ids - } - _, targetIsCloud := ParseCloudLibraryMount(target.Path) - for _, candidate := range libs { - if candidate.ID == target.ID || strings.TrimSpace(candidate.ID) == "" || !candidate.Enabled { - continue - } - key, ok := CloudLibraryMergeKey(candidate) - if ok && key == targetKey { - _, candidateIsCloud := ParseCloudLibraryMount(candidate.Path) - if !targetIsCloud && !candidateIsCloud { - continue - } - ids = appendUniqueLibraryIDs(ids, candidate.ID) - } - } - return ids -} - -func cloudRootAutoCategoryLibraryIDs(libs []model.Library, lib model.Library) []string { - mount, ok := ParseCloudLibraryMount(lib.Path) - if !ok || !cloudRootMountNeedsAutoCategory(mount) { - return nil - } - ids := make([]string, 0) - for _, candidate := range libs { - if candidate.ID == lib.ID || !candidate.Enabled || !CloudLibraryAutoCategory(candidate) { - continue - } - info, ok := ParseCloudLibraryMount(candidate.Path) - if ok && info.Provider == mount.Provider { - ids = appendUniqueLibraryIDs(ids, candidate.ID) - } - } - return ids -} - -func ExpandMediaVisibilityForMergedCloudLibraries(ctx context.Context, repo *repository.Container, visibility MediaVisibility) MediaVisibility { - if repo == nil || repo.Library == nil { - return visibility - } - libs, err := repo.Library.List(ctx) - if err != nil { - return visibility - } - if len(visibility.AllowedLibraryIDs) > 0 { - visibility.AllowedLibraryIDs = expandMergedLibraryIDsFromLibraries(libs, visibility.AllowedLibraryIDs) - } - if len(visibility.HiddenLibraryIDs) > 0 { - visibility.HiddenLibraryIDs = expandMergedLibraryIDsFromLibraries(libs, visibility.HiddenLibraryIDs) - } - visibility.HiddenLibraryIDs = appendUniqueLibraryIDs(visibility.HiddenLibraryIDs, DeprecatedNativeCloudLibraryIDs(libs)...) - return visibility -} - -func expandMergedLibraryIDs(ctx context.Context, repo *repository.Container, ids []string) []string { - if len(ids) == 0 || repo == nil || repo.Library == nil { - return ids - } - libs, err := repo.Library.List(ctx) - if err != nil { - return ids - } - return expandMergedLibraryIDsFromLibraries(libs, ids) -} - -func expandMergedLibraryIDsFromLibraries(libs []model.Library, ids []string) []string { - byID := make(map[string]model.Library, len(libs)) - for _, lib := range libs { - byID[lib.ID] = lib - } - out := make([]string, 0, len(ids)) - for _, id := range ids { - id = strings.TrimSpace(id) - if id == "" { - continue - } - if lib, ok := byID[id]; ok { - out = appendUniqueLibraryIDs(out, MergedLibraryIDs(libs, lib)...) - continue - } - out = appendUniqueLibraryIDs(out, id) - } - return out -} - -func DeprecatedNativeCloudLibraryIDs(libs []model.Library) []string { - ids := make([]string, 0) - for _, lib := range libs { - info, ok := ParseCloudLibraryMount(lib.Path) - if ok && IsDeprecatedNativeCloudProvider(info.Provider) { - ids = appendUniqueLibraryIDs(ids, lib.ID) - } - } - return ids -} - -func appendUniqueLibraryIDs(ids []string, values ...string) []string { - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" { - continue - } - exists := false - for _, id := range ids { - if id == value { - exists = true - break - } - } - if !exists { - ids = append(ids, value) - } - } - return ids -} diff --git a/internal/service/cloud_path_repair.go b/internal/service/cloud_path_repair.go deleted file mode 100644 index cc4a11f..0000000 --- a/internal/service/cloud_path_repair.go +++ /dev/null @@ -1,122 +0,0 @@ -package service - -import ( - "context" - "strings" - - "go.uber.org/zap" - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// RepairCloudPathMetadata backfills external IDs from media paths such as -// "Movie (2025) {tmdb-123}" so existing placeholder rows can be scraped -// without requiring another successful filesystem or cloud provider traversal. -// -// 传入 libraryID 时只修复这些媒体库的行;为空则修复全库。 -func (c *Container) RepairCloudPathMetadata(ctx context.Context, libraryID ...string) (int, error) { - if c == nil || c.Repo == nil || c.Repo.DB == nil { - return 0, nil - } - libraryIDs := compactLibraryIDs(libraryID...) - var repaired int - var rows []model.Media - query := c.Repo.DB.WithContext(ctx). - Model(&model.Media{}). - Select("id, title, path, year, season_num, episode_num, scrape_status, tm_db_id, bangumi_id, douban_id, thetvdb_id"). - Where("("+strings.Join([]string{ - "LOWER(path) LIKE ?", - "LOWER(path) LIKE ?", - "LOWER(path) LIKE ?", - "LOWER(path) LIKE ?", - "LOWER(path) LIKE ?", - "LOWER(path) LIKE ?", - "LOWER(path) LIKE ?", - "LOWER(path) LIKE ?", - }, " OR ")+")", - "%tmdb%", "%tmdbid%", "%douban%", "%db%", "%bangumi%", "%bgm%", "%thetvdb%", "%tvdb%") - if len(libraryIDs) > 0 { - query = query.Where("library_id IN ?", libraryIDs) - } - - err := query.FindInBatches(&rows, 500, func(_ *gorm.DB, _ int) error { - for _, row := range rows { - meta, hints := pathHintMetadata(row.Path, row.SeasonNum > 0 || row.EpisodeNum > 0) - if meta == nil || !hints.useful() { - continue - } - updates := map[string]any{} - status := strings.TrimSpace(row.ScrapeStatus) - enrichable := status == "" || status == "pending" || status == "no_match" - changedExternalID := false - if meta.TMDbID > 0 && row.TMDbID != meta.TMDbID { - updates["tm_db_id"] = meta.TMDbID - changedExternalID = true - } - if meta.BangumiID > 0 && row.BangumiID != meta.BangumiID { - updates["bangumi_id"] = meta.BangumiID - changedExternalID = true - } - if strings.TrimSpace(meta.DoubanID) != "" && strings.TrimSpace(row.DoubanID) != strings.TrimSpace(meta.DoubanID) { - updates["douban_id"] = strings.TrimSpace(meta.DoubanID) - changedExternalID = true - } - if strings.TrimSpace(meta.TheTVDBID) != "" && strings.TrimSpace(row.TheTVDBID) != strings.TrimSpace(meta.TheTVDBID) { - updates["thetvdb_id"] = strings.TrimSpace(meta.TheTVDBID) - changedExternalID = true - } - if meta.Year > 0 && row.Year <= 0 { - updates["year"] = meta.Year - } - if enrichable && strings.TrimSpace(meta.Title) != "" && cloudPathRepairShouldReplaceTitle(row.Title, meta.Title) { - updates["title"] = strings.TrimSpace(meta.Title) - } - if changedExternalID && (status == "" || status == "no_match" || status == "matched") { - updates["scrape_status"] = "pending" - } - if len(updates) == 0 { - continue - } - if err := c.Repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", row.ID).Updates(updates).Error; err != nil { - return err - } - repaired++ - } - return nil - }).Error - if err != nil { - return repaired, err - } - if repaired > 0 && c.Log != nil { - c.Log.Info("cloud path metadata repaired", zap.Int("media_count", repaired)) - } - return repaired, nil -} - -func cloudPathRepairShouldReplaceTitle(current, hinted string) bool { - current = strings.TrimSpace(current) - hinted = strings.TrimSpace(hinted) - if hinted == "" || strings.EqualFold(current, hinted) { - return false - } - if current == "" { - return true - } - noise := []string{"web-dl", "bluray", "hdtv", "2160p", "1080p", "720p", "ddp", "aac", "h.264", "h.265", "x264", "x265", "adweb", "mweb", "cmctv", "bit"} - lower := strings.ToLower(current) - for _, token := range noise { - if strings.Contains(lower, token) { - return true - } - } - return len([]rune(current)) > len([]rune(hinted))*2 -} - -func compactLibraryIDs(ids ...string) []string { - out := make([]string, 0, len(ids)) - for _, id := range ids { - out = appendUniqueLibraryIDs(out, id) - } - return out -} diff --git a/internal/service/cloud_path_repair_test.go b/internal/service/cloud_path_repair_test.go deleted file mode 100644 index 9d9aae7..0000000 --- a/internal/service/cloud_path_repair_test.go +++ /dev/null @@ -1,233 +0,0 @@ -package service - -import ( - "os" - "path/filepath" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestRepairAndRescrapeLibraryForceRematchesThenReclassifies(t *testing.T) { - scraper, repos, closeServer := newTestScraper(t) - defer closeServer() - - root := t.TempDir() - wrongRoot := filepath.Join(root, "media", "电视剧", "国产剧") - mediaPath := filepath.Join(wrongRoot, "Spy Family", "Season 01", "Spy Family - S01E01.mkv") - if err := os.MkdirAll(filepath.Dir(mediaPath), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(mediaPath, []byte("episode"), 0o644); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "国产剧", Path: wrongRoot, Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - media := model.Media{ - LibraryID: lib.ID, - Title: "错误旧匹配", - Path: mediaPath, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 999, - Countries: "CN", - Languages: "zh", - Genres: "Drama", - ScrapeStatus: "matched", - } - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - - cfg := &config.Config{} - cfg.Organizer.SmartClassify = true - organizer := NewOrganizerService(cfg, zap.NewNop(), repos) - organizer.SetScraper(scraper) - container := &Container{Cfg: cfg, Log: zap.NewNop(), Repo: repos, Scraper: scraper, Organizer: organizer} - result, err := container.RepairAndRescrapeLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("repair and rescrape: %v", err) - } - if result.Reclassified != 1 { - t.Fatalf("result=%+v, want one corrected classification", result) - } - want := filepath.Join(root, "media", "动漫", "日番", "间谍过家家", "Season 01", "间谍过家家 - S01E01.mkv") - if _, err := os.Stat(want); err != nil { - t.Fatalf("corrected media missing at %q: %v", want, err) - } - var got model.Media - if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil { - t.Fatal(err) - } - if got.TMDbID != 12345 || got.Countries != "JP" || got.Path != want { - t.Fatalf("repaired media=%#v, want rematched Japanese anime at corrected path", got) - } -} - -func TestRepairRescrapeOptionsDefaultSkipsEpisodeArtwork(t *testing.T) { - options := repairRescrapeOptions() - if !options.RetryNoMatch { - t.Fatal("repair rescrape should retry no_match rows") - } - if !options.IncludeMatched { - t.Fatal("repair rescrape should refresh already matched rows") - } - if !options.ForceRematch { - t.Fatal("repair rescrape should ignore stale external IDs and rematch by path/title") - } - if options.EpisodeArtwork == nil { - t.Fatal("repair rescrape should set an explicit episode artwork option") - } - if *options.EpisodeArtwork { - t.Fatal("repair rescrape should skip episode artwork by default") - } -} - -func TestRepairRescrapeOptionsCanEnableEpisodeArtwork(t *testing.T) { - episodeArtwork := true - options := repairRescrapeOptions(ScrapeOptions{EpisodeArtwork: &episodeArtwork}) - if !options.RetryNoMatch { - t.Fatal("repair rescrape should force retry no_match rows") - } - if !options.IncludeMatched { - t.Fatal("repair rescrape should force refreshing already matched rows") - } - if options.EpisodeArtwork == nil || !*options.EpisodeArtwork { - t.Fatal("repair rescrape should keep explicit episode artwork=true") - } -} - -func TestRepairRescrapeOptionsKeepsExplicitEpisodeArtworkFalse(t *testing.T) { - episodeArtwork := false - options := repairRescrapeOptions(ScrapeOptions{EpisodeArtwork: &episodeArtwork}) - if !options.RetryNoMatch { - t.Fatal("repair rescrape should force retry no_match rows") - } - if !options.IncludeMatched { - t.Fatal("repair rescrape should force refreshing already matched rows") - } - if options.EpisodeArtwork == nil { - t.Fatal("repair rescrape should keep explicit episode artwork option") - } - if *options.EpisodeArtwork { - t.Fatal("repair rescrape should keep explicit episode artwork=false") - } -} - -// TestResetEpisodicMatchedForRescrape 验证「修复+重刮」会把脏的 matched 剧集行 -// 重置为 pending(让 EnrichLibrary 能重新刮削),而电影行与其它库不受影响。 -func TestResetEpisodicMatchedForRescrape(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - container := &Container{Repo: repository.New(db), Log: zap.NewNop()} - - rows := []model.Media{ - // 目标库的剧集行(matched, 有季集号)→ 应被重置。 - {Base: model.Base{ID: "ep1"}, LibraryID: "lib-a", SeasonNum: 1, EpisodeNum: 1, ScrapeStatus: "matched", Path: "/a/show/S01/ep1.mkv"}, - {Base: model.Base{ID: "ep2"}, LibraryID: "lib-a", SeasonNum: 1, EpisodeNum: 2, ScrapeStatus: "matched", Path: "/a/show/S01/ep2.mkv"}, - // 目标库的电影行(无季集号)→ 不应被重置。 - {Base: model.Base{ID: "movie1"}, LibraryID: "lib-a", ScrapeStatus: "matched", Path: "/a/movie.mkv"}, - // 目标库已是 pending 的剧集行 → 不计入重置数。 - {Base: model.Base{ID: "ep3"}, LibraryID: "lib-a", SeasonNum: 1, EpisodeNum: 3, ScrapeStatus: "pending", Path: "/a/show/S01/ep3.mkv"}, - // 其它库的剧集行(matched)→ 单库重置时不应受影响。 - {Base: model.Base{ID: "ep-other"}, LibraryID: "lib-b", SeasonNum: 1, EpisodeNum: 1, ScrapeStatus: "matched", Path: "/b/show/S01/ep1.mkv"}, - } - for i := range rows { - if err := db.Create(&rows[i]).Error; err != nil { - t.Fatalf("create media %s: %v", rows[i].ID, err) - } - } - - reset, err := container.resetEpisodicMatchedForRescrape(t.Context(), "lib-a") - if err != nil { - t.Fatalf("reset: %v", err) - } - if reset != 2 { - t.Fatalf("reset = %d, want 2 (only matched episodic rows in lib-a)", reset) - } - - status := func(id string) string { - var m model.Media - if err := db.First(&m, "id = ?", id).Error; err != nil { - t.Fatalf("load %s: %v", id, err) - } - return m.ScrapeStatus - } - if status("ep1") != "pending" || status("ep2") != "pending" { - t.Fatalf("episodic matched rows should be pending, got ep1=%q ep2=%q", status("ep1"), status("ep2")) - } - if status("movie1") != "matched" { - t.Fatalf("movie row should stay matched, got %q", status("movie1")) - } - if status("ep-other") != "matched" { - t.Fatalf("other library row should stay matched, got %q", status("ep-other")) - } -} - -func TestRepairAndRescrapeLibraryExpandsMergedCloudLibraries(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - container := &Container{Repo: repository.New(db), Log: zap.NewNop()} - - local := model.Library{Name: "国产剧", Path: "/media/电视剧/国产剧", Type: "tv", Enabled: true} - cloud := model.Library{ - Name: "OpenList · 国产剧", - Path: BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), - Type: "tv", - Enabled: true, - } - if err := container.Repo.Library.Create(t.Context(), &local); err != nil { - t.Fatal(err) - } - if err := container.Repo.Library.Create(t.Context(), &cloud); err != nil { - t.Fatal(err) - } - repairMedia := model.Media{ - LibraryID: cloud.ID, - Title: "主角", - Path: "cloud://openlist/国产剧/主角 (2026) {tmdb-284110}/Season 1/主角.S01E01.mkv", - SeasonNum: 1, - EpisodeNum: 1, - ScrapeStatus: "matched", - } - resetMedia := model.Media{ - LibraryID: cloud.ID, - Title: "无占位符剧集", - Path: "cloud://openlist/国产剧/无占位符剧集/Season 1/无占位符剧集.S01E01.mkv", - SeasonNum: 1, - EpisodeNum: 1, - ScrapeStatus: "matched", - } - if err := db.Create(&repairMedia).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(&resetMedia).Error; err != nil { - t.Fatal(err) - } - - result, err := container.RepairAndRescrapeLibrary(t.Context(), local.ID) - if err != nil { - t.Fatal(err) - } - if result.Repaired != 1 || result.Reset != 1 { - t.Fatalf("result = %+v, want repaired/reset for merged cloud row", result) - } - var repaired model.Media - if err := db.First(&repaired, "id = ?", repairMedia.ID).Error; err != nil { - t.Fatal(err) - } - if repaired.TMDbID != 284110 || repaired.ScrapeStatus != "pending" { - t.Fatalf("merged cloud row not repaired/reset: tmdb=%d status=%q", repaired.TMDbID, repaired.ScrapeStatus) - } - var reset model.Media - if err := db.First(&reset, "id = ?", resetMedia.ID).Error; err != nil { - t.Fatal(err) - } - if reset.ScrapeStatus != "pending" { - t.Fatalf("merged cloud row not reset: status=%q", reset.ScrapeStatus) - } -} diff --git a/internal/service/cloud_path_rescrape.go b/internal/service/cloud_path_rescrape.go deleted file mode 100644 index e51ca76..0000000 --- a/internal/service/cloud_path_rescrape.go +++ /dev/null @@ -1,217 +0,0 @@ -package service - -import ( - "context" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// RepairAndRescrapeResult 汇总一次「全库修复+重刮」的结果。 -type RepairAndRescrapeResult struct { - Repaired int `json:"repaired"` // 从路径占位符回填外部 ID 的媒体数 - Reclassified int `json:"reclassified"` // 按元数据纠偏到正确分类/媒体库的媒体数 - Libraries int `json:"libraries"` // 参与重刮的媒体库数 - Matched int `json:"matched"` // 重刮后成功匹配的媒体数 - Processed int `json:"processed"` // 实际完成刮削处理的媒体数 - Errors int `json:"errors"` // 单条媒体刮削失败数 - Reset int `json:"reset"` // 被重置为 pending 以便重刮的剧集行数 -} - -// resetEpisodicMatchedForRescrape 把剧集类(有季集号)且已 matched 的行重置为 -// pending,使 EnrichLibrary(只处理 pending/no_match)能重新刮削它们。 -// -// 背景: 历史版本(commit b44c7f8)曾把【单集 episode id】写进整剧 tm_db_id、把 -// 单集名写进 original_name,污染了合集分组键 —— 同一部剧每集 id/原名各不相同, -// 被前端 / Emby 拆成 N 张单集卡。这些行 scrape_status 多为 matched,常规「修复+ -// 重刮」会跳过,导致「无法修复」。源头已在 local_metadata.go 修正,这里把脏的 -// matched 剧集行放回 pending,借重刮写回正确的整剧 ID / 原名。 -// -// libraryIDs 为空时处理全库;非空时仅这些库。返回被重置的行数。 -func (c *Container) resetEpisodicMatchedForRescrape(ctx context.Context, libraryIDs ...string) (int, error) { - if c == nil || c.Repo == nil || c.Repo.DB == nil { - return 0, nil - } - ids := compactLibraryIDs(libraryIDs...) - q := c.Repo.DB.WithContext(ctx).Model(&model.Media{}). - Where("(season_num > 0 OR episode_num > 0)"). - Where("LOWER(scrape_status) = ?", "matched") - if len(ids) > 0 { - q = q.Where("library_id IN ?", ids) - } - res := q.Update("scrape_status", "pending") - if res.Error != nil { - return 0, res.Error - } - reset := int(res.RowsAffected) - if reset > 0 && c.Log != nil { - c.Log.Info("episodic matched rows reset to pending for rescrape", - zap.String("libraries", strings.Join(ids, ",")), - zap.Int("reset", reset)) - } - return reset, nil -} - -// RepairAndRescrapeAllLibraries 修复并重刮所有媒体库:先从媒体路径中的 -// {tmdb-123}/{bangumi-456} 等占位符回填缺失或错误的外部 ID(回填后会把相关 -// 行的 scrape_status 重置为 pending),随后逐个媒体库重刮(含 no_match 重试), -// 让此前因空 ID / 脏 ID 无法刮削的媒体重新匹配到正确数据。 -func repairRescrapeOptions(values ...ScrapeOptions) ScrapeOptions { - options := ScrapeOptions{RetryNoMatch: true, IncludeMatched: true, ForceRematch: true} - if len(values) > 0 { - options = values[0] - options.RetryNoMatch = true - options.IncludeMatched = true - options.ForceRematch = true - } - if options.EpisodeArtwork == nil { - episodeArtwork := false - options.EpisodeArtwork = &episodeArtwork - } - return options -} - -func (c *Container) RepairAndRescrapeAllLibraries(ctx context.Context, options ...ScrapeOptions) (RepairAndRescrapeResult, error) { - var result RepairAndRescrapeResult - if c == nil || c.Repo == nil || c.Repo.DB == nil { - return result, nil - } - scrapeOptions := repairRescrapeOptions(options...) - repaired, err := c.RepairCloudPathMetadata(ctx) - if err != nil { - return result, err - } - result.Repaired = repaired - - // 重置全库脏的 matched 剧集行(单集 id 污染整剧字段),让其下方重刮一并修正。 - if reset, err := c.resetEpisodicMatchedForRescrape(ctx); err != nil { - return result, err - } else { - result.Reset = reset - } - - if c.Scraper == nil || c.Repo.Library == nil { - return result, nil - } - libraries, err := c.Repo.Library.List(ctx) - if err != nil { - return result, err - } - for i := range libraries { - select { - case <-ctx.Done(): - return result, ctx.Err() - default: - } - lib := libraries[i] - if !lib.Enabled { - continue - } - result.Libraries++ - // retryNoMatch=true:连之前匹配失败的也再试一次,因为这次可能已回填到正确 ID。 - scrapeResult, err := c.Scraper.EnrichLibraryDetailedWithOptions(ctx, lib.ID, scrapeOptions) - if err != nil { - if c.Log != nil { - c.Log.Warn("repair rescrape library failed", zap.String("library", lib.ID), zap.Error(err)) - } - result.Errors++ - continue - } - result.Matched += scrapeResult.Matched - result.Processed += scrapeResult.Processed - result.Errors += scrapeResult.Failed - } - if c.Organizer != nil { - reclassifyResult, err := c.Organizer.ReclassifyMisclassifiedMedia(ctx, MediaCategoryReclassifyOptions{}) - if err != nil { - return result, err - } - if reclassifyResult != nil { - result.Reclassified = reclassifyResult.Reclassified - result.Errors += len(reclassifyResult.Errors) - c.invalidateRepairReclassifyCache(ctx, reclassifyResult.Reclassified) - } - } - if c.Log != nil { - c.Log.Info("repair and rescrape all libraries done", - zap.Int("repaired", result.Repaired), - zap.Int("reclassified", result.Reclassified), - zap.Int("libraries", result.Libraries), - zap.Int("matched", result.Matched), - zap.Int("processed", result.Processed), - zap.Int("errors", result.Errors)) - } - return result, nil - } - -// RepairAndRescrapeLibrary 修复并重刮单个媒体库:先从该库媒体路径中的占位符 -// 回填缺失/错误的外部 ID(重置相关行 scrape_status=pending),再对该库重刮 -// (含 no_match 重试)。用于「按媒体库」单独触发修复,不影响其它库。 -func (c *Container) RepairAndRescrapeLibrary(ctx context.Context, libraryID string, options ...ScrapeOptions) (RepairAndRescrapeResult, error) { - var result RepairAndRescrapeResult - libraryID = strings.TrimSpace(libraryID) - if c == nil || c.Repo == nil || c.Repo.DB == nil || libraryID == "" { - return result, nil - } - scrapeOptions := repairRescrapeOptions(options...) - libraryIDs, err := MergedLibraryIDsForLibrary(ctx, c.Repo, libraryID) - if err != nil { - return result, err - } - repaired, err := c.RepairCloudPathMetadata(ctx, libraryIDs...) - if err != nil { - return result, err - } - result.Repaired = repaired - - // 重置该库脏的 matched 剧集行,让下方重刮修正被单集 id 污染的整剧字段。 - if reset, err := c.resetEpisodicMatchedForRescrape(ctx, libraryIDs...); err != nil { - return result, err - } else { - result.Reset = reset - } - - if c.Scraper == nil { - return result, nil - } - result.Libraries = 1 - // retryNoMatch=true:连之前匹配失败的也再试一次,因为这次可能已回填到正确 ID。 - scrapeResult, err := c.Scraper.EnrichLibraryDetailedWithOptions(ctx, libraryID, scrapeOptions) - if err != nil { - return result, err - } - result.Matched = scrapeResult.Matched - result.Processed = scrapeResult.Processed - result.Errors = scrapeResult.Failed - if c.Organizer != nil { - reclassifyResult, err := c.Organizer.ReclassifyMisclassifiedMedia(ctx, MediaCategoryReclassifyOptions{LibraryIDs: libraryIDs}) - if err != nil { - return result, err - } - if reclassifyResult != nil { - result.Reclassified = reclassifyResult.Reclassified - result.Errors += len(reclassifyResult.Errors) - c.invalidateRepairReclassifyCache(ctx, reclassifyResult.Reclassified) - } - } - if c.Log != nil { - c.Log.Info("repair and rescrape library done", - zap.String("library", libraryID), - zap.Int("repaired", result.Repaired), - zap.Int("reclassified", result.Reclassified), - zap.Int("matched", result.Matched), - zap.Int("processed", result.Processed), - zap.Int("errors", result.Errors)) - } - return result, nil - } - -func (c *Container) invalidateRepairReclassifyCache(ctx context.Context, changed int) { - if c == nil || c.Cache == nil || changed <= 0 { - return - } - c.Cache.DeletePrefix(ctx, "media:") - c.Cache.DeletePrefix(ctx, "stats:") -} diff --git a/internal/service/discover.go b/internal/service/discover.go deleted file mode 100644 index 1729478..0000000 --- a/internal/service/discover.go +++ /dev/null @@ -1,202 +0,0 @@ -// Package service — TMDb discovery (trending / popular). -// -// DiscoverService surfaces curated lists from TMDb so the React home -// page can show "Trending" and "Popular" rails alongside the user's own -// library. All methods gracefully no-op when the TMDb provider is -// disabled. -package service - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "go.uber.org/zap" -) - -// DiscoverService talks to TMDb's /trending and /movie/popular endpoints. -type DiscoverService struct { - log *zap.Logger - tmdb *TMDbProvider - client *http.Client - images *ImageProxy - sectionCache *DiscoverSectionCache -} - -// NewDiscoverService is the constructor. -func NewDiscoverService(log *zap.Logger, tmdb *TMDbProvider) *DiscoverService { - return &DiscoverService{ - log: log, - tmdb: tmdb, - client: NewExternalHTTPClient(15 * time.Second), - sectionCache: NewDiscoverSectionCache(6 * time.Hour), - } -} - -// Trending returns the daily trending movies (TMDb /trending/movie/day). -func (d *DiscoverService) Trending(ctx context.Context) ([]Match, error) { - return d.fetch(ctx, "/trending/movie/day") -} - -// Popular returns the popular movies list (TMDb /movie/popular). -func (d *DiscoverService) Popular(ctx context.Context) ([]Match, error) { - return d.fetch(ctx, "/movie/popular") -} - -// TMDbSection returns one TMDb rail converted to the common external -// discovery shape used by the multi-source Discover page. -func (d *DiscoverService) TMDbSection(ctx context.Context, key string, pages ...int) ([]ExternalMediaResult, error) { - path := tmdbDiscoverPath(key) - if path == "" { - return []ExternalMediaResult{}, nil - } - matches, err := d.Fetch(ctx, path, pages...) - if err != nil { - return nil, err - } - mediaType := "movie" - if strings.Contains(path, "/tv/") { - mediaType = "tv" - } - out := make([]ExternalMediaResult, 0, len(matches)) - for _, item := range matches { - out = append(out, ExternalMediaResult{ - Source: "tmdb", - MediaType: mediaType, - Title: item.Title, - OriginalName: item.OriginalName, - Overview: item.Overview, - PosterURL: item.PosterURL, - BackdropURL: item.BackdropURL, - Year: item.Year, - Rating: item.Rating, - TMDbID: item.TMDbID, - SubscribeKeyword: buildSubscribeKeyword(item.Title, item.Year), - SubscribeAliases: buildSubscribeAliases(item.Title, item.OriginalName, item.Year), - }) - } - return out, nil -} - -// fetch is the shared helper that paginates page=1 only — that's all the -// home page needs and it keeps us under TMDb's 50 rps limit. -func (d *DiscoverService) fetch(ctx context.Context, path string) ([]Match, error) { - return d.Fetch(ctx, path) -} - -// Fetch is the public entry point used by the multi-section handler. -// It paginates page=1 only — that's all the home page needs and it -// keeps us under TMDb's 50 rps limit. -func (d *DiscoverService) Fetch(ctx context.Context, path string, pages ...int) ([]Match, error) { - if d.tmdb == nil { - return nil, nil - } - - // Resolve API key from config or database - apiKey := d.tmdb.resolveAPIKey(ctx) - if apiKey == "" { - return nil, nil - } - base := d.tmdb.resolveBaseURL(ctx) - - q := url.Values{} - q.Set("api_key", apiKey) - q.Set("language", "zh-CN") - pageNumber := 1 - if len(pages) > 0 && pages[0] > 0 { - pageNumber = pages[0] - } - q.Set("page", strconv.Itoa(pageNumber)) - u := base + path + "?" + q.Encode() - - type result struct { - ID int `json:"id"` - Title string `json:"title"` - Name string `json:"name"` - OriginalTitle string `json:"original_title"` - OriginalName string `json:"original_name"` - Overview string `json:"overview"` - PosterPath string `json:"poster_path"` - BackdropPath string `json:"backdrop_path"` - ReleaseDate string `json:"release_date"` - FirstAirDate string `json:"first_air_date"` - VoteAverage float32 `json:"vote_average"` - } - type page struct { - Results []result `json:"results"` - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, err - } - resp, err := d.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("tmdb %s: %d", path, resp.StatusCode) - } - var p page - if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { - return nil, err - } - out := make([]Match, 0, len(p.Results)) - for _, r := range p.Results { - title := r.Title - if title == "" { - title = r.Name - } - m := Match{ - TMDbID: r.ID, - Title: title, - OriginalName: firstNonEmpty(r.OriginalTitle, r.OriginalName), - Overview: r.Overview, - Rating: r.VoteAverage, - } - if r.PosterPath != "" { - m.PosterURL = d.tmdb.imgCDN + "/w500" + r.PosterPath - } - if r.BackdropPath != "" { - m.BackdropURL = d.tmdb.imgCDN + "/w1280" + r.BackdropPath - } - date := r.ReleaseDate - if date == "" { - date = r.FirstAirDate - } - if len(date) >= 4 { - _, _ = fmt.Sscanf(date[:4], "%d", &m.Year) - } - out = append(out, m) - } - return out, nil -} - -func tmdbDiscoverPath(key string) string { - switch key { - case "tmdb_trending_day", "trending_day": - return "/trending/movie/day" - case "tmdb_trending_week", "trending_week": - return "/trending/movie/week" - case "tmdb_latest_movie", "latest_movie": - return "/movie/now_playing" - case "tmdb_latest_tv", "latest_tv": - return "/tv/on_the_air" - case "tmdb_popular_movie", "popular_movie": - return "/movie/popular" - case "tmdb_popular_tv", "popular_tv": - return "/tv/popular" - case "tmdb_top_rated_movie", "top_rated_movie": - return "/movie/top_rated" - case "tmdb_upcoming_movie", "upcoming_movie": - return "/movie/upcoming" - default: - return "" - } -} diff --git a/internal/service/discover_artwork.go b/internal/service/discover_artwork.go deleted file mode 100644 index a5c8218..0000000 --- a/internal/service/discover_artwork.go +++ /dev/null @@ -1,115 +0,0 @@ -package service - -import ( - "context" - "strings" - "sync" - "time" - - "go.uber.org/zap" -) - -const ( - discoverArtworkPrefetchLimit = 384 - discoverArtworkPrefetchConcurrency = 8 - discoverArtworkPrefetchTimeout = 90 * time.Second -) - -func (d *DiscoverService) SetImageProxy(images *ImageProxy) *DiscoverService { - if d != nil { - d.images = images - } - return d -} - -func (d *DiscoverService) WarmMatchArtwork(items []Match) int { - return d.warmArtworkURLs(matchArtworkURLs(items)) -} - -func matchArtworkURLs(items []Match) []string { - urls := make([]string, 0, len(items)*2) - for _, item := range items { - urls = append(urls, item.PosterURL) - } - for _, item := range items { - urls = append(urls, item.BackdropURL) - } - return urls -} - -func (d *DiscoverService) WarmExternalArtwork(items []ExternalMediaResult) int { - return d.warmArtworkURLs(externalArtworkURLs(items)) -} - -func externalArtworkURLs(items []ExternalMediaResult) []string { - urls := make([]string, 0, len(items)*2) - for _, item := range items { - urls = append(urls, item.PosterURL) - } - for _, item := range items { - urls = append(urls, item.BackdropURL) - } - return urls -} - -func (d *DiscoverService) warmArtworkURLs(urls []string) int { - if d == nil || d.images == nil || len(urls) == 0 { - return 0 - } - pending := uniqueDiscoverArtworkURLs(urls, discoverArtworkPrefetchLimit) - if len(pending) == 0 { - return 0 - } - if d.log != nil { - d.log.Debug("discover artwork prefetch scheduled", zap.Int("count", len(pending))) - } - go d.prefetchArtworkURLs(pending) - return len(pending) -} - -func uniqueDiscoverArtworkURLs(urls []string, limit int) []string { - if limit <= 0 { - return nil - } - seen := map[string]struct{}{} - out := make([]string, 0, min(len(urls), limit)) - for _, raw := range urls { - raw = strings.TrimSpace(raw) - if raw == "" || !isHTTPish(raw) { - continue - } - if _, ok := seen[raw]; ok { - continue - } - seen[raw] = struct{}{} - out = append(out, raw) - if len(out) >= limit { - break - } - } - return out -} - -func (d *DiscoverService) prefetchArtworkURLs(urls []string) { - ctx, cancel := context.WithTimeout(context.Background(), discoverArtworkPrefetchTimeout) - defer cancel() - - sem := make(chan struct{}, discoverArtworkPrefetchConcurrency) - var wg sync.WaitGroup - for _, raw := range urls { - select { - case <-ctx.Done(): - return - case sem <- struct{}{}: - } - wg.Add(1) - go func(raw string) { - defer wg.Done() - defer func() { <-sem }() - if err := d.images.PrefetchRemote(ctx, raw); err != nil && d.log != nil { - d.log.Debug("discover artwork prefetch failed", zap.String("url", raw), zap.Error(err)) - } - }(raw) - } - wg.Wait() -} diff --git a/internal/service/discover_artwork_test.go b/internal/service/discover_artwork_test.go deleted file mode 100644 index 06e069d..0000000 --- a/internal/service/discover_artwork_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package service - -import ( - "bytes" - "io" - "net/http" - "os" - "path/filepath" - "sync/atomic" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" -) - -func TestUniqueDiscoverArtworkURLsFiltersDuplicatesAndLimits(t *testing.T) { - urls := uniqueDiscoverArtworkURLs([]string{ - "", - "/local/poster.jpg", - "https://image.tmdb.org/t/p/w500/a.jpg", - "https://image.tmdb.org/t/p/w500/a.jpg", - "https://image.tmdb.org/t/p/w500/b.jpg", - "https://image.tmdb.org/t/p/w500/c.jpg", - }, 2) - if len(urls) != 2 { - t.Fatalf("len = %d, want 2: %v", len(urls), urls) - } - if urls[0] != "https://image.tmdb.org/t/p/w500/a.jpg" || urls[1] != "https://image.tmdb.org/t/p/w500/b.jpg" { - t.Fatalf("urls = %v", urls) - } -} - -func TestDiscoverArtworkURLsPrioritizePosters(t *testing.T) { - items := []ExternalMediaResult{ - {PosterURL: "https://img.example/a-poster.jpg", BackdropURL: "https://img.example/a-backdrop.jpg"}, - {PosterURL: "https://img.example/b-poster.jpg", BackdropURL: "https://img.example/b-backdrop.jpg"}, - } - urls := externalArtworkURLs(items) - want := []string{ - "https://img.example/a-poster.jpg", - "https://img.example/b-poster.jpg", - "https://img.example/a-backdrop.jpg", - "https://img.example/b-backdrop.jpg", - } - if len(urls) != len(want) { - t.Fatalf("len = %d, want %d: %v", len(urls), len(want), urls) - } - for i := range want { - if urls[i] != want[i] { - t.Fatalf("url[%d] = %q, want %q", i, urls[i], want[i]) - } - } -} - -func TestDiscoverWarmExternalArtworkPrefetchesAndCaches(t *testing.T) { - proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop()) - var calls int32 - proxy.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) { - atomic.AddInt32(&calls, 1) - return &http.Response{ - StatusCode: http.StatusOK, - Status: "200 OK", - Header: http.Header{"Content-Type": []string{"image/jpeg"}}, - Body: io.NopCloser(bytes.NewReader(testJPEG)), - Request: req, - }, nil - })} - - discover := NewDiscoverService(zap.NewNop(), nil).SetImageProxy(proxy) - poster := "https://image.tmdb.org/t/p/w500/discover-poster.jpg" - backdrop := "https://image.tmdb.org/t/p/w1280/discover-backdrop.jpg" - queued := discover.WarmExternalArtwork([]ExternalMediaResult{ - {Title: "A", PosterURL: poster, BackdropURL: backdrop}, - {Title: "B", PosterURL: poster}, - {Title: "Local", PosterURL: "/media/poster.jpg"}, - }) - if queued != 2 { - t.Fatalf("queued = %d, want 2", queued) - } - for _, raw := range []string{poster, backdrop} { - _, cachePath, _, err := proxy.remoteImageCachePaths(raw) - if err != nil { - t.Fatal(err) - } - waitForDiscoverArtworkCache(t, &calls, 2, cachePath, raw) - } - - callsAfterCache := atomic.LoadInt32(&calls) - queued = discover.WarmExternalArtwork([]ExternalMediaResult{{Title: "Cached", PosterURL: poster, BackdropURL: backdrop}}) - if queued != 2 { - t.Fatalf("queued cached = %d, want 2", queued) - } - time.Sleep(150 * time.Millisecond) - if got := atomic.LoadInt32(&calls); got != callsAfterCache { - t.Fatalf("cached prefetch should not call upstream again: got %d want %d", got, callsAfterCache) - } -} - -func TestDiscoverWarmArtworkNoImageProxyIsNoop(t *testing.T) { - discover := NewDiscoverService(zap.NewNop(), nil) - if got := discover.WarmMatchArtwork([]Match{{PosterURL: "https://image.tmdb.org/t/p/w500/a.jpg"}}); got != 0 { - t.Fatalf("queued = %d, want 0 without image proxy", got) - } -} - -func TestTMDbDiscoverPathIncludesLatestSections(t *testing.T) { - cases := map[string]string{ - "tmdb_latest_movie": "/movie/now_playing", - "latest_movie": "/movie/now_playing", - "tmdb_latest_tv": "/tv/on_the_air", - "latest_tv": "/tv/on_the_air", - "tmdb_upcoming_movie": "/movie/upcoming", - } - for key, want := range cases { - if got := tmdbDiscoverPath(key); got != want { - t.Fatalf("tmdbDiscoverPath(%q) = %q, want %q", key, got, want) - } - } -} - -func waitForDiscoverArtworkCache(t *testing.T, calls *int32, wantCalls int32, cachePath, raw string) { - t.Helper() - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - if atomic.LoadInt32(calls) >= wantCalls { - if _, err := os.Stat(cachePath); err == nil { - return - } - } - time.Sleep(10 * time.Millisecond) - } - t.Fatalf("expected cached artwork %q after %d upstream calls: %v", raw, atomic.LoadInt32(calls), os.ErrNotExist) -} diff --git a/internal/service/discover_section_cache.go b/internal/service/discover_section_cache.go deleted file mode 100644 index 63a949c..0000000 --- a/internal/service/discover_section_cache.go +++ /dev/null @@ -1,104 +0,0 @@ -package service - -import ( - "fmt" - "sync" - "time" -) - -// DiscoverSectionCache keeps the last good discover rail in memory so a slow -// upstream provider does not turn a populated page into empty rows. -type DiscoverSectionCache struct { - ttl time.Duration - mu sync.RWMutex - entries map[string]discoverSectionCacheEntry -} - -type discoverSectionCacheEntry struct { - items []ExternalMediaResult - storedAt time.Time -} - -func NewDiscoverSectionCache(ttl time.Duration) *DiscoverSectionCache { - if ttl <= 0 { - ttl = 6 * time.Hour - } - return &DiscoverSectionCache{ - ttl: ttl, - entries: map[string]discoverSectionCacheEntry{}, - } -} - -func (d *DiscoverService) RememberSection(key string, page int, items []ExternalMediaResult) { - if d == nil || d.sectionCache == nil || len(items) == 0 { - return - } - d.sectionCache.Set(key, page, items) -} - -func (d *DiscoverService) CachedSection(key string, page int) ([]ExternalMediaResult, bool) { - if d == nil || d.sectionCache == nil { - return nil, false - } - return d.sectionCache.Get(key, page) -} - -func (c *DiscoverSectionCache) Set(key string, page int, items []ExternalMediaResult) { - if c == nil || key == "" || page < 1 || len(items) == 0 { - return - } - c.mu.Lock() - defer c.mu.Unlock() - c.entries[discoverSectionCacheKey(key, page)] = discoverSectionCacheEntry{ - items: cloneExternalMediaResults(items), - storedAt: time.Now(), - } -} - -func (c *DiscoverSectionCache) Get(key string, page int) ([]ExternalMediaResult, bool) { - if c == nil || key == "" || page < 1 { - return nil, false - } - c.mu.RLock() - entry, ok := c.entries[discoverSectionCacheKey(key, page)] - c.mu.RUnlock() - if !ok || time.Since(entry.storedAt) > c.ttl || len(entry.items) == 0 { - return nil, false - } - return cloneExternalMediaResults(entry.items), true -} - -func discoverSectionCacheKey(key string, page int) string { - return fmt.Sprintf("%s:%d", key, page) -} - -func cloneExternalMediaResults(items []ExternalMediaResult) []ExternalMediaResult { - out := make([]ExternalMediaResult, len(items)) - for i, item := range items { - out[i] = item - out[i].SubscribeAliases = cloneStrings(item.SubscribeAliases) - out[i].MissingEpisodes = cloneInts(item.MissingEpisodes) - out[i].Languages = cloneStrings(item.Languages) - out[i].Countries = cloneStrings(item.Countries) - out[i].Genres = cloneStrings(item.Genres) - } - return out -} - -func cloneStrings(items []string) []string { - if len(items) == 0 { - return nil - } - out := make([]string, len(items)) - copy(out, items) - return out -} - -func cloneInts(items []int) []int { - if len(items) == 0 { - return nil - } - out := make([]int, len(items)) - copy(out, items) - return out -} diff --git a/internal/service/discover_section_cache_test.go b/internal/service/discover_section_cache_test.go deleted file mode 100644 index 97d511f..0000000 --- a/internal/service/discover_section_cache_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package service - -import ( - "testing" - "time" -) - -func TestDiscoverSectionCacheReturnsClone(t *testing.T) { - cache := NewDiscoverSectionCache(time.Hour) - cache.Set("douban_hot_movie", 1, []ExternalMediaResult{{ - Title: "第一部", - SubscribeAliases: []string{"别名"}, - MissingEpisodes: []int{1}, - Languages: []string{"zh"}, - }}) - - got, ok := cache.Get("douban_hot_movie", 1) - if !ok || len(got) != 1 || got[0].Title != "第一部" { - t.Fatalf("cached section = %#v, %v", got, ok) - } - - got[0].Title = "被修改" - got[0].SubscribeAliases[0] = "别名被改" - got[0].MissingEpisodes[0] = 9 - got[0].Languages[0] = "en" - again, ok := cache.Get("douban_hot_movie", 1) - if !ok || - again[0].Title != "第一部" || - again[0].SubscribeAliases[0] != "别名" || - again[0].MissingEpisodes[0] != 1 || - again[0].Languages[0] != "zh" { - t.Fatalf("cache should return a clone, got %#v", again) - } -} - -func TestDiscoverSectionCacheExpires(t *testing.T) { - cache := NewDiscoverSectionCache(time.Nanosecond) - cache.Set("tmdb_latest_movie", 1, []ExternalMediaResult{{Title: "旧数据"}}) - time.Sleep(time.Millisecond) - - if got, ok := cache.Get("tmdb_latest_movie", 1); ok || len(got) != 0 { - t.Fatalf("expired cache should miss, got %#v", got) - } -} diff --git a/internal/service/douban_discover.go b/internal/service/douban_discover.go deleted file mode 100644 index f938762..0000000 --- a/internal/service/douban_discover.go +++ /dev/null @@ -1,90 +0,0 @@ -// Package service — Douban discovery rails. -package service - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" -) - -// Discover returns public Douban movie/TV rails. Douban does not require a -// formal API key here; these are the same public web endpoints the site uses. -func (d *DoubanProvider) Discover(ctx context.Context, key string, pages ...int) ([]ExternalMediaResult, error) { - doubanType := "movie" - tag := "热门" - switch key { - case "douban_hot_movie": - doubanType = "movie" - tag = "热门" - case "douban_top_movie": - doubanType = "movie" - tag = "高分" - case "douban_hot_tv": - doubanType = "tv" - tag = "热门" - default: - return []ExternalMediaResult{}, nil - } - q := url.Values{} - q.Set("type", doubanType) - q.Set("tag", tag) - q.Set("sort", "recommend") - q.Set("page_limit", "24") - pageNumber := 1 - if len(pages) > 0 && pages[0] > 0 { - pageNumber = pages[0] - } - q.Set("page_start", strconv.Itoa((pageNumber-1)*24)) - u := "https://movie.douban.com/j/search_subjects?" + q.Encode() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, err - } - d.setHeaders(req) - resp, err := d.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("douban discover: %d", resp.StatusCode) - } - var page struct { - Subjects []struct { - ID string `json:"id"` - Title string `json:"title"` - Rate string `json:"rate"` - Cover string `json:"cover"` - URL string `json:"url"` - } `json:"subjects"` - } - if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { - return nil, err - } - out := make([]ExternalMediaResult, 0, len(page.Subjects)) - mediaType := "movie" - if doubanType == "tv" { - mediaType = "tv" - } - for _, subject := range page.Subjects { - if strings.TrimSpace(subject.Title) == "" { - continue - } - rating, _ := strconv.ParseFloat(subject.Rate, 32) - out = append(out, ExternalMediaResult{ - Source: "douban", - MediaType: mediaType, - Title: subject.Title, - PosterURL: subject.Cover, - Rating: float32(rating), - DoubanID: subject.ID, - SubscribeKeyword: subject.Title, - SubscribeAliases: buildSubscribeAliases(subject.Title, "", 0), - }) - } - return out, nil -} diff --git a/internal/service/download_active_paths.go b/internal/service/download_active_paths.go deleted file mode 100644 index f965e0c..0000000 --- a/internal/service/download_active_paths.go +++ /dev/null @@ -1,36 +0,0 @@ -package service - -import ( - "context" - "time" - - "go.uber.org/zap" -) - -const activeDownloadSnapshotFallbackAge = 2 * time.Minute - -func (d *DownloadService) ActiveDownloadPaths(ctx context.Context) []string { - if d == nil { - return nil - } - live, err := d.listLiveTorrents(ctx, "") - if err != nil && len(live) == 0 { - live = d.LiveTorrentSnapshot(activeDownloadSnapshotFallbackAge) - if d.log != nil && len(live) == 0 { - d.log.Debug("active download guard could not list download clients and has no fresh snapshot", zap.Error(err)) - } - } - return activeDownloadPathCandidates(live, d.downloadPathMappings(ctx)) -} - -func (d *DownloadService) downloadPathMappings(ctx context.Context) map[string]string { - mappings := map[string]string{ - "/var/apps/qBittorrent/shares/qBittorrent/Download": "/downloads", - "/data/qBittorrent/downloads": "/downloads", - "/downloads/qBittorrent": "/downloads", - } - for clientPrefix, localPrefix := range d.userPathMappings(ctx) { - mappings[clientPrefix] = localPrefix - } - return mappings -} diff --git a/internal/service/download_adapter.go b/internal/service/download_adapter.go deleted file mode 100644 index 275fae3..0000000 --- a/internal/service/download_adapter.go +++ /dev/null @@ -1,90 +0,0 @@ -// Package service 定义下载适配器接口和通用数据结构。 -package service - -import ( - "context" - "time" -) - -// DownloadAdapter 定义下载客户端的统一接口。 -// 所有下载客户端(qBittorrent / Transmission / Aria2)必须实现此接口。 -type DownloadAdapter interface { - // Initialize 使用配置初始化客户端连接。 - Initialize(ctx context.Context, cfg DownloadClientConfig) error - // Ping 测试客户端连接是否可用。 - Ping(ctx context.Context) error - // AddTorrent 通过 URL(磁力链接或种子 URL)添加下载任务。 - AddTorrent(ctx context.Context, url, savePath string) (string, error) - // AddMagnet 通过磁力链接添加下载任务。 - AddMagnet(ctx context.Context, magnet, savePath string) (string, error) - // Pause 暂停指定下载任务。 - Pause(ctx context.Context, hash string) error - // Resume 恢复指定下载任务。 - Resume(ctx context.Context, hash string) error - // Remove 移除指定下载任务,deleteFiles 控制是否同时删除文件。 - Remove(ctx context.Context, hash string, deleteFiles bool) error - // List 列出所有或过滤后的种子任务。filter 可为空字符串表示全部。 - List(ctx context.Context, filter string) ([]TorrentInfo, error) - // GetInfo 获取指定种子的详细信息。 - GetInfo(ctx context.Context, hash string) (*TorrentInfo, error) -} - -// TorrentFileDownloadAdapter is implemented by clients that can accept the -// application-fetched .torrent payload instead of fetching a private URL. -type TorrentFileDownloadAdapter interface { - AddTorrentFile(ctx context.Context, data []byte, name, savePath string) (string, error) -} - -// CategorizedTorrentDownloadAdapter is implemented by qBittorrent, whose -// native category is part of MediaStationGo's automatic classification flow. -type CategorizedTorrentDownloadAdapter interface { - AddTorrentWithCategory(ctx context.Context, url, savePath, category string) (string, error) - AddTorrentFileWithCategory(ctx context.Context, data []byte, name, savePath, category string) (string, error) -} - -// TorrentRelocateAdapter is intentionally qBittorrent-only: qB can move -// payload data while preserving its seeding task through setLocation. -type TorrentRelocateAdapter interface { - Relocate(ctx context.Context, hash, location string) error -} - -// TorrentInfo 是各种下载客户端的种子信息的统一表示。 -type TorrentInfo struct { - Hash string `json:"hash"` - Name string `json:"name"` - Size int64 `json:"size"` - Progress float64 `json:"progress"` - DLSpeed int64 `json:"dl_speed"` - UPSpeed int64 `json:"up_speed"` - State string `json:"state"` - SavePath string `json:"save_path"` - NumSeeds int `json:"num_seeds"` - NumLeechs int `json:"num_leechs"` - AddedOn time.Time `json:"added_on"` - Category string `json:"category"` - Tags string `json:"tags"` - ContentPath string `json:"content_path"` - CompletionOn int64 `json:"completion_on"` -} - -// DownloadClientConfig 是下载客户端的连接配置。 -type DownloadClientConfig struct { - Host string `json:"host"` - Username string `json:"username"` - Password string `json:"password"` - Extra map[string]string `json:"extra,omitempty"` -} - -// AdapterFactory 根据客户端类型创建适配器实例。 -func AdapterFactory(clientType string) DownloadAdapter { - switch clientType { - case "qbittorrent": - return NewQBitAdapter() - case "transmission": - return NewTransmissionAdapter() - case "aria2": - return NewAria2Adapter() - default: - return nil - } -} diff --git a/internal/service/download_adapter_file_test.go b/internal/service/download_adapter_file_test.go deleted file mode 100644 index c7a9479..0000000 --- a/internal/service/download_adapter_file_test.go +++ /dev/null @@ -1,219 +0,0 @@ -package service - -import ( - "encoding/base64" - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "testing" -) - -func TestAria2AdapterRemoveClearsTaskAndResult(t *testing.T) { - var methods []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req aria2Request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode aria2 request: %v", err) - return - } - if req.Method != "aria2.getVersion" { - methods = append(methods, req.Method) - } - result := interface{}("OK") - if req.Method == "aria2.getVersion" { - result = map[string]interface{}{"version": "1.37"} - } - _ = json.NewEncoder(w).Encode(map[string]interface{}{"jsonrpc": "2.0", "id": req.ID, "result": result}) - })) - defer server.Close() - - adapter := NewAria2Adapter() - if err := adapter.Initialize(t.Context(), DownloadClientConfig{Host: server.URL}); err != nil { - t.Fatal(err) - } - if err := adapter.Remove(t.Context(), "aria2-gid", true); err != nil { - t.Fatal(err) - } - if len(methods) != 2 || methods[0] != "aria2.remove" || methods[1] != "aria2.removeDownloadResult" { - t.Fatalf("methods = %#v", methods) - } -} - -func TestAria2AdapterListReportsConnectionFailures(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req aria2Request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode aria2 request: %v", err) - return - } - response := map[string]interface{}{"jsonrpc": "2.0", "id": req.ID, "result": map[string]interface{}{"version": "1.37"}} - if req.Method != "aria2.getVersion" { - response = map[string]interface{}{"jsonrpc": "2.0", "id": req.ID, "error": map[string]interface{}{"code": 1, "message": "offline"}} - } - _ = json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - adapter := NewAria2Adapter() - if err := adapter.Initialize(t.Context(), DownloadClientConfig{Host: server.URL}); err != nil { - t.Fatal(err) - } - _, err := adapter.List(t.Context(), "") - if err == nil || !errors.Is(err, errAria2ListUnavailable) { - t.Fatalf("err = %v", err) - } -} - -func TestTransmissionAdapterAddsTorrentFileAsMetainfo(t *testing.T) { - payload := []byte("d4:infod4:name5:movieee") - var added map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("X-Transmission-Session-Id", "session-test") - w.WriteHeader(http.StatusConflict) - return - } - var req transmissionRPCRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode transmission request: %v", err) - return - } - if req.Method != "torrent-add" { - t.Errorf("transmission method = %q, want torrent-add", req.Method) - return - } - added = req.Arguments - _ = json.NewEncoder(w).Encode(transmissionRPCResponse{ - Result: "success", - Arguments: map[string]interface{}{ - "torrent-added": map[string]interface{}{"hashString": "transmission-file-hash"}, - }, - }) - })) - defer server.Close() - - adapter := NewTransmissionAdapter() - if err := adapter.Initialize(t.Context(), DownloadClientConfig{Host: server.URL}); err != nil { - t.Fatal(err) - } - hash, err := adapter.AddTorrentFile(t.Context(), payload, "movie.torrent", "/downloads/movies") - if err != nil { - t.Fatal(err) - } - if hash != "transmission-file-hash" { - t.Fatalf("hash = %q", hash) - } - if added["metainfo"] != base64.StdEncoding.EncodeToString(payload) { - t.Fatalf("metainfo = %#v", added["metainfo"]) - } - if added["download-dir"] != "/downloads/movies" { - t.Fatalf("download-dir = %#v", added["download-dir"]) - } - if _, ok := added["filename"]; ok { - t.Fatalf("torrent file request unexpectedly included filename: %#v", added) - } -} - -func TestAria2AdapterAddsTorrentFileWithAddTorrent(t *testing.T) { - payload := []byte("d4:infod4:name5:movieee") - var addParams []interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req aria2Request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode aria2 request: %v", err) - return - } - result := interface{}(map[string]interface{}{"version": "1.37"}) - if req.Method == "aria2.addTorrent" { - addParams = req.Params - result = "aria2-gid" - } - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "jsonrpc": "2.0", - "id": req.ID, - "result": result, - }) - })) - defer server.Close() - - adapter := NewAria2Adapter() - if err := adapter.Initialize(t.Context(), DownloadClientConfig{Host: server.URL, Password: "secret"}); err != nil { - t.Fatal(err) - } - gid, err := adapter.AddTorrentFile(t.Context(), payload, "movie.torrent", "/downloads/movies") - if err != nil { - t.Fatal(err) - } - if gid != "aria2-gid" { - t.Fatalf("gid = %q", gid) - } - if len(addParams) != 4 { - t.Fatalf("aria2 params = %#v", addParams) - } - if addParams[0] != "token:secret" || addParams[1] != base64.StdEncoding.EncodeToString(payload) { - t.Fatalf("aria2 params = %#v", addParams) - } - options, ok := addParams[3].(map[string]interface{}) - if !ok || options["dir"] != "/downloads/movies" { - t.Fatalf("aria2 options = %#v", addParams[3]) - } -} - -func TestQBitAdapterAddsTorrentFileWithCategory(t *testing.T) { - payload := []byte("d4:infod4:name5:movieee") - var gotCategory, gotSavePath, gotName string - var gotPayload []byte - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/add": - reader, err := r.MultipartReader() - if err != nil { - t.Errorf("multipart reader: %v", err) - return - } - for { - part, err := reader.NextPart() - if err == io.EOF { - break - } - if err != nil { - t.Errorf("multipart next part: %v", err) - return - } - value, _ := io.ReadAll(part) - switch part.FormName() { - case "torrents": - gotName = part.FileName() - gotPayload = value - case "savepath": - gotSavePath = string(value) - case "category": - gotCategory = string(value) - } - } - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - adapter := NewQBitAdapter() - if err := adapter.Initialize(t.Context(), DownloadClientConfig{Host: server.URL}); err != nil { - t.Fatal(err) - } - hash, err := adapter.AddTorrentFileWithCategory(t.Context(), payload, "movie.torrent", "/downloads/movies", "Movies") - if err != nil { - t.Fatal(err) - } - if hash != torrentInfoHash(payload) { - t.Fatalf("hash = %q, want %q", hash, torrentInfoHash(payload)) - } - if gotName != "movie.torrent" || string(gotPayload) != string(payload) || gotSavePath != "/downloads/movies" || gotCategory != "Movies" { - t.Fatalf("multipart = name %q payload %q savepath %q category %q", gotName, gotPayload, gotSavePath, gotCategory) - } -} diff --git a/internal/service/download_add.go b/internal/service/download_add.go deleted file mode 100644 index 96df9af..0000000 --- a/internal/service/download_add.go +++ /dev/null @@ -1,232 +0,0 @@ -package service - -import ( - "context" - "errors" - "path" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// DownloadTaskMeta carries public display metadata for a download. It is -// deliberately separate from the private torrent URL so API responses never -// need to expose tracker tokens. -type DownloadTaskMeta struct { - SubscriptionID string - Title string - PosterURL string - BackdropURL string - Overview string - MediaType string - MediaCategory string - SourceCategory string - OriginalName string - OriginalLanguage string - Year int - Rating float32 - Genres string - AllowExistingLibrary bool -} - -type downloadAddRequest struct { - title string - savePath string - qbitCategory string - meta DownloadTaskMeta -} - -// AddDownload accepts a magnet URL / HTTP URL and persists a tracking row. -func (d *DownloadService) AddDownload(ctx context.Context, userID, urlStr, savePath string) (*model.DownloadTask, error) { - return d.AddDownloadWithMeta(ctx, userID, urlStr, savePath, DownloadTaskMeta{}) -} - -func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlStr, savePath string, meta DownloadTaskMeta) (*model.DownloadTask, error) { - req, err := d.prepareDownloadAdd(ctx, urlStr, savePath, meta) - if err != nil { - return nil, err - } - if !req.meta.AllowExistingLibrary && d.localMediaAlreadyExists(ctx, req.title) { - return nil, ErrMediaAlreadyInLibrary - } - if existing, ok := d.findExistingDownloadTask(ctx, req); ok { - d.linkExistingDownloadTaskToSubscription(ctx, existing, req) - return existing, ErrDownloadAlreadyExists - } - _ = d.ReloadConfig(ctx) - target, err := d.defaultDownloadTarget(ctx) - if err != nil { - return nil, d.defaultDownloaderNotConfiguredError(ctx) - } - if liveTorrent, ok := d.findLiveTorrentByIdentity(ctx, urlStr, req); ok { - existingTarget := target - if strings.TrimSpace(liveTorrent.ClientID) != "" { - existingTarget = downloadTarget{clientID: liveTorrent.ClientID, typ: firstNonEmpty(liveTorrent.Source, target.typ)} - } - task, err := d.createTask(ctx, userID, urlStr, req.savePath, req.meta, existingTarget, liveTorrent.Hash) - if err != nil { - return nil, err - } - if strings.TrimSpace(req.meta.SubscriptionID) != "" { - return task, nil - } - return task, ErrDownloadAlreadyExists - } - externalID, err := d.addPreparedDownloadToClient(ctx, urlStr, &req, target) - if err != nil { - if errors.Is(err, ErrDownloadAlreadyExists) && strings.TrimSpace(req.meta.SubscriptionID) != "" { - return d.createTask(ctx, userID, urlStr, req.savePath, req.meta, target, externalID) - } - return nil, err - } - return d.createTask(ctx, userID, urlStr, req.savePath, req.meta, target, externalID) -} - -func (d *DownloadService) prepareDownloadAdd(ctx context.Context, urlStr, savePath string, meta DownloadTaskMeta) (downloadAddRequest, error) { - if urlStr == "" { - return downloadAddRequest{}, errors.New("empty url") - } - title := strings.TrimSpace(meta.Title) - if title == "" { - title = publicDownloadTitle(urlStr) - meta.Title = title - } - autoClassify := downloadSmartClassifyEnabled(ctx, d.repo, d.organizer) - savePath, resolvedCategory := d.resolveDownloadSavePath(ctx, savePath, meta, autoClassify) - if !autoClassify { - meta.MediaCategory = "" - } else if strings.TrimSpace(meta.MediaCategory) == "" { - meta.MediaCategory = resolvedCategory - } - return downloadAddRequest{ - title: title, - savePath: savePath, - qbitCategory: strings.TrimSpace(meta.MediaCategory), - meta: meta, - }, nil -} - -func (d *DownloadService) addPreparedDownloadToClient(ctx context.Context, urlStr string, req *downloadAddRequest, target downloadTarget) (string, error) { - var siteFetchErr error - if d.site != nil { - if data, name, err := d.site.FetchTorrentFile(ctx, urlStr); err == nil { - return d.addTorrentFileToTarget(ctx, data, name, req, target) - } else { - siteFetchErr = err - } - } - externalID, err := d.addTorrentURLToTarget(ctx, urlStr, req, target) - if err != nil { - return externalID, joinTorrentFetchError(err, siteFetchErr) - } - return externalID, nil -} - -func (d *DownloadService) addTorrentFileToTarget(ctx context.Context, data []byte, name string, req *downloadAddRequest, target downloadTarget) (string, error) { - if target.legacyQB { - if err := d.qb.AddTorrentFileWithCategory(ctx, data, name, req.savePath, req.qbitCategory); err != nil { - return "", err - } - return torrentInfoHash(data), nil - } - if categorized, ok := target.adapter.(CategorizedTorrentDownloadAdapter); ok { - externalID, err := categorized.AddTorrentFileWithCategory(ctx, data, name, req.savePath, req.qbitCategory) - setFetchedTorrentTitle(req, name, err) - return externalID, err - } - fileAdapter, ok := target.adapter.(TorrentFileDownloadAdapter) - if !ok { - return "", errors.New("configured downloader does not accept torrent files") - } - externalID, err := fileAdapter.AddTorrentFile(ctx, data, name, req.savePath) - setFetchedTorrentTitle(req, name, err) - return externalID, err -} - -func (d *DownloadService) addTorrentURLToTarget(ctx context.Context, urlStr string, req *downloadAddRequest, target downloadTarget) (string, error) { - if target.legacyQB { - if err := d.qb.AddTorrentWithCategory(ctx, urlStr, req.savePath, req.qbitCategory); err != nil { - return "", err - } - return torrentURLInfoHash(urlStr), nil - } - if categorized, ok := target.adapter.(CategorizedTorrentDownloadAdapter); ok { - return categorized.AddTorrentWithCategory(ctx, urlStr, req.savePath, req.qbitCategory) - } - if strings.HasPrefix(strings.ToLower(strings.TrimSpace(urlStr)), "magnet:") { - return target.adapter.AddMagnet(ctx, urlStr, req.savePath) - } - return target.adapter.AddTorrent(ctx, urlStr, req.savePath) -} - -func setFetchedTorrentTitle(req *downloadAddRequest, name string, addErr error) { - if addErr == nil && req != nil && strings.TrimSpace(req.meta.Title) == "" { - req.meta.Title = strings.TrimSuffix(name, path.Ext(name)) - } -} - -func joinTorrentFetchError(addErr, fetchErr error) error { - if fetchErr != nil && !strings.Contains(fetchErr.Error(), "no matching PT site") { - return errors.Join(addErr, fetchErr) - } - return addErr -} - -func (d *DownloadService) resolveDownloadSavePath(ctx context.Context, explicitSavePath string, meta DownloadTaskMeta, autoClassify bool) (string, string) { - if strings.TrimSpace(explicitSavePath) != "" { - if !autoClassify { - return explicitSavePath, "" - } - return explicitSavePath, strings.TrimSpace(meta.MediaCategory) - } - base := downloadDefaultSaveRoot(ctx, d.repo) - if strings.TrimSpace(base) == "" { - return "", strings.TrimSpace(meta.MediaCategory) - } - mediaType := normalizeMediaType(meta.MediaType, meta.Title, meta.SourceCategory) - category := strings.TrimSpace(meta.MediaCategory) - if category == "" { - category = classifyMediaCategory(mediaClassifyInput{ - MediaType: mediaType, - Title: meta.Title, - Category: meta.SourceCategory, - }, downloadCategoryMap(d.organizer)) - } - if !autoClassify || category == "" { - return base, "" - } - return downloadSavePathCategoryRoot(base, sanitizeFilename(category)), category -} - -func (d *DownloadService) createTask(ctx context.Context, userID, urlStr, savePath string, meta DownloadTaskMeta, target downloadTarget, externalID string) (*model.DownloadTask, error) { - title := strings.TrimSpace(meta.Title) - if title == "" { - title = publicDownloadTitle(urlStr) - } - t := &model.DownloadTask{ - UserID: userID, - SubscriptionID: strings.TrimSpace(meta.SubscriptionID), - DownloadClientID: target.clientID, - ExternalID: strings.TrimSpace(externalID), - Source: target.typ, - URL: urlStr, - Title: title, - PosterURL: meta.PosterURL, - BackdropURL: meta.BackdropURL, - Overview: meta.Overview, - SavePath: savePath, - MediaType: meta.MediaType, - MediaCategory: meta.MediaCategory, - OriginalName: meta.OriginalName, - OriginalLanguage: meta.OriginalLanguage, - Year: meta.Year, - Rating: meta.Rating, - Genres: meta.Genres, - Status: "queued", - AllowExistingLibrary: meta.AllowExistingLibrary, - } - if err := d.repo.Download.Create(ctx, t); err != nil { - return nil, err - } - return t, nil -} diff --git a/internal/service/download_add_classification_test.go b/internal/service/download_add_classification_test.go deleted file mode 100644 index 1e056ae..0000000 --- a/internal/service/download_add_classification_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestAddDownloadWithMetaAutoClassifiesSavePathAndQBitCategory(t *testing.T) { - var addCalls int32 - var gotSavePath string - var gotCategory string - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if atomic.LoadInt32(&addCalls) > 0 { - _, _ = w.Write([]byte(`[{"hash":"auto123","name":"声生不息 S01E01","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - if err := r.ParseMultipartForm(1024 * 1024); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - gotSavePath = r.FormValue("savepath") - gotCategory = r.FormValue("category") - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - if err := repos.Setting.Set(t.Context(), "qbittorrent.savepath", "/downloads"); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&dn=%E5%A3%B0%E7%94%9F%E4%B8%8D%E6%81%AF+S01E01", "", DownloadTaskMeta{ - Title: "声生不息 S01E01", - SourceCategory: "综艺", - }) - if err != nil { - t.Fatal(err) - } - wantPath := filepath.Join("/downloads", "综艺") - if task.SavePath != wantPath { - t.Fatalf("task save path = %q, want %q", task.SavePath, wantPath) - } - if gotSavePath != wantPath { - t.Fatalf("qb savepath = %q, want %q", gotSavePath, wantPath) - } - if gotCategory != "综艺" { - t.Fatalf("qb category = %q, want 综艺", gotCategory) - } -} - -func TestDownloadSavePathCategoryRootKeepsWindowsClientSeparators(t *testing.T) { - if got := downloadSavePathCategoryRoot(`F:\downloads`, "国产剧"); got != `F:\downloads\国产剧` { - t.Fatalf("downloadSavePathCategoryRoot() = %q, want Windows qB path", got) - } - if got := downloadSavePathCategoryRoot(`F:\downloads\国产剧`, "国产剧"); got != `F:\downloads\国产剧` { - t.Fatalf("downloadSavePathCategoryRoot() duplicated category: %q", got) - } - if got := downloadSavePathCategoryRoot(`/downloads`, "国产剧"); got != filepath.Join(`/downloads`, "国产剧") { - t.Fatalf("downloadSavePathCategoryRoot() = %q, want local path", got) - } -} - -func TestTranslateClientPathMapsWindowsQBitPathToContainerDownloadPath(t *testing.T) { - root := t.TempDir() - containerDownloads := filepath.Join(root, "downloads") - want := filepath.Join(containerDownloads, "国产剧", "Show.S01E01.mkv") - if err := os.MkdirAll(filepath.Dir(want), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(want, []byte("episode"), 0o644); err != nil { - t.Fatal(err) - } - - got := translateClientPath(`F:\downloads\国产剧\Show.S01E01.mkv`, map[string]string{ - `F:\downloads`: containerDownloads, - }) - if got != want { - t.Fatalf("translateClientPath() = %q, want %q", got, want) - } -} - -func TestAddDownloadWithMetaCanDisableAutoClassifiedSavePath(t *testing.T) { - var addCalls int32 - var gotSavePath string - var gotCategory string - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if atomic.LoadInt32(&addCalls) > 0 { - _, _ = w.Write([]byte(`[{"hash":"auto456","name":"声生不息 S01E01","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - if err := r.ParseMultipartForm(1024 * 1024); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - gotSavePath = r.FormValue("savepath") - gotCategory = r.FormValue("category") - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - if err := repos.Setting.Set(t.Context(), "qbittorrent.savepath", "/downloads"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), DownloadSmartClassifySettingKey, "false"); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:ffffffffffffffffffffffffffffffffffffffff&dn=%E5%A3%B0%E7%94%9F%E4%B8%8D%E6%81%AF+S01E01", "", DownloadTaskMeta{ - Title: "声生不息 S01E01", - SourceCategory: "综艺", - }) - if err != nil { - t.Fatal(err) - } - if task.SavePath != "/downloads" { - t.Fatalf("task save path = %q, want /downloads", task.SavePath) - } - if gotSavePath != "/downloads" { - t.Fatalf("qb savepath = %q, want /downloads", gotSavePath) - } - if gotCategory != "" { - t.Fatalf("qb category = %q, want empty", gotCategory) - } -} diff --git a/internal/service/download_add_dedup.go b/internal/service/download_add_dedup.go deleted file mode 100644 index 081de44..0000000 --- a/internal/service/download_add_dedup.go +++ /dev/null @@ -1,196 +0,0 @@ -package service - -import ( - "context" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (d *DownloadService) findExistingDownloadTask(ctx context.Context, req downloadAddRequest) (*model.DownloadTask, bool) { - key := downloadTaskIdentityKey(req.title) - if key == "" || d == nil || d.repo == nil || d.repo.Download == nil { - return nil, false - } - rows, err := d.repo.Download.List(ctx) - if err != nil { - return nil, false - } - subscriptionID := strings.TrimSpace(req.meta.SubscriptionID) - for i := range rows { - if subscriptionID != "" { - if !downloadTaskBlocksReadd(rows[i].Status) { - continue - } - if !downloadTaskInSubscriptionScope(rows[i], req) { - continue - } - if !d.subscriptionDownloadTaskStillLive(ctx, rows[i]) { - continue - } - } else if !downloadTaskBlocksDuplicate(rows[i].Status) { - continue - } - current := downloadTaskIdentityKey(rows[i].Title) - if downloadTaskCoversAddRequest(rows[i].Title, req) || current == key { - return &rows[i], true - } - } - return nil, false -} - -func (d *DownloadService) subscriptionDownloadTaskStillLive(ctx context.Context, row model.DownloadTask) bool { - live, ok := d.liveTorrentSnapshot(30 * time.Second) - if !ok && d != nil { - var err error - live, err = d.listLiveTorrents(ctx, "") - if err != nil && len(live) == 0 { - return true - } - ok = true - } - if !ok { - return true - } - for _, torrent := range live { - if downloadTaskMatchesLiveTorrent(row, torrent) { - return true - } - } - return false -} - -func downloadTaskMatchesLiveTorrent(row model.DownloadTask, torrent QBitTorrent) bool { - if strings.TrimSpace(row.DownloadClientID) != "" && strings.TrimSpace(torrent.ClientID) != "" && row.DownloadClientID != torrent.ClientID { - return false - } - if strings.TrimSpace(row.ExternalID) != "" { - return strings.EqualFold(strings.TrimSpace(row.ExternalID), strings.TrimSpace(torrent.Hash)) - } - torrentName := strings.TrimSpace(torrent.Name) - if torrentName == "" { - return false - } - req := downloadAddRequest{ - title: row.Title, - savePath: row.SavePath, - meta: DownloadTaskMeta{ - SubscriptionID: row.SubscriptionID, - }, - } - if downloadTaskCoversAddRequest(torrentName, req) { - return true - } - rowKey := downloadTaskIdentityKey(row.Title) - torrentKey := downloadTaskIdentityKey(torrentName) - if rowKey != "" && torrentKey != "" { - return rowKey == torrentKey - } - if len(episodeRefsFromTitle(row.Title)) > 0 || len(episodeRefsFromTitle(torrentName)) > 0 { - return false - } - rowTorrentKey := normalizeTorrentName(row.Title) - liveTorrentKey := normalizeTorrentName(torrentName) - return rowTorrentKey != "" && rowTorrentKey == liveTorrentKey -} - -func downloadTaskCoversAddRequest(existing string, req downloadAddRequest) bool { - if subscriptionRequestHasExplicitEpisodes(req) { - return downloadExplicitEpisodesCoverRequest(existing, req.title) - } - return downloadTitleCoversRequest(existing, req.title) -} - -func subscriptionRequestHasExplicitEpisodes(req downloadAddRequest) bool { - return strings.TrimSpace(req.meta.SubscriptionID) != "" && len(episodeRefsFromTitle(req.title)) > 0 -} - -func downloadExplicitEpisodesCoverRequest(existing, requested string) bool { - current := parseDownloadMediaIdentity(existing) - want := parseDownloadMediaIdentity(requested) - if current.TitleKey == "" || want.TitleKey == "" { - return false - } - if current.TitleKey != want.TitleKey { - return false - } - if current.Year > 0 && want.Year > 0 && current.Year != want.Year { - return false - } - if len(current.Episodes) == 0 || len(want.Episodes) == 0 { - return false - } - currentEpisodes := map[string]struct{}{} - for _, ref := range current.Episodes { - currentEpisodes[episodeKey(ref.Season, ref.Episode)] = struct{}{} - } - for _, ref := range want.Episodes { - if _, ok := currentEpisodes[episodeKey(ref.Season, ref.Episode)]; !ok { - return false - } - } - return true -} - -func downloadTaskInSubscriptionScope(row model.DownloadTask, req downloadAddRequest) bool { - subscriptionID := strings.TrimSpace(req.meta.SubscriptionID) - if subscriptionID == "" { - return true - } - rowSubscriptionID := strings.TrimSpace(row.SubscriptionID) - if rowSubscriptionID != "" { - return rowSubscriptionID == subscriptionID - } - rowSavePath := strings.TrimSpace(row.SavePath) - requestSavePath := strings.TrimSpace(req.savePath) - if rowSavePath == "" || requestSavePath == "" { - return false - } - return sameOrChildPath(rowSavePath, requestSavePath) || sameOrChildPath(requestSavePath, rowSavePath) -} - -func (d *DownloadService) findLiveTorrentByIdentity(ctx context.Context, downloadURL string, req downloadAddRequest) (QBitTorrent, bool) { - query := downloadTaskIdentityKey(req.title) - requestHash := torrentURLInfoHash(downloadURL) - if query == "" && requestHash == "" { - return QBitTorrent{}, false - } - live, err := d.listLiveTorrents(ctx, "") - if err != nil { - if len(live) == 0 { - return QBitTorrent{}, false - } - } - for _, torrent := range live { - if !torrentInDownloadRequestScope(torrent, req) { - continue - } - if requestHash != "" && strings.EqualFold(requestHash, strings.TrimSpace(torrent.Hash)) { - return torrent, true - } - if downloadTaskCoversAddRequest(torrent.Name, req) { - return torrent, true - } - current := downloadTaskIdentityKey(torrent.Name) - if current == "" { - continue - } - if current == query { - return torrent, true - } - } - return QBitTorrent{}, false -} - -func torrentInDownloadRequestScope(torrent QBitTorrent, req downloadAddRequest) bool { - if strings.TrimSpace(req.meta.SubscriptionID) == "" { - return true - } - requestSavePath := strings.TrimSpace(req.savePath) - torrentSavePath := strings.TrimSpace(torrent.SavePath) - if requestSavePath == "" || torrentSavePath == "" { - return false - } - return sameOrChildPath(torrentSavePath, requestSavePath) || sameOrChildPath(requestSavePath, torrentSavePath) -} diff --git a/internal/service/download_add_dedup_test.go b/internal/service/download_add_dedup_test.go deleted file mode 100644 index 15bcace..0000000 --- a/internal/service/download_add_dedup_test.go +++ /dev/null @@ -1,332 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestAddDownloadWithMetaDoesNotDedupRangeAgainstSingleEpisodeTask(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - http.Error(w, "temporary list unavailable", http.StatusInternalServerError) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - UserID: "u1", - Source: "qbittorrent", - URL: "https://pt.example/download?id=old", - Title: "Archives The Nanyang Mystery 2026 S01E07 2160p WEB-DL", - SavePath: "/downloads/tv", - Status: "completed", - Progress: 1, - }); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd&dn=Archives+The+Nanyang+Mystery+2026+S01E07-S01E08", "/downloads", DownloadTaskMeta{ - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - }) - if err != nil { - t.Fatalf("AddDownloadWithMeta returned %v, want queued because existing task covers only E07", err) - } - if task == nil { - t.Fatal("task = nil, want queued task") - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } -} - -func TestAddDownloadWithMetaDoesNotDedupRangeAgainstSeasonOnlyTask(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - http.Error(w, "temporary list unavailable", http.StatusInternalServerError) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - UserID: "u1", - SubscriptionID: "sub-nanyang", - Source: "qbittorrent", - URL: "https://pt.example/download?id=old-season", - Title: "Archives The Nanyang Mystery 2026 S01 2160p WEB-DL", - SavePath: "/downloads/tv", - Status: "completed", - Progress: 1, - }); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:abababababababababababababababababababab&dn=Archives+The+Nanyang+Mystery+2026+S01E09-E10", "/downloads/tv", DownloadTaskMeta{ - SubscriptionID: "sub-nanyang", - Title: "Archives The Nanyang Mystery 2026 S01E09-E10 2160p WEB-DL", - }) - if err != nil { - t.Fatalf("AddDownloadWithMeta returned %v, want queued because season-only task does not prove E09-E10 exists", err) - } - if task == nil { - t.Fatal("task = nil, want queued task") - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } -} - -func TestAddDownloadWithMetaDoesNotDedupSubscriptionRangeAgainstCompletePackTask(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - http.Error(w, "temporary list unavailable", http.StatusInternalServerError) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - UserID: "u1", - SubscriptionID: "sub-nanyang", - Source: "qbittorrent", - URL: "https://pt.example/download?id=old-complete", - Title: "Archives The Nanyang Mystery 2026 S01 Complete 2160p WEB-DL", - SavePath: "/downloads/tv", - Status: "completed", - Progress: 1, - }); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:fafafafafafafafafafafafafafafafafafafafa&dn=Archives+The+Nanyang+Mystery+2026+S01E29-E33", "/downloads/tv", DownloadTaskMeta{ - SubscriptionID: "sub-nanyang", - Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", - }) - if err != nil { - t.Fatalf("AddDownloadWithMeta returned %v, want queued because complete-pack history does not prove missing range exists", err) - } - if task == nil { - t.Fatal("task = nil, want queued task") - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } -} - -func TestAddDownloadWithMetaTracksExistingQBTorrentForSubscription(t *testing.T) { - torrentData := []byte("d4:infod4:name7:fixtureee") - hash := torrentInfoHash(torrentData) - if hash == "" { - t.Fatal("expected fixture info hash") - } - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/fixture.torrent": - w.Header().Set("Content-Type", "application/x-bittorrent") - _, _ = w.Write(torrentData) - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[{"hash":"` + hash + `","name":"Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL","save_path":"/downloads/tv"}]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", qb.URL+"/fixture.torrent", "/downloads/tv", DownloadTaskMeta{ - SubscriptionID: "sub-nanyang", - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - }) - if err != nil { - t.Fatalf("AddDownloadWithMeta returned %v, want tracking task for existing qB torrent", err) - } - if task == nil || task.SubscriptionID != "sub-nanyang" { - t.Fatalf("task = %#v, want subscription tracking task", task) - } - if task.DownloadClientID != legacyQBitDownloadClientID || task.ExternalID != hash || task.Source != "qbittorrent" { - t.Fatalf("tracked downloader identity = %#v", task) - } - if got := atomic.LoadInt32(&addCalls); got != 0 { - t.Fatalf("qb add calls = %d, want 0 because infohash already exists", got) - } -} - -func TestDownloadTitleCoversRequestKeepsCompletePackDedup(t *testing.T) { - if !downloadTitleCoversRequest("Archives The Nanyang Mystery 2026 S01 Complete 2160p WEB-DL", "Archives The Nanyang Mystery 2026 S01E09-E10 2160p WEB-DL") { - t.Fatal("complete pack should cover requested episode range") - } - if downloadTitleCoversRequest("Archives The Nanyang Mystery 2026 S01 2160p WEB-DL", "Archives The Nanyang Mystery 2026 S01E09-E10 2160p WEB-DL") { - t.Fatal("season-only title must not cover requested episode range") - } -} - -func TestAddDownloadWithMetaScopesSubscriptionDedupBySubscriptionOrSavePath(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - http.Error(w, "temporary list unavailable", http.StatusInternalServerError) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - for _, existing := range []model.DownloadTask{ - { - UserID: "u1", - SubscriptionID: "other-subscription", - Source: "qbittorrent", - URL: "https://pt.example/download?id=old-sub", - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - SavePath: "/downloads/other", - Status: "completed", - Progress: 1, - }, - { - UserID: "u1", - Source: "qbittorrent", - URL: "https://pt.example/download?id=old-manual", - Title: "Archives The Nanyang Mystery 2026 S01E09-S01E10 2160p WEB-DL", - SavePath: "/downloads/archive", - Status: "completed", - Progress: 1, - }, - } { - row := existing - if err := repos.Download.Create(t.Context(), &row); err != nil { - t.Fatal(err) - } - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:efefefefefefefefefefefefefefefefefefefef&dn=Archives+The+Nanyang+Mystery+2026+S01E07-S01E08", "/downloads/tv", DownloadTaskMeta{ - SubscriptionID: "current-subscription", - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - }) - if err != nil { - t.Fatalf("AddDownloadWithMeta returned %v, want queued because old task is outside current subscription scope", err) - } - if task == nil { - t.Fatal("task = nil, want queued task") - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } -} - -func TestAddDownloadWithMetaRequeuesStaleSubscriptionTaskMissingFromQB(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if atomic.LoadInt32(&addCalls) > 0 { - _, _ = w.Write([]byte(`[{"hash":"newhash","name":"Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - UserID: "u1", - SubscriptionID: "sub-nanyang", - Source: "qbittorrent", - URL: "https://pt.example/download?id=stale", - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - SavePath: "/downloads/tv", - Status: "queued", - Progress: 0, - }); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.qb.Configure(QBitConfig{BaseURL: qb.URL, Username: "admin", Password: "admin"}) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:bcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbc&dn=Archives+The+Nanyang+Mystery+2026+S01E07-S01E08", "/downloads/tv", DownloadTaskMeta{ - SubscriptionID: "sub-nanyang", - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - }) - if err != nil { - t.Fatalf("AddDownloadWithMeta returned %v, want stale task ignored and candidate queued", err) - } - if task == nil { - t.Fatal("task = nil, want requeued task") - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } -} diff --git a/internal/service/download_add_existing_test.go b/internal/service/download_add_existing_test.go deleted file mode 100644 index 96d69d5..0000000 --- a/internal/service/download_add_existing_test.go +++ /dev/null @@ -1,220 +0,0 @@ -package service - -import ( - "errors" - "fmt" - "net/http" - "net/http/httptest" - "path/filepath" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestAddDownloadWithMetaSkipsExistingLocalMovieBeforeQBAdd(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := db.Create(&model.Media{ - Title: "Inception", - Path: "/media/movies/Inception (2010)/Inception (2010).mkv", - }).Error; err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&dn=Inception+2010+1080p", "/downloads", DownloadTaskMeta{ - Title: "Inception 2010 1080p WEB-DL", - }) - if !errors.Is(err, ErrMediaAlreadyInLibrary) { - t.Fatalf("err = %v, want ErrMediaAlreadyInLibrary", err) - } - if task != nil { - t.Fatalf("task = %#v, want nil because local media already exists", task) - } - rows, err := repos.Download.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("download rows = %d, want 0", len(rows)) - } -} - -func TestAddDownloadWithMetaSkipsExistingLocalEpisodeBeforeQBAdd(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := db.Create(&model.Media{ - Title: "Some Show", - Path: "/media/tv/Some Show/Season 01/Some Show - S01E01.mkv", - SeasonNum: 1, - EpisodeNum: 1, - }).Error; err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&dn=Some+Show+S01E01", "/downloads", DownloadTaskMeta{ - Title: "Some Show S01E01 2160p WEB-DL", - }) - if !errors.Is(err, ErrMediaAlreadyInLibrary) { - t.Fatalf("err = %v, want ErrMediaAlreadyInLibrary", err) - } - if task != nil { - t.Fatalf("task = %#v, want nil because local episode already exists", task) - } - rows, err := repos.Download.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("download rows = %d, want 0", len(rows)) - } -} - -func TestAddDownloadWithMetaQueuesEpisodeRangeWhenOnlyPartlyInLibrary(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - http.Error(w, "temporary list unavailable", http.StatusInternalServerError) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Media{}, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - if err := db.Create(&model.Media{ - Title: "Archives The Nanyang Mystery", - Path: "/media/tv/Archives The Nanyang Mystery/Season 01/Archives The Nanyang Mystery - S01E07.mkv", - SeasonNum: 1, - EpisodeNum: 7, - }).Error; err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:abababababababababababababababababababab&dn=Archives+The+Nanyang+Mystery+2026+S01E07-S01E08", "/downloads", DownloadTaskMeta{ - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - }) - if err != nil { - t.Fatalf("AddDownloadWithMeta returned %v, want queued because E08 is missing", err) - } - if task == nil { - t.Fatal("task = nil, want queued task") - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } -} - -func TestAddDownloadWithMetaSkipsEpisodeRangeWhenFullyInLibrary(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - for _, episode := range []int{7, 8} { - if err := db.Create(&model.Media{ - Title: "Archives The Nanyang Mystery", - Path: filepath.Join("/media/tv/Archives The Nanyang Mystery/Season 01", fmt.Sprintf("Archives The Nanyang Mystery - S01E%02d.mkv", episode)), - SeasonNum: 1, - EpisodeNum: episode, - }).Error; err != nil { - t.Fatal(err) - } - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:babababababababababababababababababababa&dn=Archives+The+Nanyang+Mystery+2026+S01E07-S01E08", "/downloads", DownloadTaskMeta{ - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - }) - if !errors.Is(err, ErrMediaAlreadyInLibrary) { - t.Fatalf("err = %v, want ErrMediaAlreadyInLibrary", err) - } - if task != nil { - t.Fatalf("task = %#v, want nil", task) - } -} - -func TestAddDownloadWithMetaQueuesExplicitEpisodeWhenOnlySeriesPackInLibrary(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - http.Error(w, "temporary list unavailable", http.StatusInternalServerError) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Media{}, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - if err := db.Create(&model.Media{ - Title: "Archives The Nanyang Mystery S01 Complete", - Path: "/media/tv/Archives The Nanyang Mystery/Season 01/Archives The Nanyang Mystery S01 Complete.mkv", - }).Error; err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:fefefefefefefefefefefefefefefefefefefefe&dn=Archives+The+Nanyang+Mystery+2026+S01E29-E33", "/downloads/tv", DownloadTaskMeta{ - SubscriptionID: "sub-nanyang", - Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", - }) - if err != nil { - t.Fatalf("AddDownloadWithMeta returned %v, want queued because explicit missing episodes are not proven by a pack row", err) - } - if task == nil { - t.Fatal("task = nil, want queued task") - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } -} - -func TestAddDownloadWithMetaSkipsExistingLocalEpisodeWithReleaseGroup(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}, &model.DownloadTask{}, &model.Setting{}, &model.DownloadClient{}) - repos := repository.New(db) - if err := db.Create(&model.Media{ - Title: "凡人修仙传", - Path: "/media/动漫/国漫/凡人修仙传/Season 01/凡人修仙传 - S01E146.mkv", - SeasonNum: 1, - EpisodeNum: 146, - }).Error; err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&dn=%5BMagicStar%5D+%E5%87%A1%E4%BA%BA%E4%BF%AE%E4%BB%99%E4%BC%A0+%E5%B9%B4%E7%95%AA+-+146+%5B1080p%5D", "/downloads", DownloadTaskMeta{ - Title: "[MagicStar] 凡人修仙传 年番 - 146 [1080p][WEB-DL]", - }) - if !errors.Is(err, ErrMediaAlreadyInLibrary) { - t.Fatalf("err = %v, want ErrMediaAlreadyInLibrary", err) - } - if task != nil { - t.Fatalf("task = %#v, want nil", task) - } - rows, err := repos.Download.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("download rows = %d, want 0", len(rows)) - } -} diff --git a/internal/service/download_add_local.go b/internal/service/download_add_local.go deleted file mode 100644 index 8400676..0000000 --- a/internal/service/download_add_local.go +++ /dev/null @@ -1,92 +0,0 @@ -package service - -import ( - "context" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (d *DownloadService) localMediaAlreadyExists(ctx context.Context, title string) bool { - rows, ok := d.localMediaAvailabilityRows(ctx, title) - if !ok { - return false - } - return localMediaRowsMatchDownloadTitle(title, rows) -} - -func (d *DownloadService) localMediaAvailabilityRows(ctx context.Context, title string) ([]model.Media, bool) { - if d == nil || d.repo == nil || d.repo.DB == nil { - return nil, false - } - if !d.repo.DB.Migrator().HasTable(&model.Media{}) { - return nil, false - } - queries := localAvailabilityTitleCandidates(title) - if len(queries) == 0 { - return nil, false - } - var rows []model.Media - db := d.repo.DB.WithContext(ctx).Model(&model.Media{}) - for i, query := range queries { - like := "%" + query + "%" - clause := "title LIKE ? OR original_name LIKE ? OR path LIKE ?" - if i == 0 { - db = db.Where(clause, like, like, like) - } else { - db = db.Or(clause, like, like, like) - } - } - if err := db. - Order("season_num asc, episode_num asc, created_at desc"). - Limit(200). - Find(&rows).Error; err != nil || len(rows) == 0 { - return nil, false - } - return rows, true -} - -func localMediaRowsMatchDownloadTitle(title string, rows []model.Media) bool { - wanted := episodeRefsFromTitle(title) - if len(wanted) == 0 { - return true - } - existing := map[string]struct{}{} - hasSeriesPack := false - for _, row := range rows { - rowSeason, rowEpisode := localMediaRowSeasonEpisode(row) - if rowEpisode > 0 { - existing[episodeKey(rowSeason, rowEpisode)] = struct{}{} - continue - } - if rowEpisode <= 0 && isSeriesPackTitle(row.Title+" "+row.OriginalName+" "+row.Path) { - hasSeriesPack = true - } - } - if hasSeriesPack { - return len(wanted) == 0 - } - for _, ref := range wanted { - if _, ok := existing[episodeKey(ref.Season, ref.Episode)]; !ok { - return false - } - } - return true -} - -func localMediaRowSeasonEpisode(row model.Media) (int, int) { - rowSeason := row.SeasonNum - rowEpisode := row.EpisodeNum - if rowSeason <= 0 || rowEpisode <= 0 { - parsedSeason, parsedEpisode := ParseEpisode(row.Path) - if rowSeason <= 0 { - rowSeason = parsedSeason - } - if rowEpisode <= 0 { - rowEpisode = parsedEpisode - } - } - if rowSeason <= 0 { - rowSeason = 1 - } - return rowSeason, rowEpisode -} diff --git a/internal/service/download_add_subscription.go b/internal/service/download_add_subscription.go deleted file mode 100644 index f6daab5..0000000 --- a/internal/service/download_add_subscription.go +++ /dev/null @@ -1,48 +0,0 @@ -package service - -import ( - "context" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (d *DownloadService) linkExistingDownloadTaskToSubscription(ctx context.Context, task *model.DownloadTask, req downloadAddRequest) { - subscriptionID := strings.TrimSpace(req.meta.SubscriptionID) - if d == nil || d.repo == nil || d.repo.DB == nil || task == nil || subscriptionID == "" || strings.TrimSpace(task.ID) == "" { - return - } - updates := map[string]any{} - if strings.TrimSpace(task.SubscriptionID) == "" { - updates["subscription_id"] = subscriptionID - task.SubscriptionID = subscriptionID - } - if strings.TrimSpace(task.MediaType) == "" && strings.TrimSpace(req.meta.MediaType) != "" { - updates["media_type"] = req.meta.MediaType - task.MediaType = req.meta.MediaType - } - if strings.TrimSpace(task.MediaCategory) == "" && strings.TrimSpace(req.meta.MediaCategory) != "" { - updates["media_category"] = req.meta.MediaCategory - task.MediaCategory = req.meta.MediaCategory - } - if strings.TrimSpace(task.PosterURL) == "" && strings.TrimSpace(req.meta.PosterURL) != "" { - updates["poster_url"] = req.meta.PosterURL - task.PosterURL = req.meta.PosterURL - } - if strings.TrimSpace(task.BackdropURL) == "" && strings.TrimSpace(req.meta.BackdropURL) != "" { - updates["backdrop_url"] = req.meta.BackdropURL - task.BackdropURL = req.meta.BackdropURL - } - if strings.TrimSpace(task.Overview) == "" && strings.TrimSpace(req.meta.Overview) != "" { - updates["overview"] = req.meta.Overview - task.Overview = req.meta.Overview - } - if !task.AllowExistingLibrary && req.meta.AllowExistingLibrary { - updates["allow_existing_library"] = true - task.AllowExistingLibrary = true - } - if len(updates) == 0 { - return - } - _ = d.repo.DB.WithContext(ctx).Model(&model.DownloadTask{}).Where("id = ?", task.ID).Updates(updates).Error -} diff --git a/internal/service/download_add_test.go b/internal/service/download_add_test.go deleted file mode 100644 index d43cf95..0000000 --- a/internal/service/download_add_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package service - -import ( - "errors" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestPublicDownloadTitleUsesMagnetDisplayName(t *testing.T) { - got := publicDownloadTitle("magnet:?xt=urn:btih:abc&dn=%E6%B5%8B%E8%AF%95%E5%BD%B1%E7%89%87") - if got != "测试影片" { - t.Fatalf("publicDownloadTitle = %q, want %q", got, "测试影片") - } -} - -func TestTorrentURLInfoHashNormalizesBase32BTIH(t *testing.T) { - got := torrentURLInfoHash("magnet:?xt=urn:btih:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") - if got != "0000000000000000000000000000000000000000" { - t.Fatalf("hash = %q", got) - } -} - -func configureTestDefaultQB(t *testing.T, repos *repository.Container, baseURL string) { - t.Helper() - if err := repos.DownloadClient.Create(t.Context(), &model.DownloadClient{ - Name: "qB test", - Type: "qbittorrent", - Host: baseURL, - Username: "admin", - Password: "admin", - IsDefault: true, - Enabled: true, - }); err != nil { - t.Fatalf("create default qB client: %v", err) - } - if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil { - t.Fatalf("mark download clients managed: %v", err) - } -} - -func TestAddDownloadWithMetaSkipsExistingTaskBeforeQBAdd(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - existing := &model.DownloadTask{ - UserID: "u1", - Source: "qbittorrent", - URL: "https://pt.example/download?id=old&passkey=old", - Title: "Some Show S01E01 1080p", - SavePath: "/downloads/tv", - Status: "completed", - Progress: 1, - } - if err := repos.Download.Create(t.Context(), existing); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.qb.Configure(QBitConfig{BaseURL: qb.URL, Username: "admin", Password: "admin"}) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "https://pt.example/download?id=new&passkey=new", "/downloads/tv", DownloadTaskMeta{ - Title: "Some Show S01E01 2160p WEB-DL", - }) - if !errors.Is(err, ErrDownloadAlreadyExists) { - t.Fatalf("err = %v, want ErrDownloadAlreadyExists", err) - } - if task == nil || task.ID != existing.ID { - t.Fatalf("task = %#v, want existing task %#v", task, existing) - } - if got := atomic.LoadInt32(&addCalls); got != 0 { - t.Fatalf("qb add calls = %d, want 0", got) - } -} - -func TestAddDownloadWithMetaSkipsUserDeletedTaskBeforeQBAdd(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Media{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), "qbittorrent.url", qb.URL); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "qbittorrent.username", "admin"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "qbittorrent.password", "admin"); err != nil { - t.Fatal(err) - } - existing := &model.DownloadTask{ - UserID: "u1", - Source: "qbittorrent", - URL: "https://pt.example/download?id=old&passkey=old", - Title: "User Deleted Show S01E01 1080p", - SavePath: "/downloads/tv", - Status: "deleted", - } - if err := repos.Download.Create(t.Context(), existing); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "https://pt.example/download?id=new&passkey=new", "/downloads/tv", DownloadTaskMeta{ - Title: "User Deleted Show S01E01 1080p WEB-DL", - }) - if !errors.Is(err, ErrDownloadAlreadyExists) { - t.Fatalf("err = %v, want ErrDownloadAlreadyExists", err) - } - if task == nil || task.ID != existing.ID { - t.Fatalf("task = %#v, want existing task %#v", task, existing) - } - if got := atomic.LoadInt32(&addCalls); got != 0 { - t.Fatalf("qb add calls = %d, want 0", got) - } -} diff --git a/internal/service/download_classifier.go b/internal/service/download_classifier.go deleted file mode 100644 index 03a5e85..0000000 --- a/internal/service/download_classifier.go +++ /dev/null @@ -1,83 +0,0 @@ -package service - -import ( - "context" - "os" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func downloadDefaultSaveRoot(ctx context.Context, repo *repository.Container) string { - if repo != nil && repo.Setting != nil { - if base, _ := repo.Setting.Get(ctx, "qbittorrent.savepath"); strings.TrimSpace(base) != "" { - return strings.TrimSpace(base) - } - } - for _, key := range []string{"MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "MEDIASTATION_DOWNLOAD_DIR"} { - if value := strings.TrimSpace(os.Getenv(key)); value != "" { - return value - } - } - return "" -} - -func downloadSmartClassifyEnabled(ctx context.Context, repo *repository.Container, organizer *OrganizerService) bool { - if repo != nil && repo.Setting != nil { - val, err := repo.Setting.Get(ctx, DownloadSmartClassifySettingKey) - if err == nil && val != "" { - return parseBoolSetting(val, true) - } - val, err = repo.Setting.Get(ctx, "organizer.smart_classify") - if err == nil && parseBoolSetting(val, false) { - return true - } - } - if organizer != nil && organizer.cfg != nil && organizer.cfg.Organizer.SmartClassify { - return true - } - return true -} - -func downloadCategoryMap(organizer *OrganizerService) map[string]string { - if organizer == nil { - return nil - } - return organizer.categoryMap() -} - -func downloadSavePathCategoryRoot(root, category string) string { - root = strings.TrimSpace(root) - category = strings.TrimSpace(category) - if root == "" || category == "" { - return root - } - if isWindowsStyleClientPath(root) { - cleanRoot := strings.ReplaceAll(root, "/", `\`) - cleanRoot = strings.TrimRight(cleanRoot, `\`) - if windowsPathBaseEqual(cleanRoot, category) { - return cleanRoot - } - return cleanRoot + `\` + category - } - return categoryRoot(root, category) -} - -func isWindowsStyleClientPath(path string) bool { - path = strings.TrimSpace(path) - return (len(path) >= 2 && isASCIIAlpha(path[0]) && path[1] == ':') || - strings.HasPrefix(path, `\\`) -} - -func windowsPathBaseEqual(path, base string) bool { - path = strings.TrimRight(strings.ReplaceAll(strings.TrimSpace(path), "/", `\`), `\`) - base = strings.Trim(strings.TrimSpace(base), `\/`) - if path == "" || base == "" { - return false - } - idx := strings.LastIndex(path, `\`) - if idx >= 0 { - path = path[idx+1:] - } - return strings.EqualFold(path, base) -} diff --git a/internal/service/download_client_endpoint.go b/internal/service/download_client_endpoint.go deleted file mode 100644 index 58a3e0c..0000000 --- a/internal/service/download_client_endpoint.go +++ /dev/null @@ -1,115 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "regexp" - "strconv" - "strings" -) - -var downloadClientEndpointPattern = regexp.MustCompile(`^https?://(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?(?:/[A-Za-z0-9._~%!$&'()*+,;=:@/-]*)?$`) - -func NormalizeDownloadClientHost(clientType, raw string) (string, error) { - return normalizeDownloadClientEndpoint(clientType, raw) -} - -func normalizeDownloadClientEndpoint(clientType, raw string) (string, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return "", errors.New("host required") - } - if strings.ContainsAny(raw, "\r\n\t") { - return "", errors.New("host contains invalid control characters") - } - if !strings.Contains(raw, "://") { - raw = "http://" + raw - } - if !downloadClientEndpointPattern.MatchString(raw) { - return "", errors.New("host must be a valid http(s) URL without username, query, or fragment") - } - parsed, err := url.Parse(raw) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return "", errors.New("host must be a valid http(s) URL") - } - scheme := strings.ToLower(parsed.Scheme) - if scheme != "http" && scheme != "https" { - return "", errors.New("host only supports http or https") - } - if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { - return "", errors.New("host must not include username, query, or fragment") - } - if strings.TrimSpace(parsed.Hostname()) == "" { - return "", errors.New("host must include a hostname") - } - if port := parsed.Port(); port != "" { - n, err := strconv.Atoi(port) - if err != nil || n < 1 || n > 65535 { - return "", errors.New("host port must be between 1 and 65535") - } - } - if err := validateDownloadClientPath(clientType, parsed.Path); err != nil { - return "", err - } - parsed.Scheme = scheme - parsed.Path = strings.TrimRight(parsed.Path, "/") - parsed.RawPath = "" - parsed.RawQuery = "" - parsed.Fragment = "" - return strings.TrimRight(parsed.String(), "/"), nil -} - -func validateDownloadClientPath(clientType, rawPath string) error { - rawPath = strings.TrimSpace(rawPath) - if rawPath == "" || rawPath == "/" { - return nil - } - for _, segment := range strings.Split(rawPath, "/") { - if segment == "." || segment == ".." { - return errors.New("host path must not contain traversal segments") - } - } - switch clientType { - case "qbittorrent", "aria2", "transmission": - return nil - default: - return fmt.Errorf("unsupported client type %q", clientType) - } -} - -func downloadClientRPCURL(clientType, host string) (string, error) { - base, err := normalizeDownloadClientEndpoint(clientType, host) - if err != nil { - return "", err - } - u, err := url.Parse(base) - if err != nil { - return "", err - } - switch clientType { - case "aria2": - if !strings.HasSuffix(strings.ToLower(u.Path), "/jsonrpc") { - u.Path = strings.TrimRight(u.Path, "/") + "/jsonrpc" - } - case "transmission": - if !strings.Contains(strings.ToLower(u.Path), "/rpc") { - u.Path = strings.TrimRight(u.Path, "/") + "/transmission/rpc" - } - case "qbittorrent": - default: - return "", fmt.Errorf("unsupported client type %q", clientType) - } - return u.String(), nil -} - -func newDownloadClientHTTPRequest(ctx context.Context, method, endpoint string, body io.Reader) (*http.Request, error) { - endpoint = strings.TrimSpace(endpoint) - if !downloadClientEndpointPattern.MatchString(endpoint) { - return nil, errors.New("download client endpoint failed safety validation") - } - return http.NewRequestWithContext(ctx, method, endpoint, body) -} diff --git a/internal/service/download_clients.go b/internal/service/download_clients.go deleted file mode 100644 index 64686dd..0000000 --- a/internal/service/download_clients.go +++ /dev/null @@ -1,270 +0,0 @@ -// Package service — download client (qBittorrent / Aria2 / Transmission) -// configuration. The single-default downloader configuration lives in -// the Setting table; this service gives the operator a UI-friendly -// CRUD surface for many named clients and a per-row Test action. -package service - -import ( - "context" - "errors" - "fmt" - "net/http" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// DownloadClientService persists model.DownloadClient rows. -type DownloadClientService struct { - log *zap.Logger - repo *repository.Container - client *http.Client -} - -// NewDownloadClientService is the constructor. -func NewDownloadClientService(log *zap.Logger, repo *repository.Container) *DownloadClientService { - return &DownloadClientService{ - log: log, - repo: repo, - client: NewInternalHTTPClient(10 * time.Second), - } -} - -// DownloadClientInput is the create / update payload. -type DownloadClientInput struct { - Name string `json:"name" binding:"required"` - Type string `json:"type" binding:"required"` - Host string `json:"host" binding:"required"` - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` - IsDefault bool `json:"is_default"` - Enabled bool `json:"enabled"` -} - -// List returns every configured client. -func (s *DownloadClientService) List(ctx context.Context) ([]model.DownloadClient, error) { - return s.repo.DownloadClient.List(ctx) -} - -// Create inserts a new client. -func (s *DownloadClientService) Create(ctx context.Context, in DownloadClientInput) (*model.DownloadClient, error) { - normalized, err := normalizeDownloadClientInput(in) - if err != nil { - return nil, err - } - s.markManaged(ctx) - c := &model.DownloadClient{ - Name: normalized.Name, - Type: normalized.Type, - Host: normalized.Host, - Username: normalized.Username, - Password: normalized.Password, - IsDefault: normalized.IsDefault, - Enabled: normalized.Enabled, - } - if !c.IsDefault && c.Enabled { - if currentDefault, err := s.repo.DownloadClient.FindDefault(ctx); err == nil && currentDefault == nil { - if enabled, err := s.repo.DownloadClient.ListEnabled(ctx); err == nil && len(enabled) == 0 { - c.IsDefault = true - } - } - } - if normalized.IsDefault { - _ = s.repo.DownloadClient.ClearDefault(ctx) - } - if err := s.repo.DownloadClient.Create(ctx, c); err != nil { - return nil, err - } - return c, nil -} - -// Update applies a patch. -func (s *DownloadClientService) Update(ctx context.Context, id string, in DownloadClientInput) (*model.DownloadClient, error) { - normalized, err := normalizeDownloadClientInput(in) - if err != nil { - return nil, err - } - s.markManaged(ctx) - patch := map[string]any{ - "name": normalized.Name, - "type": normalized.Type, - "host": normalized.Host, - "username": normalized.Username, - "is_default": normalized.IsDefault, - "enabled": normalized.Enabled, - } - // Only overwrite the password when the caller actually sent one. - if normalized.Password != "" { - patch["password"] = normalized.Password - } - // Fetch existing row, apply patch via Save - existing, err := s.repo.DownloadClient.FindByID(ctx, id) - if err != nil { - return nil, err - } - if existing == nil { - return nil, errors.New("client not found") - } - if normalized.IsDefault { - _ = s.repo.DownloadClient.ClearDefault(ctx) - } - existing.Name = patch["name"].(string) - existing.Type = patch["type"].(string) - existing.Host = patch["host"].(string) - existing.Username = patch["username"].(string) - existing.IsDefault = patch["is_default"].(bool) - existing.Enabled = patch["enabled"].(bool) - if pw, ok := patch["password"]; ok { - existing.Password = pw.(string) - } - if err := s.repo.DownloadClient.Update(ctx, existing); err != nil { - return nil, err - } - s.clearLegacyQBitConnectionIfNoDefault(ctx) - return s.repo.DownloadClient.FindByID(ctx, id) -} - -// Delete removes one client. -func (s *DownloadClientService) Delete(ctx context.Context, id string) error { - s.markManaged(ctx) - if err := s.repo.DownloadClient.Delete(ctx, id); err != nil { - return err - } - s.clearLegacyQBitConnectionIfNoDefault(ctx) - return nil -} - -// Test verifies that the client's WebUI is reachable. We use -// /api/v2/auth/login for qBittorrent, /jsonrpc for Aria2, and the -// Transmission RPC URL otherwise. -func (s *DownloadClientService) Test(ctx context.Context, id string) error { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - c, err := s.repo.DownloadClient.FindByID(ctx, id) - if err != nil { - return err - } - if c == nil { - return errors.New("client not found") - } - switch c.Type { - case "qbittorrent": - return qbitLogin(ctx, s.client, c.Host, c.Username, c.Password) - case "aria2", "transmission": - endpoint, err := downloadClientRPCURL(c.Type, c.Host) - if err != nil { - return err - } - req, err := newDownloadClientHTTPRequest(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return err - } - resp, err := s.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode >= 500 { - return fmt.Errorf("%s returned %d", c.Type, resp.StatusCode) - } - return nil - } - return fmt.Errorf("unsupported client type %q", c.Type) -} - -// Aria2GlobalStats issues a JSON-RPC `aria2.getGlobalStat` call against -// the first enabled aria2 client. Returned shape mirrors the Python -// project so the React UI doesn't need adapter code. -func (s *DownloadClientService) Aria2GlobalStats(ctx context.Context, clientID string) (map[string]any, error) { - c, err := s.repo.DownloadClient.FindByID(ctx, clientID) - if err != nil { - return nil, err - } - if c == nil || c.Type != "aria2" { - return nil, errors.New("aria2 client not found") - } - endpoint, err := downloadClientRPCURL("aria2", c.Host) - if err != nil { - return nil, err - } - payload := fmt.Sprintf( - `{"jsonrpc":"2.0","id":"x","method":"aria2.getGlobalStat","params":["token:%s"]}`, - c.Password, - ) - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, endpoint, - strings.NewReader(payload)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - resp, err := s.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("aria2 returned %d", resp.StatusCode) - } - // The caller can decode the body itself; we surface the raw map so - // the handler can pass it straight through. - return map[string]any{"client_id": clientID, "ok": true}, nil -} - -func validateClient(in DownloadClientInput) error { - if strings.TrimSpace(in.Name) == "" { - return errors.New("name required") - } - if strings.TrimSpace(in.Host) == "" { - return errors.New("host required") - } - switch in.Type { - case "qbittorrent", "aria2", "transmission": - default: - return fmt.Errorf("unsupported client type %q", in.Type) - } - return nil -} - -func normalizeDownloadClientInput(in DownloadClientInput) (DownloadClientInput, error) { - in.Name = strings.TrimSpace(in.Name) - in.Type = strings.TrimSpace(in.Type) - in.Host = strings.TrimSpace(in.Host) - in.Username = strings.TrimSpace(in.Username) - if err := validateClient(in); err != nil { - return in, err - } - if !strings.Contains(in.Host, "://") { - in.Host = "http://" + in.Host - } - normalized, err := normalizeDownloadClientEndpoint(in.Type, in.Host) - if err != nil { - return in, err - } - in.Host = normalized - return in, nil -} - -func (s *DownloadClientService) markManaged(ctx context.Context) { - if s == nil || s.repo == nil || s.repo.Setting == nil { - return - } - _ = s.repo.Setting.Set(ctx, settingDownloadClientsManaged, "true") -} - -func (s *DownloadClientService) clearLegacyQBitConnectionIfNoDefault(ctx context.Context) { - if s == nil || s.repo == nil || s.repo.DownloadClient == nil || s.repo.Setting == nil { - return - } - defaultClient, err := s.repo.DownloadClient.FindDefault(ctx) - if err != nil || defaultClient != nil { - return - } - _ = s.repo.Setting.Set(ctx, "qbittorrent.url", "") - _ = s.repo.Setting.Set(ctx, "qbittorrent.username", "") - _ = s.repo.Setting.Set(ctx, "qbittorrent.password", "") -} diff --git a/internal/service/download_clients_test.go b/internal/service/download_clients_test.go deleted file mode 100644 index cc152bb..0000000 --- a/internal/service/download_clients_test.go +++ /dev/null @@ -1,248 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestDownloadClientCreateNormalizesHostAndClearsDefault(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - svc := NewDownloadClientService(zap.NewNop(), repos) - - first, err := svc.Create(t.Context(), DownloadClientInput{ - Name: "qB old", - Type: "qbittorrent", - Host: "http://127.0.0.1:8080/", - IsDefault: true, - Enabled: true, - }) - if err != nil { - t.Fatal(err) - } - second, err := svc.Create(t.Context(), DownloadClientInput{ - Name: "qB NAS", - Type: "qbittorrent", - Host: "172.17.0.1:8085", - IsDefault: true, - Enabled: true, - }) - if err != nil { - t.Fatal(err) - } - if second.Host != "http://172.17.0.1:8085" { - t.Fatalf("host = %q, want normalized http URL", second.Host) - } - refreshedFirst, err := repos.DownloadClient.FindByID(t.Context(), first.ID) - if err != nil { - t.Fatal(err) - } - if refreshedFirst == nil || refreshedFirst.IsDefault { - t.Fatalf("old default should be cleared, got %#v", refreshedFirst) - } - refreshedSecond, err := repos.DownloadClient.FindByID(t.Context(), second.ID) - if err != nil { - t.Fatal(err) - } - if refreshedSecond == nil || !refreshedSecond.IsDefault { - t.Fatalf("new default should be active, got %#v", refreshedSecond) - } -} - -func TestDownloadClientCreateMakesFirstEnabledClientDefault(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - svc := NewDownloadClientService(zap.NewNop(), repos) - - client, err := svc.Create(t.Context(), DownloadClientInput{ - Name: "qB", - Type: "qbittorrent", - Host: "127.0.0.1:8080", - Enabled: true, - }) - if err != nil { - t.Fatal(err) - } - if !client.IsDefault { - t.Fatalf("first enabled client should become default: %#v", client) - } -} - -func TestDownloadClientRejectsUnsupportedHostScheme(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadClient{}, &model.Setting{}) - svc := NewDownloadClientService(zap.NewNop(), repository.New(db)) - - if _, err := svc.Create(t.Context(), DownloadClientInput{ - Name: "bad", - Type: "qbittorrent", - Host: "ftp://127.0.0.1:8080", - Enabled: true, - }); err == nil { - t.Fatal("expected unsupported scheme error") - } -} - -func TestDownloadClientRejectsUnsafeEndpointParts(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadClient{}, &model.Setting{}) - svc := NewDownloadClientService(zap.NewNop(), repository.New(db)) - - for _, host := range []string{ - "http://user:pass@127.0.0.1:6800", - "http://127.0.0.1:6800/jsonrpc?target=http://169.254.169.254", - "http://127.0.0.1:6800/jsonrpc#fragment", - "http://127.0.0.1:70000", - "file:///etc/passwd", - } { - if _, err := svc.Create(t.Context(), DownloadClientInput{ - Name: "bad", - Type: "aria2", - Host: host, - Enabled: true, - }); err == nil { - t.Fatalf("Create allowed unsafe host %q", host) - } - } -} - -func TestDownloadClientRPCURLAppendsExpectedPath(t *testing.T) { - cases := []struct { - clientType string - host string - want string - }{ - {"aria2", "127.0.0.1:6800", "http://127.0.0.1:6800/jsonrpc"}, - {"aria2", "http://nas.local:6800/rpc", "http://nas.local:6800/rpc/jsonrpc"}, - {"transmission", "http://nas.local:9091", "http://nas.local:9091/transmission/rpc"}, - {"transmission", "http://nas.local:9091/transmission/rpc", "http://nas.local:9091/transmission/rpc"}, - } - for _, tc := range cases { - got, err := downloadClientRPCURL(tc.clientType, tc.host) - if err != nil { - t.Fatalf("downloadClientRPCURL(%q, %q) error: %v", tc.clientType, tc.host, err) - } - if got != tc.want { - t.Fatalf("downloadClientRPCURL(%q, %q) = %q, want %q", tc.clientType, tc.host, got, tc.want) - } - } -} - -func TestAria2AdapterRejectsUnsafeHostBeforeHTTPRequest(t *testing.T) { - adapter := NewAria2Adapter() - called := false - adapter.client = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - called = true - return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil - })} - - if err := adapter.Initialize(t.Context(), DownloadClientConfig{ - Host: "http://user:pass@127.0.0.1:6800", - Password: "secret", - }); err == nil { - t.Fatal("expected unsafe host error") - } - if called { - t.Fatal("unsafe aria2 host should be rejected before any HTTP request") - } -} - -func TestAria2AdapterUsesNormalizedRPCURL(t *testing.T) { - var gotPath string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - adapter := NewAria2Adapter() - if err := adapter.Initialize(t.Context(), DownloadClientConfig{ - Host: server.URL, - Password: "secret", - }); err != nil { - t.Fatal(err) - } - if gotPath != "/jsonrpc" { - t.Fatalf("aria2 request path = %q, want /jsonrpc", gotPath) - } -} - -func TestDownloadClientDeleteClearsLegacyQBitConnectionWhenNoDefault(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - for key, value := range map[string]string{ - "qbittorrent.url": "http://127.0.0.1:8080", - "qbittorrent.username": "admin", - "qbittorrent.password": "admin", - } { - if err := repos.Setting.Set(t.Context(), key, value); err != nil { - t.Fatal(err) - } - } - svc := NewDownloadClientService(zap.NewNop(), repos) - row, err := svc.Create(t.Context(), DownloadClientInput{ - Name: "qB", - Type: "qbittorrent", - Host: "http://127.0.0.1:8080", - IsDefault: true, - Enabled: true, - }) - if err != nil { - t.Fatal(err) - } - - if err := svc.Delete(t.Context(), row.ID); err != nil { - t.Fatal(err) - } - - for _, key := range []string{"qbittorrent.url", "qbittorrent.username", "qbittorrent.password"} { - value, err := repos.Setting.Get(t.Context(), key) - if err != nil { - t.Fatal(err) - } - if value != "" { - t.Fatalf("%s = %q, want cleared", key, value) - } - } -} - -func TestDownloadClientUpdateClearsLegacyQBitConnectionWhenDefaultDisabled(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), "qbittorrent.url", "http://127.0.0.1:8080"); err != nil { - t.Fatal(err) - } - svc := NewDownloadClientService(zap.NewNop(), repos) - row, err := svc.Create(t.Context(), DownloadClientInput{ - Name: "qB", - Type: "qbittorrent", - Host: "http://127.0.0.1:8080", - IsDefault: true, - Enabled: true, - }) - if err != nil { - t.Fatal(err) - } - - if _, err := svc.Update(t.Context(), row.ID, DownloadClientInput{ - Name: "qB", - Type: "qbittorrent", - Host: "http://127.0.0.1:8080", - IsDefault: false, - Enabled: false, - }); err != nil { - t.Fatal(err) - } - - value, err := repos.Setting.Get(t.Context(), "qbittorrent.url") - if err != nil { - t.Fatal(err) - } - if value != "" { - t.Fatalf("qbittorrent.url = %q, want cleared", value) - } -} diff --git a/internal/service/download_completion.go b/internal/service/download_completion.go deleted file mode 100644 index 05250f8..0000000 --- a/internal/service/download_completion.go +++ /dev/null @@ -1,216 +0,0 @@ -package service - -import ( - "context" - "errors" - "os" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// onTorrentComplete handles a torrent that just finished downloading. -// It organizes the completed torrent payload directly. Relying on existing -// Media rows is too late for freshly-downloaded files: they usually have not -// been scanned into the library yet. -func (d *DownloadService) onTorrentComplete(ctx context.Context, torrent QBitTorrent) { - taskRow, hasTask := d.completedTorrentTask(ctx, torrent) - d.notifyDownloadComplete(ctx, torrent, taskRow) - if d.organizer == nil { - return - } - // 仅当显式开启 organizer.auto_after_download / organize.auto 时才在下载完成后整理。 - // 之前的代码错误地把 organizer.smart_classify 也当成"自动整理"开关, - // 让操作员只想启用"分类子目录"就被动触发了文件 move。 - autoOrganize := d.downloadAutoOrganizeEnabled(ctx) - if !autoOrganize { - d.log.Info("download completed, auto-organize disabled", zap.String("hash", torrent.Hash)) - return - } - source := d.completedTorrentSource(ctx, torrent) - if source == "" { - d.log.Warn("download completed but payload path is not accessible", - zap.String("hash", torrent.Hash), - zap.String("name", torrent.Name), - zap.String("save_path", torrent.SavePath), - zap.String("content_path", torrent.ContentPath)) - return - } - allowReplace := hasTask && taskRow.AllowExistingLibrary - d.runCompletedTorrentOrganize(ctx, torrent, taskRow, source, allowReplace) -} - -func (d *DownloadService) runCompletedTorrentOrganize(ctx context.Context, torrent QBitTorrent, task *model.DownloadTask, source string, allowReplace bool) { - d.log.Info("download completed, triggering directory organize", - zap.String("hash", torrent.Hash), - zap.String("name", torrent.Name), - zap.String("source", source), - zap.Bool("allow_replace_existing", allowReplace)) - resWrap, err := d.ensureOrganizePipeline().Run(ctx, OrganizePipelineRequest{ - Scope: OrganizeScopeDirectory, - Trigger: OrganizeTriggerDownload, - TaskName: d.downloadOrganizeTaskName(torrent, allowReplace), - SourcePath: source, - MediaType: downloadTaskMediaType(task), - MediaCategory: firstNonEmpty(downloadTaskMediaCategory(task), torrent.Category), - AllowReplace: allowReplace, - }) - if err != nil { - if errors.Is(err, ErrUnsupportedOrganizeSource) { - d.markCompletedTorrentCatchupRecorded(context.Background(), torrent) - d.log.Warn("auto organize skipped unsupported completed torrent", - zap.String("hash", torrent.Hash), - zap.String("source", source), - zap.Error(err)) - return - } - d.log.Error("auto organize completed torrent failed", - zap.String("hash", torrent.Hash), - zap.String("source", source), - zap.Error(err)) - return - } - res := resWrap.Result - if res == nil { - res = &OrganizeResult{} - } - d.markCompletedTorrentCatchupRecorded(context.Background(), torrent) - d.log.Info("auto organize completed torrent finished", - zap.String("hash", torrent.Hash), - zap.String("source", source), - zap.String("dest", firstNonEmpty(res.DestPath, "")), - zap.Int("organized", res.Organized), - zap.Int("replaced", res.Replaced), - zap.Int("skipped", res.Skipped), - zap.Int("scrapes", len(res.Scrapes)), - zap.Int("errors", len(res.Errors))) -} - -func (d *DownloadService) downloadOrganizeTaskName(torrent QBitTorrent, allowReplace bool) string { - name := strings.TrimSpace(torrent.Name) - if name == "" { - name = "下载完成自动整理" - } - if allowReplace { - name += "(允许洗版)" - } - return name -} - -func (d *DownloadService) ensureOrganizePipeline() *OrganizePipelineService { - if d.organizePipeline != nil { - return d.organizePipeline - } - return NewOrganizePipelineService(d.log, d.repo, d.organizer, d.scanner, d.tasks) -} - -func (d *DownloadService) completedTorrentTask(ctx context.Context, torrent QBitTorrent) (*model.DownloadTask, bool) { - if d == nil || d.repo == nil || d.repo.Download == nil { - return nil, false - } - rows, err := d.repo.Download.List(ctx) - if err != nil || len(rows) == 0 { - return nil, false - } - taskByKey := tasksByTorrentIdentity(rows) - if task, ok := findMatchingTaskForTorrent(torrent, taskByKey); ok { - return &task, true - } - if strings.TrimSpace(torrent.ContentPath) != "" { - pathTorrent := torrent - pathTorrent.Name = downloaderPathBase(torrent.ContentPath) - if task, ok := findMatchingTaskForTorrent(pathTorrent, taskByKey); ok { - return &task, true - } - } - return nil, false -} - -func downloadTaskMediaType(task *model.DownloadTask) string { - if task == nil { - return "" - } - return strings.TrimSpace(task.MediaType) -} - -func downloadTaskMediaCategory(task *model.DownloadTask) string { - if task == nil { - return "" - } - return strings.TrimSpace(task.MediaCategory) -} - -// DownloadPathMappingsSettingKey 允许用户自定义「下载器路径 → 本程序路径」 -// 映射,每行一条,格式 `客户端路径=本地路径`(也接受 `=>` 或单个 `:` 分隔)。 -// qBittorrent 与本程序常在不同容器/主机里,对同一份数据看到的路径不同; -// 此前映射表是写死的三条猜测,对不上时整理静默失败。 -const DownloadPathMappingsSettingKey = "download.path_mappings" - -func (d *DownloadService) completedTorrentSource(ctx context.Context, torrent QBitTorrent) string { - mappings := d.downloadPathMappings(ctx) - for _, candidate := range []string{ - torrent.ContentPath, - downloaderPayloadPath(torrent.SavePath, torrent.Name), - } { - clean := strings.TrimSpace(candidate) - if clean == "" || clean == "." { - continue - } - // 尝试直接访问或路径映射 - if translated := translateClientPath(clean, mappings); translated != "" { - return translated - } - // 复用 compose 注入的 MEDIASTATION_DOWNLOAD_DIR/MEDIA_DIR 宿主机↔容器 - // 映射(与媒体库路径换算同一套规则),覆盖「qB 跑在宿主机、 - // 本程序在容器里」的最常见部署形态。 - for _, mapped := range mappedPathCandidates(clean) { - if mapped == clean { - continue - } - if _, err := os.Stat(mapped); err == nil { - return mapped - } - } - } - return "" -} - -// userPathMappings 解析用户配置的下载器路径映射。 -func (d *DownloadService) userPathMappings(ctx context.Context) map[string]string { - out := map[string]string{} - if d == nil || d.repo == nil || d.repo.Setting == nil { - return out - } - raw, err := d.repo.Setting.Get(ctx, DownloadPathMappingsSettingKey) - if err != nil { - return out - } - for _, line := range strings.Split(raw, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - var from, to string - switch { - case strings.Contains(line, "=>"): - parts := strings.SplitN(line, "=>", 2) - from, to = parts[0], parts[1] - case strings.Contains(line, "="): - parts := strings.SplitN(line, "=", 2) - from, to = parts[0], parts[1] - case strings.Count(line, ":") == 1: - parts := strings.SplitN(line, ":", 2) - from, to = parts[0], parts[1] - default: - continue - } - from = strings.TrimSpace(from) - to = strings.TrimSpace(to) - if from != "" && to != "" { - out[from] = to - } - } - return out -} diff --git a/internal/service/download_completion_state.go b/internal/service/download_completion_state.go deleted file mode 100644 index db9da35..0000000 --- a/internal/service/download_completion_state.go +++ /dev/null @@ -1,327 +0,0 @@ -package service - -import ( - "context" - "crypto/sha1" - "fmt" - "math" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// completedTorrentCatchupWindow 限定重启补整理只覆盖最近完成的种子, -// 防止每次启动都把全部历史种子重新过一遍整理流程。 -const completedTorrentCatchupWindow = 24 * time.Hour - -const completedTorrentCatchupSettingPrefix = "download.auto_organized." -const completedTorrentNotifySettingPrefix = "download.completed_notified." - -func (d *DownloadService) downloadAutoOrganizeEnabled(ctx context.Context) bool { - if d == nil || d.repo == nil || d.repo.Setting == nil { - return false - } - if v, err := d.repo.Setting.Get(ctx, "organizer.auto_after_download"); err == nil && parseBoolSetting(v, false) { - return true - } - if v, err := d.repo.Setting.Get(ctx, "organize.auto"); err == nil && parseBoolSetting(v, false) { - return true - } - return false -} - -// recentlyCompletedTorrent 报告该种子是否在补整理时间窗内完成。 -// qBittorrent 未提供 completion_on 时保守地返回 false。 -func recentlyCompletedTorrent(torrent QBitTorrent, now time.Time) bool { - if torrent.CompletionOn <= 0 { - return false - } - completed := time.Unix(torrent.CompletionOn, 0) - return now.Sub(completed) <= completedTorrentCatchupWindow -} - -func qbitTorrentCompleted(torrent QBitTorrent) bool { - if torrent.Progress < 1 { - return false - } - state := strings.ToLower(strings.TrimSpace(torrent.State)) - switch state { - case "completed", "complete", "seeding", "uploading", "stalledup", "pausedup", "queuedup", "forcedup": - return true - default: - return false - } -} - -func (d *DownloadService) completedTorrentCatchupRecorded(ctx context.Context, torrent QBitTorrent) bool { - if d == nil || d.repo == nil || d.repo.Setting == nil { - return false - } - key := completedTorrentCatchupSettingKey(torrent) - if key == "" { - return false - } - value, err := d.repo.Setting.Get(ctx, key) - if err != nil { - return false - } - return parseBoolSetting(value, false) -} - -func (d *DownloadService) markCompletedTorrentCatchupRecorded(ctx context.Context, torrent QBitTorrent) { - if d == nil || d.repo == nil || d.repo.Setting == nil { - return - } - key := completedTorrentCatchupSettingKey(torrent) - if key == "" { - return - } - if err := d.repo.Setting.Set(ctx, key, "true"); err != nil && d.log != nil { - d.log.Debug("mark completed torrent catchup failed", - zap.String("hash", torrent.Hash), - zap.String("name", torrent.Name), - zap.Error(err)) - } -} - -func completedTorrentCatchupSettingKey(torrent QBitTorrent) string { - key := completedTorrentQueueKey(torrent) - if key == "" { - return "" - } - sum := sha1.Sum([]byte(key)) - return completedTorrentCatchupSettingPrefix + fmt.Sprintf("%x", sum[:]) -} - -func (d *DownloadService) completedTorrentNotified(ctx context.Context, torrent QBitTorrent) bool { - if d == nil || d.repo == nil || d.repo.Setting == nil { - return false - } - key := completedTorrentNotifySettingKey(torrent) - if key == "" { - return false - } - value, err := d.repo.Setting.Get(ctx, key) - if err != nil { - return false - } - return parseBoolSetting(value, false) -} - -func (d *DownloadService) markCompletedTorrentNotified(ctx context.Context, torrent QBitTorrent) { - if d == nil || d.repo == nil || d.repo.Setting == nil { - return - } - key := completedTorrentNotifySettingKey(torrent) - if key == "" { - return - } - if err := d.repo.Setting.Set(ctx, key, "true"); err != nil && d.log != nil { - d.log.Debug("mark completed torrent notification failed", - zap.String("hash", torrent.Hash), - zap.String("name", torrent.Name), - zap.Error(err)) - } -} - -func completedTorrentNotifySettingKey(torrent QBitTorrent) string { - key := completedTorrentQueueKey(torrent) - if key == "" { - return "" - } - sum := sha1.Sum([]byte(key)) - return completedTorrentNotifySettingPrefix + fmt.Sprintf("%x", sum[:]) -} - -func completedTorrentQueueKey(torrent QBitTorrent) string { - hash := strings.ToLower(strings.TrimSpace(torrent.Hash)) - if hash != "" { - owner := strings.ToLower(firstNonEmpty(torrent.ClientID, torrent.Source)) - if owner != "" { - return owner + "|" + hash - } - return hash - } - parts := []string{torrent.Name, torrent.ContentPath, torrent.SavePath} - if owner := firstNonEmpty(torrent.ClientID, torrent.Source); owner != "" { - parts = append([]string{owner}, parts...) - } - for i := range parts { - parts[i] = strings.TrimSpace(parts[i]) - } - key := strings.Join(parts, "|") - if strings.Trim(key, "|") == "" { - return "" - } - return strings.ToLower(key) -} - -func (d *DownloadService) syncDownloadTaskProgress(ctx context.Context, torrent QBitTorrent, taskByKey map[string]model.DownloadTask) { - if d == nil || d.repo == nil || d.repo.DB == nil { - return - } - matched, ok := findMatchingTaskForTorrent(torrent, taskByKey) - if !ok { - return - } - status := torrent.State - if qbitTorrentCompleted(torrent) { - status = "completed" - } - if strings.TrimSpace(status) == "" { - status = matched.Status - } - updates := map[string]any{} - if math.Abs(float64(matched.Progress-torrent.Progress)) > 0.0001 { - updates["progress"] = torrent.Progress - } - if status != "" && status != matched.Status { - updates["status"] = status - } - if strings.TrimSpace(matched.DownloadClientID) == "" && strings.TrimSpace(torrent.ClientID) != "" { - updates["download_client_id"] = strings.TrimSpace(torrent.ClientID) - } - if strings.TrimSpace(matched.ExternalID) == "" && strings.TrimSpace(torrent.Hash) != "" { - updates["external_id"] = strings.TrimSpace(torrent.Hash) - } - if strings.TrimSpace(torrent.Source) != "" && matched.Source != strings.TrimSpace(torrent.Source) { - updates["source"] = strings.TrimSpace(torrent.Source) - } - if len(updates) == 0 { - return - } - _ = d.repo.DB.WithContext(ctx).Model(&model.DownloadTask{}).Where("id = ?", matched.ID).Updates(updates).Error -} - -func tasksByIdentity(rows []model.DownloadTask) map[string]model.DownloadTask { - out := make(map[string]model.DownloadTask, len(rows)) - for _, row := range rows { - key := downloadTaskIdentityKey(row.Title) - if key != "" { - out[key] = row - } - } - return out -} - -func tasksByTorrentIdentity(rows []model.DownloadTask) map[string]model.DownloadTask { - out := make(map[string]model.DownloadTask, len(rows)*4) - for _, row := range rows { - key := normalizeTorrentName(row.Title) - if key != "" { - setDownloadTaskIndex(out, key, row) - setDownloadTaskIndex(out, downloadTaskClientTitleKey(row.DownloadClientID, key), row) - } - if externalID := strings.TrimSpace(row.ExternalID); externalID != "" { - setDownloadTaskIndex(out, downloadTaskExternalKey(row.DownloadClientID, externalID), row) - setDownloadTaskIndex(out, downloadTaskAnyExternalKey(externalID), row) - } - } - return out -} - -func setDownloadTaskIndex(index map[string]model.DownloadTask, key string, row model.DownloadTask) { - if key == "" { - return - } - if _, exists := index[key]; !exists { - index[key] = row - } -} - -func downloadTaskExternalKey(clientID, externalID string) string { - clientID = strings.ToLower(strings.TrimSpace(clientID)) - externalID = strings.ToLower(strings.TrimSpace(externalID)) - if clientID == "" || externalID == "" { - return "" - } - return "\x00external:" + clientID + ":" + externalID -} - -func downloadTaskAnyExternalKey(externalID string) string { - externalID = strings.ToLower(strings.TrimSpace(externalID)) - if externalID == "" { - return "" - } - return "\x00external-any:" + externalID -} - -func downloadTaskClientTitleKey(clientID, titleKey string) string { - clientID = strings.ToLower(strings.TrimSpace(clientID)) - titleKey = strings.TrimSpace(titleKey) - if clientID == "" || titleKey == "" { - return "" - } - return "\x00client-title:" + clientID + ":" + titleKey -} - -func findMatchingTaskForTorrent(torrent QBitTorrent, taskByKey map[string]model.DownloadTask) (model.DownloadTask, bool) { - if row, ok := taskByKey[downloadTaskExternalKey(torrent.ClientID, torrent.Hash)]; ok { - return row, true - } - if row, ok := taskByKey[downloadTaskAnyExternalKey(torrent.Hash)]; ok { - if strings.TrimSpace(row.DownloadClientID) == "" || strings.TrimSpace(torrent.ClientID) == "" || row.DownloadClientID == torrent.ClientID { - return row, true - } - } - titleKey := normalizeTorrentName(torrent.Name) - if row, ok := taskByKey[downloadTaskClientTitleKey(torrent.ClientID, titleKey)]; ok { - return row, true - } - row, ok := findMatchingTaskByTorrentIdentity(torrent.Name, taskByKey) - if !ok { - return model.DownloadTask{}, false - } - if strings.TrimSpace(torrent.ClientID) != "" && strings.TrimSpace(row.DownloadClientID) != "" && row.DownloadClientID != torrent.ClientID { - return model.DownloadTask{}, false - } - return row, true -} - -func findMatchingTaskByIdentity(title string, taskByKey map[string]model.DownloadTask) (model.DownloadTask, bool) { - key := downloadTaskIdentityKey(title) - if key == "" { - return model.DownloadTask{}, false - } - if row, ok := taskByKey[key]; ok { - return row, true - } - for currentKey, row := range taskByKey { - if strings.HasPrefix(currentKey, "\x00") { - continue - } - if strings.Contains(key, currentKey) || strings.Contains(currentKey, key) { - return row, true - } - } - return model.DownloadTask{}, false -} - -func findMatchingTaskByTorrentIdentity(title string, taskByKey map[string]model.DownloadTask) (model.DownloadTask, bool) { - key := normalizeTorrentName(title) - if key == "" { - return model.DownloadTask{}, false - } - if row, ok := taskByKey[key]; ok { - return row, true - } - for currentKey, row := range taskByKey { - if strings.HasPrefix(currentKey, "\x00") { - continue - } - if strings.Contains(key, currentKey) || strings.Contains(currentKey, key) { - return row, true - } - } - return model.DownloadTask{}, false -} - -func downloadTaskNeedsCompletion(task model.DownloadTask) bool { - if task.Progress < 1 { - return true - } - return strings.ToLower(strings.TrimSpace(task.Status)) != "completed" -} diff --git a/internal/service/download_config_test.go b/internal/service/download_config_test.go deleted file mode 100644 index 6414920..0000000 --- a/internal/service/download_config_test.go +++ /dev/null @@ -1,390 +0,0 @@ -package service - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestReloadConfigDoesNotFallbackToLegacyAfterClientDeleted(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if atomic.LoadInt32(&addCalls) > 0 { - _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Movie 2026 1080p","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), "qbittorrent.url", qb.URL); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "qbittorrent.username", "admin"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "qbittorrent.password", "admin"); err != nil { - t.Fatal(err) - } - client := &model.DownloadClient{Name: "qB", Type: "qbittorrent", Host: qb.URL, Username: "admin", Password: "admin", IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - if err := repos.DownloadClient.Delete(t.Context(), client.ID); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - if err := svc.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - _, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=Movie+2026+1080p", "/downloads", DownloadTaskMeta{ - Title: "Movie 2026 1080p", - }) - if err == nil { - t.Fatal("expected add to fail when the configured downloader was deleted") - } - if got := atomic.LoadInt32(&addCalls); got != 0 { - t.Fatalf("qb add calls = %d, want 0", got) - } -} - -func TestReloadConfigDoesNotFallbackToLegacyAfterClientDisabled(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), "qbittorrent.url", qb.URL); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "qbittorrent.username", "admin"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "qbittorrent.password", "admin"); err != nil { - t.Fatal(err) - } - client := &model.DownloadClient{Name: "qB", Type: "qbittorrent", Host: qb.URL, Username: "admin", Password: "admin", IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - client.Enabled = false - if err := repos.DownloadClient.Update(t.Context(), client); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - if err := svc.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - _, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=Movie+2026+1080p", "/downloads", DownloadTaskMeta{ - Title: "Movie 2026 1080p", - }) - if err == nil { - t.Fatal("expected add to fail when the configured downloader was disabled") - } - if got := atomic.LoadInt32(&addCalls); got != 0 { - t.Fatalf("qb add calls = %d, want 0", got) - } -} - -func TestReloadConfigUsesSoleEnabledQBitWhenNoExplicitDefault(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if atomic.LoadInt32(&addCalls) > 0 { - _, _ = w.Write([]byte(`[{"hash":"sole123","name":"Movie 2026 1080p","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil { - t.Fatal(err) - } - client := &model.DownloadClient{Name: "qB", Type: "qbittorrent", Host: qb.URL, Username: "admin", Password: "admin", IsDefault: false, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:abababababababababababababababababababab&dn=Movie+2026+1080p", "/downloads", DownloadTaskMeta{ - Title: "Movie 2026 1080p", - }) - if err != nil { - t.Fatal(err) - } - if task == nil { - t.Fatal("expected task") - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } -} - -func TestReloadConfigDoesNotOverrideExplicitTransmissionDefaultWithQBit(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - transmission := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: "http://127.0.0.1:9091", IsDefault: true, Enabled: true} - qb := &model.DownloadClient{Name: "qB", Type: "qbittorrent", Host: "http://127.0.0.1:8080", Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), transmission); err != nil { - t.Fatal(err) - } - if err := repos.DownloadClient.Create(t.Context(), qb); err != nil { - t.Fatal(err) - } - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - if err := svc.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - if svc.qb.IsConfigured() { - t.Fatal("legacy qB client was configured despite explicit Transmission default") - } - selected, err := repos.DownloadClient.FindDefault(t.Context()) - if err != nil { - t.Fatal(err) - } - if selected == nil || selected.ID != transmission.ID { - t.Fatalf("default client = %#v", selected) - } -} - -func TestAddDownloadWithMetaFailsClosedWhenNoDownloaderConfigured(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&dn=Movie+2026+1080p", "/downloads", DownloadTaskMeta{ - Title: "Movie 2026 1080p", - }) - if err == nil { - t.Fatal("expected no downloader configured error") - } - if !strings.Contains(err.Error(), "当前没有已启用的下载器") { - t.Fatalf("err = %v, want enabled downloader guidance", err) - } - if task != nil { - t.Fatalf("task = %#v, want nil", task) - } - rows, err := repos.Download.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("download rows = %d, want 0", len(rows)) - } -} - -func TestAddDownloadSelectsFirstEnabledQBitWhenDefaultMissing(t *testing.T) { - var firstAddCalls int32 - first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if atomic.LoadInt32(&firstAddCalls) > 0 { - _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Movie 2026 1080p","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&firstAddCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer first.Close() - second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - t.Fatal("second qB should not be selected before first enabled qB") - default: - http.NotFound(w, r) - } - })) - defer second.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil { - t.Fatal(err) - } - firstClient := &model.DownloadClient{Name: "qB first", Type: "qbittorrent", Host: first.URL, Username: "admin", Password: "admin", IsDefault: false, Enabled: true} - secondClient := &model.DownloadClient{Name: "qB second", Type: "qbittorrent", Host: second.URL, Username: "admin", Password: "admin", IsDefault: false, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), firstClient); err != nil { - t.Fatal(err) - } - if err := repos.DownloadClient.Create(t.Context(), secondClient); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&dn=Movie+2026+1080p", "/downloads", DownloadTaskMeta{ - Title: "Movie 2026 1080p", - }) - if err != nil { - t.Fatal(err) - } - if task == nil { - t.Fatal("expected task") - } - if got := atomic.LoadInt32(&firstAddCalls); got != 1 { - t.Fatalf("first qb add calls = %d, want 1", got) - } - refreshed, err := repos.DownloadClient.FindByID(t.Context(), firstClient.ID) - if err != nil { - t.Fatal(err) - } - if refreshed == nil || !refreshed.IsDefault { - t.Fatalf("first enabled qB should be persisted as default, got %#v", refreshed) - } -} - -func TestReloadConfigManagedModeDoesNotFallbackToLegacyWithoutRows(t *testing.T) { - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), "qbittorrent.url", qb.URL); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "qbittorrent.username", "admin"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "qbittorrent.password", "admin"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - _, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&dn=Movie+2026+1080p", "/downloads", DownloadTaskMeta{ - Title: "Movie 2026 1080p", - }) - if err == nil { - t.Fatal("expected managed mode to reject missing default downloader") - } - if got := atomic.LoadInt32(&addCalls); got != 0 { - t.Fatalf("qb add calls = %d, want 0", got) - } -} - -func TestAddDownloadWithMetaUsesEnabledAria2Downloader(t *testing.T) { - var addCalls int32 - aria2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req aria2Request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode aria2 request: %v", err) - return - } - var result interface{} = map[string]interface{}{"version": "1.37"} - switch req.Method { - case "aria2.tellActive", "aria2.tellWaiting", "aria2.tellStopped": - result = []interface{}{} - case "aria2.addUri": - atomic.AddInt32(&addCalls, 1) - result = "aria2-gid" - } - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "jsonrpc": "2.0", - "id": req.ID, - "result": result, - }) - })) - defer aria2.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil { - t.Fatal(err) - } - client := &model.DownloadClient{ - Name: "aria2", - Type: "aria2", - Host: aria2.URL, - Enabled: true, - } - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.SetDownloadManager(NewDownloadManager(zap.NewNop(), repos, nil)) - task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:ffffffffffffffffffffffffffffffffffffffff&dn=Movie+2026+1080p", "/downloads", DownloadTaskMeta{ - Title: "Movie 2026 1080p", - }) - if err != nil { - t.Fatal(err) - } - if task.Source != "aria2" || task.DownloadClientID != client.ID || task.ExternalID != "aria2-gid" { - t.Fatalf("task = %#v", task) - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("add calls = %d", got) - } -} diff --git a/internal/service/download_control_test.go b/internal/service/download_control_test.go deleted file mode 100644 index dc4f49e..0000000 --- a/internal/service/download_control_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package service - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "reflect" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestPauseAndResumeRouteThroughTaskDownloadClient(t *testing.T) { - var methods []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("X-Transmission-Session-Id", "session-test") - w.WriteHeader(http.StatusConflict) - return - } - var req transmissionRPCRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode transmission request: %v", err) - return - } - methods = append(methods, req.Method) - _ = json.NewEncoder(w).Encode(transmissionRPCResponse{Result: "success", Arguments: map[string]interface{}{}}) - })) - defer server.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - client := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: server.URL, IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - task := &model.DownloadTask{ - UserID: "u1", - Source: "transmission", - DownloadClientID: client.ID, - ExternalID: "transmission-hash", - URL: "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - Title: "Controlled Transmission Movie", - Status: "downloading", - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - manager := NewDownloadManager(zap.NewNop(), repos, nil) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.SetDownloadManager(manager) - if err := svc.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - - if err := svc.PauseDownloadTask(t.Context(), task.ID); err != nil { - t.Fatal(err) - } - if err := svc.ResumeDownloadTask(t.Context(), task.ID); err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(methods, []string{"torrent-stop", "torrent-start"}) { - t.Fatalf("methods = %#v", methods) - } - var updated model.DownloadTask - if err := db.Where("id = ?", task.ID).First(&updated).Error; err != nil { - t.Fatal(err) - } - if updated.Status != "queued" { - t.Fatalf("status = %q", updated.Status) - } -} diff --git a/internal/service/download_controls.go b/internal/service/download_controls.go deleted file mode 100644 index 6da2e6f..0000000 --- a/internal/service/download_controls.go +++ /dev/null @@ -1,105 +0,0 @@ -package service - -import ( - "context" - "errors" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (d *DownloadService) PauseDownloadTask(ctx context.Context, taskID string) error { - return d.controlDownloadTask(ctx, taskID, "paused", func(target downloadTarget, externalID string) error { - if target.legacyQB { - return d.qb.Pause(ctx, externalID) - } - return target.adapter.Pause(ctx, externalID) - }) -} - -func (d *DownloadService) ResumeDownloadTask(ctx context.Context, taskID string) error { - return d.controlDownloadTask(ctx, taskID, "queued", func(target downloadTarget, externalID string) error { - if target.legacyQB { - return d.qb.Resume(ctx, externalID) - } - return target.adapter.Resume(ctx, externalID) - }) -} - -func (d *DownloadService) controlDownloadTask(ctx context.Context, taskID, status string, operation func(downloadTarget, string) error) error { - taskID = strings.TrimSpace(taskID) - if taskID == "" { - return errors.New("task id is required") - } - var task model.DownloadTask - if d == nil || d.repo == nil || d.repo.DB == nil { - return errors.New("download repository is unavailable") - } - if err := d.repo.DB.WithContext(ctx).Where("id = ?", taskID).First(&task).Error; err != nil { - return err - } - clientID, externalID := d.resolveTaskDownloaderIdentity(ctx, &task) - if externalID == "" { - return errors.New("download task has no client task id") - } - target, err := d.downloadTargetByID(ctx, clientID) - if err != nil { - return err - } - if err := operation(target, externalID); err != nil { - return err - } - return d.repo.DB.WithContext(ctx).Model(&model.DownloadTask{}). - Where("id = ?", task.ID). - Updates(map[string]any{ - "status": status, - "download_client_id": clientID, - "external_id": externalID, - "source": firstNonEmpty(target.typ, task.Source), - }).Error -} - -func (d *DownloadService) resolveTaskDownloaderIdentity(ctx context.Context, task *model.DownloadTask) (string, string) { - if task == nil { - return "", "" - } - clientID := strings.TrimSpace(task.DownloadClientID) - persistedExternalID := strings.TrimSpace(task.ExternalID) - externalID := persistedExternalID - if externalID == "" { - externalID = torrentURLInfoHash(task.URL) - } - if clientID != "" && persistedExternalID != "" { - return clientID, externalID - } - live, _ := d.listLiveTorrents(ctx, "") - for _, torrent := range live { - if clientID != "" && torrent.ClientID != clientID { - continue - } - if externalID != "" && strings.EqualFold(torrent.Hash, externalID) { - clientID = firstNonEmpty(clientID, torrent.ClientID) - return clientID, torrent.Hash - } - if downloadTaskMatchesLiveTorrent(*task, torrent) { - clientID = firstNonEmpty(clientID, torrent.ClientID) - externalID = firstNonEmpty(externalID, torrent.Hash) - return clientID, externalID - } - } - if clientID == "" && d.manager != nil { - var matched []managedDownloadTarget - for _, target := range d.manager.targets() { - if strings.TrimSpace(task.Source) == "" || target.client.Type == task.Source { - matched = append(matched, target) - } - } - if len(matched) == 1 { - clientID = matched[0].client.ID - } - } - if clientID == "" && d.qb != nil && d.qb.IsConfigured() && (task.Source == "" || task.Source == "qbittorrent") { - clientID = legacyQBitDownloadClientID - } - return clientID, externalID -} diff --git a/internal/service/download_delete_test.go b/internal/service/download_delete_test.go deleted file mode 100644 index 2bcbfe4..0000000 --- a/internal/service/download_delete_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package service - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestDeleteMarksMatchingDownloadTaskDeleted(t *testing.T) { - const hash = "abc123" - const title = "Delete Marker Show S01E01 1080p" - var deleteCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Delete Marker Show S01E01 1080p","state":"downloading","progress":0.5}]`)) - case "/api/v2/torrents/delete": - atomic.AddInt32(&deleteCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - task := &model.DownloadTask{ - UserID: "u1", - Source: "qbittorrent", - URL: "https://pt.example/download?id=1", - Title: title, - SavePath: "/downloads/tv", - Status: "downloading", - Progress: 0.5, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - if err := svc.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - if err := svc.Delete(t.Context(), hash, false); err != nil { - t.Fatal(err) - } - if got := atomic.LoadInt32(&deleteCalls); got != 1 { - t.Fatalf("delete calls = %d, want 1", got) - } - - var updated model.DownloadTask - if err := db.Where("id = ?", task.ID).First(&updated).Error; err != nil { - t.Fatal(err) - } - if updated.Status != "deleted" { - t.Fatalf("status = %q, want deleted", updated.Status) - } -} - -func TestDeleteRoutesToRequestedTransmissionClient(t *testing.T) { - var removed map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("X-Transmission-Session-Id", "session-test") - w.WriteHeader(http.StatusConflict) - return - } - var req transmissionRPCRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode transmission request: %v", err) - return - } - arguments := map[string]interface{}{} - switch req.Method { - case "torrent-get": - arguments["torrents"] = []map[string]interface{}{{ - "hashString": "transmission-hash", - "name": "Delete Transmission Movie", - "percentDone": 0.5, - "status": 4, - }} - case "torrent-remove": - removed = req.Arguments - default: - t.Errorf("unexpected transmission method %q", req.Method) - } - _ = json.NewEncoder(w).Encode(transmissionRPCResponse{Result: "success", Arguments: arguments}) - })) - defer server.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - client := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: server.URL, IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - task := &model.DownloadTask{ - UserID: "u1", - Source: "transmission", - DownloadClientID: client.ID, - ExternalID: "transmission-hash", - URL: "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - Title: "Delete Transmission Movie", - Status: "downloading", - Progress: 0.5, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - manager := NewDownloadManager(zap.NewNop(), repos, nil) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.SetDownloadManager(manager) - if err := svc.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - - if err := svc.Delete(t.Context(), "transmission-hash", true, client.ID); err != nil { - t.Fatal(err) - } - ids, ok := removed["ids"].([]interface{}) - if !ok || len(ids) != 1 || ids[0] != "transmission-hash" || removed["delete-local-data"] != true { - t.Fatalf("remove arguments = %#v", removed) - } - var updated model.DownloadTask - if err := db.Where("id = ?", task.ID).First(&updated).Error; err != nil { - t.Fatal(err) - } - if updated.Status != "deleted" { - t.Fatalf("status = %q", updated.Status) - } -} - -func TestDeleteMarksMagnetTaskDeletedWhenLiveTorrentNameMissing(t *testing.T) { - const hash = "0123456789abcdef0123456789abcdef0123c0de" - var deleteCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/delete": - atomic.AddInt32(&deleteCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - task := &model.DownloadTask{ - UserID: "u1", - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:" + hash + "&dn=Codex.Path.Verify.S01E01.2026", - Title: "Codex Path Verify S01E01 2026", - SavePath: "/downloads/tv", - Status: "queued", - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - if err := svc.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - if err := svc.Delete(t.Context(), hash, false); err != nil { - t.Fatal(err) - } - if got := atomic.LoadInt32(&deleteCalls); got != 1 { - t.Fatalf("delete calls = %d, want 1", got) - } - - var updated model.DownloadTask - if err := db.Where("id = ?", task.ID).First(&updated).Error; err != nil { - t.Fatal(err) - } - if updated.Status != "deleted" { - t.Fatalf("status = %q, want deleted", updated.Status) - } -} diff --git a/internal/service/download_helpers.go b/internal/service/download_helpers.go new file mode 100644 index 0000000..22df0bd --- /dev/null +++ b/internal/service/download_helpers.go @@ -0,0 +1,75 @@ +package service + +import ( + "strings" + "unicode" +) + +// 整理入库与外部搜索依赖的通用标题归一化辅助函数。 +// 这些函数原本与订阅/下载逻辑共存,移除对应功能时保留为通用帮助函数。 + +func normalizeAvailabilityComparable(value string) string { + var b strings.Builder + for _, r := range strings.ToLower(value) { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + } + } + return b.String() +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func compactUniqueStrings(values ...string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + key := normalizeAvailabilityComparable(value) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, value) + } + return out +} + +func titleMatchesResolution(titleFold, resolution string) bool { + switch strings.ToLower(strings.TrimSpace(resolution)) { + case "2160p", "4k", "uhd": + return strings.Contains(titleFold, "2160p") || strings.Contains(titleFold, "4k") || strings.Contains(titleFold, "uhd") + case "1080p": + return strings.Contains(titleFold, "1080p") || strings.Contains(titleFold, "fhd") + case "720p": + return strings.Contains(titleFold, "720p") + default: + return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(resolution))) + } +} + +func detectResolutionScore(titleFold string) int { + switch { + case titleMatchesResolution(titleFold, "2160p"): + return 4 + case titleMatchesResolution(titleFold, "1080p"): + return 3 + case titleMatchesResolution(titleFold, "720p"): + return 2 + default: + return 1 + } +} \ No newline at end of file diff --git a/internal/service/download_identity.go b/internal/service/download_identity.go deleted file mode 100644 index 032d89a..0000000 --- a/internal/service/download_identity.go +++ /dev/null @@ -1,207 +0,0 @@ -package service - -import ( - "fmt" - "net/url" - "path" - "regexp" - "strings" - "unicode" -) - -var torrentEpisodeToken = regexp.MustCompile(`(?i)e\d{1,3}`) -var downloadPackTitleToken = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:complete|batch|pack|合集|全集|整季|全季)(?:[\s._-]|$)`) - -func localAvailabilityTitleCandidates(title string) []string { - seen := map[string]struct{}{} - out := make([]string, 0, 6) - add := func(value string) { - value = strings.TrimSpace(value) - if value == "" { - return - } - if _, ok := seen[value]; ok { - return - } - seen[value] = struct{}{} - out = append(out, value) - } - add(availabilityQuery(title, "")) - if cleaned, _ := CleanQuery(title); cleaned != "" { - for _, candidate := range titleCandidates(cleaned) { - add(candidate) - fields := strings.Fields(candidate) - for i := len(fields) - 1; i >= 1; i-- { - prefix := strings.Join(fields[:i], " ") - if containsCJK(prefix) { - add(prefix) - } - } - } - } - return out -} - -func downloadTaskBlocksDuplicate(status string) bool { - switch strings.ToLower(strings.TrimSpace(status)) { - case "failed", "error", "removed", "cancelled", "canceled": - return false - default: - return true - } -} - -func downloadTaskBlocksReadd(status string) bool { - switch strings.ToLower(strings.TrimSpace(status)) { - case "failed", "error", "deleted", "removed", "cancelled", "canceled": - return false - default: - return true - } -} - -func downloadTaskIdentityKey(name string) string { - if key := downloadMediaIdentityKey(name); key != "" { - return key - } - return normalizedDownloadTitleKey(name) -} - -type downloadMediaIdentity struct { - TitleKey string - Year int - Episodes []episodeRef - Pack bool -} - -func parseDownloadMediaIdentity(name string) downloadMediaIdentity { - title, year := CleanQuery(name) - if isSeriesPackTitle(name) { - title = downloadPackTitleToken.ReplaceAllString(title, " ") - } - titleKey := normalizeAvailabilityComparable(title) - if titleKey == "" { - titleKey = normalizeAvailabilityComparable(availabilityQuery(name, "")) - } - return downloadMediaIdentity{ - TitleKey: titleKey, - Year: year, - Episodes: episodeRefsFromTitle(name), - Pack: isSeriesPackTitle(name), - } -} - -func downloadTitleCoversRequest(existing, requested string) bool { - current := parseDownloadMediaIdentity(existing) - want := parseDownloadMediaIdentity(requested) - if current.TitleKey == "" || want.TitleKey == "" { - currentKey := normalizedDownloadTitleKey(existing) - wantKey := normalizedDownloadTitleKey(requested) - return currentKey != "" && wantKey != "" && (currentKey == wantKey || strings.Contains(currentKey, wantKey) || strings.Contains(wantKey, currentKey)) - } - if current.TitleKey != want.TitleKey { - return false - } - if current.Year > 0 && want.Year > 0 && current.Year != want.Year { - return false - } - if downloadIdentityCoversWholeSeason(existing, current) { - return true - } - if len(current.Episodes) == 0 || len(want.Episodes) == 0 { - return len(current.Episodes) == len(want.Episodes) - } - currentEpisodes := map[string]struct{}{} - for _, ref := range current.Episodes { - currentEpisodes[episodeKey(ref.Season, ref.Episode)] = struct{}{} - } - for _, ref := range want.Episodes { - if _, ok := currentEpisodes[episodeKey(ref.Season, ref.Episode)]; !ok { - return false - } - } - return true -} - -func downloadIdentityCoversWholeSeason(title string, identity downloadMediaIdentity) bool { - if !identity.Pack || len(identity.Episodes) > 0 { - return false - } - return seriesPackRE.MatchString(title) -} - -func downloadMediaIdentityKey(name string) string { - name = strings.ToLower(strings.TrimSpace(name)) - if name == "" { - return "" - } - identity := parseDownloadMediaIdentity(name) - titleKey := identity.TitleKey - if titleKey == "" { - return "" - } - parts := []string{titleKey} - if identity.Year > 0 { - parts = append(parts, fmt.Sprintf("y%d", identity.Year)) - } - if len(identity.Episodes) > 0 { - first := identity.Episodes[0] - last := identity.Episodes[len(identity.Episodes)-1] - parts = append(parts, fmt.Sprintf("s%02de%03d", first.Season, first.Episode)) - if len(identity.Episodes) > 1 { - parts = append(parts, fmt.Sprintf("to%03d", last.Episode)) - } - } - return strings.Join(parts, "|") -} - -func normalizedDownloadTitleKey(name string) string { - name = strings.ToLower(strings.TrimSpace(name)) - var b strings.Builder - for _, r := range name { - if unicode.IsLetter(r) || unicode.IsDigit(r) { - b.WriteRune(r) - } - } - return b.String() -} - -func publicDownloadTitle(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "下载任务" - } - if u, err := url.Parse(raw); err == nil { - if dn := strings.TrimSpace(u.Query().Get("dn")); dn != "" { - if decoded, err := url.QueryUnescape(dn); err == nil && strings.TrimSpace(decoded) != "" { - return strings.TrimSpace(decoded) - } - return dn - } - if u.Host != "" { - base := path.Base(u.Path) - if base != "." && base != "/" && base != "" { - base = strings.TrimSuffix(base, path.Ext(base)) - if base != "" { - return base - } - } - return u.Host - } - } - if strings.HasPrefix(strings.ToLower(raw), "magnet:") { - return "磁力下载" - } - return "下载任务" -} - -func normalizeTorrentName(name string) string { - name = torrentEpisodeToken.ReplaceAllString(strings.ToLower(name), "") - var b strings.Builder - for _, r := range name { - if unicode.IsLetter(r) || unicode.IsDigit(r) { - b.WriteRune(r) - } - } - return b.String() -} diff --git a/internal/service/download_live_snapshot.go b/internal/service/download_live_snapshot.go deleted file mode 100644 index bbc67bb..0000000 --- a/internal/service/download_live_snapshot.go +++ /dev/null @@ -1,52 +0,0 @@ -package service - -import "time" - -func (d *DownloadService) currentTime() time.Time { - if d != nil && d.now != nil { - return d.now() - } - return time.Now() -} - -func (d *DownloadService) recordLiveTorrentSnapshot(live []QBitTorrent) { - if d == nil { - return - } - snapshot := cloneQBitTorrentSlice(live) - d.mu.Lock() - d.liveTorrents = snapshot - d.liveTorrentsAt = d.currentTime() - d.mu.Unlock() -} - -func (d *DownloadService) LiveTorrentSnapshot(maxAge time.Duration) []QBitTorrent { - snapshot, ok := d.liveTorrentSnapshot(maxAge) - if !ok { - return nil - } - return snapshot -} - -func (d *DownloadService) liveTorrentSnapshot(maxAge time.Duration) ([]QBitTorrent, bool) { - if d == nil { - return nil, false - } - now := d.currentTime() - d.mu.Lock() - defer d.mu.Unlock() - if d.liveTorrentsAt.IsZero() { - return nil, false - } - if maxAge > 0 && now.Sub(d.liveTorrentsAt) > maxAge { - return nil, false - } - return cloneQBitTorrentSlice(d.liveTorrents), true -} - -func cloneQBitTorrentSlice(in []QBitTorrent) []QBitTorrent { - if len(in) == 0 { - return nil - } - return append([]QBitTorrent(nil), in...) -} diff --git a/internal/service/download_manager_svc.go b/internal/service/download_manager_svc.go deleted file mode 100644 index e20abc2..0000000 --- a/internal/service/download_manager_svc.go +++ /dev/null @@ -1,346 +0,0 @@ -// Package service — 下载管理器,管理多个下载客户端适配器。 -// -// DownloadManager 提供多客户端分发能力,支持运行时热插拔。 -// 调用方通过 GetDefault() 或 GetClient(id) 获取适配器来执行下载操作。 -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sync" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// DownloadManager 管理多个下载客户端适配器实例。 -type DownloadManager struct { - log *zap.Logger - repo *repository.Container - crypto *CryptoService - - mu sync.RWMutex - clients map[string]DownloadAdapter // clientID -> adapter - configs map[string]DownloadClientConfig - models map[string]model.DownloadClient - order []string -} - -// NewDownloadManager 创建新的下载管理器。 -func NewDownloadManager(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *DownloadManager { - return &DownloadManager{ - log: log, - repo: repo, - crypto: crypto, - clients: make(map[string]DownloadAdapter), - configs: make(map[string]DownloadClientConfig), - models: make(map[string]model.DownloadClient), - } -} - -// LoadAll 从数据库加载所有已启用的客户端并初始化适配器。 -func (m *DownloadManager) LoadAll(ctx context.Context) error { - dbClients, err := m.repo.DownloadClient.ListEnabled(ctx) - if err != nil { - return err - } - if err := m.ensureEnabledDefault(ctx, dbClients); err != nil { - return err - } - - clients := make(map[string]DownloadAdapter, len(dbClients)) - configs := make(map[string]DownloadClientConfig, len(dbClients)) - models := make(map[string]model.DownloadClient, len(dbClients)) - order := make([]string, 0, len(dbClients)) - - for _, dc := range dbClients { - adapter, cfg, ok := m.initializeClient(ctx, dc) - if !ok { - continue - } - clients[dc.ID] = adapter - configs[dc.ID] = cfg - models[dc.ID] = dc - order = append(order, dc.ID) - m.log.Info("download client registered", - zap.String("id", dc.ID), - zap.String("name", dc.Name), - zap.String("type", dc.Type), - ) - } - m.mu.Lock() - m.clients = clients - m.configs = configs - m.models = models - m.order = order - m.mu.Unlock() - return nil -} - -func (m *DownloadManager) ensureEnabledDefault(ctx context.Context, clients []model.DownloadClient) error { - if len(clients) == 0 { - return nil - } - for i := range clients { - if clients[i].IsDefault { - return nil - } - } - if err := m.repo.DownloadClient.SetDefault(ctx, clients[0].ID); err != nil { - return err - } - clients[0].IsDefault = true - return nil -} - -func (m *DownloadManager) initializeClient(ctx context.Context, dc model.DownloadClient) (DownloadAdapter, DownloadClientConfig, bool) { - cfg, err := m.buildConfig(&dc) - if err != nil { - m.log.Warn("failed to build config for download client", - zap.String("id", dc.ID), - zap.String("name", dc.Name), - zap.Error(err)) - return nil, DownloadClientConfig{}, false - } - adapter := AdapterFactory(dc.Type) - if adapter == nil { - m.log.Warn("unknown download client type", - zap.String("type", dc.Type), - zap.String("id", dc.ID)) - return nil, DownloadClientConfig{}, false - } - if initErr := adapter.Initialize(ctx, cfg); initErr != nil { - // Register configured clients even when the external process is still - // starting; each operation can reconnect once it becomes reachable. - m.log.Warn("download client init failed; registered for lazy reconnect", - zap.String("id", dc.ID), - zap.String("name", dc.Name), - zap.Error(initErr)) - } - return adapter, cfg, true -} - -// GetDefault 返回默认下载客户端适配器。 -// 如果没有设置默认客户端,返回第一个可用的客户端。 -func (m *DownloadManager) GetDefault(_ context.Context) (*model.DownloadClient, DownloadAdapter, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - for _, id := range m.order { - client := m.models[id] - if client.IsDefault { - if adapter, ok := m.clients[id]; ok { - copy := client - return ©, adapter, nil - } - } - } - for _, id := range m.order { - if adapter, ok := m.clients[id]; ok { - client := m.models[id] - copy := client - return ©, adapter, nil - } - } - - return nil, nil, errors.New("no download client available") -} - -// GetClient 返回指定 ID 的下载客户端适配器。 -func (m *DownloadManager) GetClient(id string) (DownloadAdapter, error) { - m.mu.RLock() - defer m.mu.RUnlock() - adapter, ok := m.clients[id] - if !ok { - return nil, errors.New("download client not found or not initialized") - } - return adapter, nil -} - -type managedDownloadTarget struct { - client model.DownloadClient - adapter DownloadAdapter -} - -func (m *DownloadManager) getTarget(id string) (managedDownloadTarget, error) { - m.mu.RLock() - defer m.mu.RUnlock() - adapter, ok := m.clients[id] - if !ok { - return managedDownloadTarget{}, errors.New("download client not found or not initialized") - } - client, ok := m.models[id] - if !ok { - return managedDownloadTarget{}, errors.New("download client metadata not found") - } - return managedDownloadTarget{client: client, adapter: adapter}, nil -} - -func (m *DownloadManager) targets() []managedDownloadTarget { - if m == nil { - return nil - } - m.mu.RLock() - defer m.mu.RUnlock() - out := make([]managedDownloadTarget, 0, len(m.order)) - for _, id := range m.order { - adapter, ok := m.clients[id] - if !ok { - continue - } - client, ok := m.models[id] - if !ok { - continue - } - out = append(out, managedDownloadTarget{client: client, adapter: adapter}) - } - return out -} - -func (m *DownloadManager) hasClients() bool { - if m == nil { - return false - } - m.mu.RLock() - defer m.mu.RUnlock() - return len(m.clients) > 0 -} - -// AddClient 动态添加并初始化一个下载客户端。 -func (m *DownloadManager) AddClient(ctx context.Context, dc *model.DownloadClient) error { - cfg, err := m.buildConfig(dc) - if err != nil { - return err - } - - adapter := AdapterFactory(dc.Type) - if adapter == nil { - return errors.New("unknown download client type: " + dc.Type) - } - - if err := adapter.Initialize(ctx, cfg); err != nil { - return err - } - - m.mu.Lock() - defer m.mu.Unlock() - for i, current := range m.order { - if current == dc.ID { - m.order = append(m.order[:i], m.order[i+1:]...) - break - } - } - m.clients[dc.ID] = adapter - m.configs[dc.ID] = cfg - m.models[dc.ID] = *dc - m.order = append(m.order, dc.ID) - return nil -} - -// RemoveClient 移除一个下载客户端(停止适配器,不删除数据库记录)。 -func (m *DownloadManager) RemoveClient(id string) { - m.mu.Lock() - defer m.mu.Unlock() - delete(m.clients, id) - delete(m.configs, id) - delete(m.models, id) - for i, current := range m.order { - if current == id { - m.order = append(m.order[:i], m.order[i+1:]...) - break - } - } -} - -// UpdateClient 更新已有客户端的配置并重新初始化。 -func (m *DownloadManager) UpdateClient(ctx context.Context, dc *model.DownloadClient) error { - m.RemoveClient(dc.ID) - return m.AddClient(ctx, dc) -} - -// TestConnection 测试客户端连接。 -func (m *DownloadManager) TestConnection(ctx context.Context, dc *model.DownloadClient) error { - cfg, err := m.buildConfig(dc) - if err != nil { - return err - } - - adapter := AdapterFactory(dc.Type) - if adapter == nil { - return errors.New("unknown download client type: " + dc.Type) - } - - return adapter.Initialize(ctx, cfg) -} - -// ListAll 获取所有已加载客户端的种子列表。 -func (m *DownloadManager) ListAll(ctx context.Context, filter string) (map[string][]TorrentInfo, error) { - result := make(map[string][]TorrentInfo) - var listErrs []error - for _, target := range m.targets() { - list, err := target.adapter.List(ctx, filter) - if err != nil { - m.log.Warn("failed to list torrents from client", - zap.String("id", target.client.ID), - zap.Error(err), - ) - listErrs = append(listErrs, fmt.Errorf("%s (%s): %w", target.client.Name, target.client.Type, err)) - } - if len(list) > 0 || err == nil { - result[target.client.ID] = list - } - } - return result, errors.Join(listErrs...) -} - -// GetAdapterTypes 返回支持的下载客户端类型列表。 -func (m *DownloadManager) GetAdapterTypes() []AdapterTypeInfo { - return []AdapterTypeInfo{ - {Type: "qbittorrent", Name: "qBittorrent", Description: "qBittorrent WebUI API (v2)"}, - {Type: "transmission", Name: "Transmission", Description: "Transmission RPC API"}, - {Type: "aria2", Name: "Aria2", Description: "Aria2 JSON-RPC API"}, - } -} - -// AdapterTypeInfo 描述下载客户端类型信息。 -type AdapterTypeInfo struct { - Type string `json:"type"` - Name string `json:"name"` - Description string `json:"description"` -} - -// buildConfig 从数据库模型构建适配器配置。 -func (m *DownloadManager) buildConfig(dc *model.DownloadClient) (DownloadClientConfig, error) { - password := dc.Password - if m.crypto != nil && password != "" { - password = m.crypto.Decrypt(password) - } - host, err := normalizeDownloadClientEndpoint(dc.Type, dc.Host) - if err != nil { - return DownloadClientConfig{}, err - } - - cfg := DownloadClientConfig{ - Host: host, - Username: dc.Username, - Password: password, - } - - // 解析 Extra JSON 配置 - if dc.Extra != "" { - extraStr := dc.Extra - if m.crypto != nil { - extraStr = m.crypto.Decrypt(extraStr) - } - var extra map[string]string - if err := json.Unmarshal([]byte(extraStr), &extra); err == nil { - cfg.Extra = extra - } - } - - return cfg, nil -} diff --git a/internal/service/download_multi_client_test.go b/internal/service/download_multi_client_test.go deleted file mode 100644 index 4760206..0000000 --- a/internal/service/download_multi_client_test.go +++ /dev/null @@ -1,417 +0,0 @@ -package service - -import ( - "encoding/base64" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "sort" - "sync" - "sync/atomic" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestAddDownloadUsesDefaultTransmissionClient(t *testing.T) { - var mu sync.Mutex - var added map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("X-Transmission-Session-Id", "session-test") - w.WriteHeader(http.StatusConflict) - return - } - var req transmissionRPCRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode transmission request: %v", err) - return - } - switch req.Method { - case "torrent-get": - _ = json.NewEncoder(w).Encode(transmissionRPCResponse{Result: "success", Arguments: map[string]interface{}{"torrents": []interface{}{}}}) - case "torrent-add": - mu.Lock() - added = req.Arguments - mu.Unlock() - _ = json.NewEncoder(w).Encode(transmissionRPCResponse{ - Result: "success", - Arguments: map[string]interface{}{ - "torrent-added": map[string]interface{}{"hashString": "transmission-hash", "name": "Movie 2026"}, - }, - }) - default: - t.Errorf("unexpected transmission method %q", req.Method) - } - })) - defer server.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - client := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: server.URL, IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - manager := NewDownloadManager(zap.NewNop(), repos, nil) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.SetDownloadManager(manager) - - task, err := svc.AddDownloadWithMeta(t.Context(), "user-1", "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=Movie+2026", "/downloads/movies", DownloadTaskMeta{Title: "Movie 2026"}) - if err != nil { - t.Fatal(err) - } - if task.Source != "transmission" || task.DownloadClientID != client.ID || task.ExternalID != "transmission-hash" { - t.Fatalf("task downloader identity = %#v", task) - } - mu.Lock() - defer mu.Unlock() - if added["filename"] != "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=Movie+2026" { - t.Fatalf("transmission filename = %#v", added["filename"]) - } - if added["download-dir"] != "/downloads/movies" { - t.Fatalf("transmission download-dir = %#v", added["download-dir"]) - } -} - -func TestAddDownloadSendsFetchedTorrentBytesToTransmission(t *testing.T) { - torrentData := []byte("d4:infod4:name7:fixtureee") - torrentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/x-bittorrent") - w.Header().Set("Content-Disposition", `attachment; filename="fixture.torrent"`) - _, _ = w.Write(torrentData) - })) - defer torrentServer.Close() - - var metainfo string - transmission := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("X-Transmission-Session-Id", "session-test") - w.WriteHeader(http.StatusConflict) - return - } - var req transmissionRPCRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode transmission request: %v", err) - return - } - arguments := map[string]interface{}{} - switch req.Method { - case "torrent-get": - arguments["torrents"] = []interface{}{} - case "torrent-add": - metainfo, _ = req.Arguments["metainfo"].(string) - arguments["torrent-added"] = map[string]interface{}{"hashString": "torrent-file-hash"} - } - _ = json.NewEncoder(w).Encode(transmissionRPCResponse{Result: "success", Arguments: arguments}) - })) - defer transmission.Close() - - db := newServiceTestDB(t, &model.Site{}, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Site.Create(t.Context(), &model.Site{Name: "Fixture", Type: "custom_rss", URL: torrentServer.URL, AuthType: "cookie", Enabled: true}); err != nil { - t.Fatal(err) - } - client := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: transmission.URL, IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - manager := NewDownloadManager(zap.NewNop(), repos, nil) - site := NewSiteService(zap.NewNop(), repos, "") - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil, site) - svc.SetDownloadManager(manager) - task, err := svc.AddDownloadWithMeta(t.Context(), "user-1", torrentServer.URL+"/fixture.torrent", "/downloads", DownloadTaskMeta{}) - if err != nil { - t.Fatal(err) - } - if metainfo != base64.StdEncoding.EncodeToString(torrentData) { - t.Fatalf("metainfo = %q", metainfo) - } - if task.ExternalID != "torrent-file-hash" || task.Title != "fixture" { - t.Fatalf("task = %#v", task) - } -} - -func TestAddDownloadSendsPublicTorrentURLBytesToAria2(t *testing.T) { - torrentData := []byte("d4:infod4:name7:fixtureee") - torrentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/x-bittorrent") - _, _ = w.Write(torrentData) - })) - defer torrentServer.Close() - - var addMethod string - aria2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req aria2Request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode aria2 request: %v", err) - return - } - result := interface{}(map[string]interface{}{"version": "1.37"}) - switch req.Method { - case "aria2.tellActive", "aria2.tellWaiting", "aria2.tellStopped": - result = []interface{}{} - case "aria2.addTorrent", "aria2.addUri": - addMethod = req.Method - result = "aria2-torrent-gid" - } - _ = json.NewEncoder(w).Encode(map[string]interface{}{"jsonrpc": "2.0", "id": req.ID, "result": result}) - })) - defer aria2.Close() - - db := newServiceTestDB(t, &model.Site{}, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - client := &model.DownloadClient{Name: "aria2", Type: "aria2", Host: aria2.URL, IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - site := NewSiteService(zap.NewNop(), repos, "") - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil, site) - svc.SetDownloadManager(NewDownloadManager(zap.NewNop(), repos, nil)) - task, err := svc.AddDownloadWithMeta(t.Context(), "user-1", torrentServer.URL+"/public.torrent", "/downloads", DownloadTaskMeta{Title: "Public Torrent"}) - if err != nil { - t.Fatal(err) - } - if addMethod != "aria2.addTorrent" || task.ExternalID != "aria2-torrent-gid" { - t.Fatalf("add method = %q task = %#v", addMethod, task) - } -} - -func TestReloadConfigHotSwapsUpdatedTransmissionClient(t *testing.T) { - newServer := func(addCalls *int32, hash string) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("X-Transmission-Session-Id", "session-test") - w.WriteHeader(http.StatusConflict) - return - } - var req transmissionRPCRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode transmission request: %v", err) - return - } - arguments := map[string]interface{}{} - switch req.Method { - case "torrent-get": - arguments["torrents"] = []interface{}{} - case "torrent-add": - atomic.AddInt32(addCalls, 1) - arguments["torrent-added"] = map[string]interface{}{"hashString": hash} - } - _ = json.NewEncoder(w).Encode(transmissionRPCResponse{Result: "success", Arguments: arguments}) - })) - } - var firstCalls, secondCalls int32 - first := newServer(&firstCalls, "first-hash") - defer first.Close() - second := newServer(&secondCalls, "second-hash") - defer second.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - client := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: first.URL, IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - manager := NewDownloadManager(zap.NewNop(), repos, nil) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.SetDownloadManager(manager) - if _, err := svc.AddDownloadWithMeta(t.Context(), "user-1", "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=First+Movie", "/downloads", DownloadTaskMeta{Title: "First Movie"}); err != nil { - t.Fatal(err) - } - - client.Host = second.URL - if err := repos.DownloadClient.Update(t.Context(), client); err != nil { - t.Fatal(err) - } - task, err := svc.AddDownloadWithMeta(t.Context(), "user-1", "magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=Second+Movie", "/downloads", DownloadTaskMeta{Title: "Second Movie"}) - if err != nil { - t.Fatal(err) - } - if atomic.LoadInt32(&firstCalls) != 1 || atomic.LoadInt32(&secondCalls) != 1 || task.ExternalID != "second-hash" { - t.Fatalf("hot reload calls = %d/%d task = %#v", firstCalls, secondCalls, task) - } -} - -func TestDownloadManagerPersistsOldestEnabledClientAsDefault(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("X-Transmission-Session-Id", "session-test") - w.WriteHeader(http.StatusConflict) - return - } - http.NotFound(w, r) - })) - defer server.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}) - repos := repository.New(db) - first := &model.DownloadClient{ - Base: model.Base{CreatedAt: time.Now().Add(-time.Hour)}, - Name: "First Transmission", - Type: "transmission", - Host: server.URL, - Enabled: true, - IsDefault: false, - } - second := &model.DownloadClient{ - Base: model.Base{CreatedAt: time.Now()}, - Name: "Second Transmission", - Type: "transmission", - Host: server.URL, - Enabled: true, - IsDefault: false, - } - if err := repos.DownloadClient.Create(t.Context(), first); err != nil { - t.Fatal(err) - } - if err := repos.DownloadClient.Create(t.Context(), second); err != nil { - t.Fatal(err) - } - manager := NewDownloadManager(zap.NewNop(), repos, nil) - if err := manager.LoadAll(t.Context()); err != nil { - t.Fatal(err) - } - selected, _, err := manager.GetDefault(t.Context()) - if err != nil { - t.Fatal(err) - } - if selected.ID != first.ID { - t.Fatalf("default client = %#v", selected) - } - refreshed, err := repos.DownloadClient.FindByID(t.Context(), first.ID) - if err != nil { - t.Fatal(err) - } - if refreshed == nil || !refreshed.IsDefault { - t.Fatalf("persisted default = %#v", refreshed) - } -} - -func TestRelocateRejectsNonQBitClient(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("X-Transmission-Session-Id", "session-test") - w.WriteHeader(http.StatusConflict) - return - } - t.Errorf("unexpected Transmission request during unsupported relocation") - })) - defer server.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - client := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: server.URL, IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - manager := NewDownloadManager(zap.NewNop(), repos, nil) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.SetDownloadManager(manager) - if err := svc.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - - err := svc.RelocateTorrent(t.Context(), "transmission-hash", "/new/location", client.ID) - if !errors.Is(err, ErrDownloadOperationUnsupported) { - t.Fatalf("err = %v", err) - } -} - -func TestListAggregatesEnabledClientsWithNormalizedProgress(t *testing.T) { - transmission := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.Header().Set("X-Transmission-Session-Id", "session-test") - w.WriteHeader(http.StatusConflict) - return - } - var req transmissionRPCRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode transmission request: %v", err) - return - } - arguments := map[string]interface{}{} - if req.Method == "torrent-get" { - arguments["torrents"] = []map[string]interface{}{{ - "hashString": "transmission-hash", - "name": "Transmission Movie", - "totalSize": 1000, - "percentDone": 0.5, - "rateDownload": 100, - "rateUpload": 10, - "status": 4, - "downloadDir": "/downloads/transmission", - "addedDate": 100, - "doneDate": 0, - }} - } - _ = json.NewEncoder(w).Encode(transmissionRPCResponse{Result: "success", Arguments: arguments}) - })) - defer transmission.Close() - - aria2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req aria2Request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Errorf("decode aria2 request: %v", err) - return - } - result := interface{}(map[string]interface{}{"version": "1.37"}) - switch req.Method { - case "aria2.tellActive": - result = []map[string]interface{}{{ - "gid": "aria2-gid", - "bittorrent": map[string]interface{}{"info": map[string]interface{}{"name": "Aria Movie"}, "infoHash": "aria-info-hash"}, - "totalLength": "2000", - "completedLength": "500", - "downloadSpeed": "200", - "uploadSpeed": "20", - "status": "active", - "dir": "/downloads/aria2", - }} - case "aria2.tellWaiting", "aria2.tellStopped": - result = []interface{}{} - } - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "jsonrpc": "2.0", - "id": req.ID, - "result": result, - }) - })) - defer aria2.Close() - - db := newServiceTestDB(t, &model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - transmissionClient := &model.DownloadClient{Name: "Transmission", Type: "transmission", Host: transmission.URL, IsDefault: true, Enabled: true} - aria2Client := &model.DownloadClient{Name: "aria2", Type: "aria2", Host: aria2.URL, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), transmissionClient); err != nil { - t.Fatal(err) - } - if err := repos.DownloadClient.Create(t.Context(), aria2Client); err != nil { - t.Fatal(err) - } - manager := NewDownloadManager(zap.NewNop(), repos, nil) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.SetDownloadManager(manager) - if err := svc.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - - _, live, err := svc.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(live) != 2 { - t.Fatalf("live torrents = %#v", live) - } - sort.Slice(live, func(i, j int) bool { return live[i].Source < live[j].Source }) - if live[0].Source != "aria2" || live[0].ClientID != aria2Client.ID || live[0].Progress != 0.25 || live[0].ContentPath != "/downloads/aria2/Aria Movie" { - t.Fatalf("aria2 live torrent = %#v", live[0]) - } - if live[1].Source != "transmission" || live[1].ClientID != transmissionClient.ID || live[1].Progress != 0.5 || live[1].ContentPath != "/downloads/transmission/Transmission Movie" { - t.Fatalf("transmission live torrent = %#v", live[1]) - } -} diff --git a/internal/service/download_notification.go b/internal/service/download_notification.go deleted file mode 100644 index dac1120..0000000 --- a/internal/service/download_notification.go +++ /dev/null @@ -1,84 +0,0 @@ -package service - -import ( - "context" - "path/filepath" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (d *DownloadService) notifyDownloadComplete(ctx context.Context, torrent QBitTorrent, task *model.DownloadTask) { - if d == nil || d.notify == nil { - return - } - if d.completedTorrentNotified(ctx, torrent) { - return - } - d.markCompletedTorrentNotified(ctx, torrent) - body, data := downloadCompleteNotificationPayload(torrent, task) - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - d.notify.BroadcastEvent(ctx, NotifyEvent{ - Type: EventDownloadComplete, - Title: "MediaStationGo 下载完成", - Message: body, - Data: data, - }) - }() -} - -func downloadCompleteNotificationPayload(torrent QBitTorrent, task *model.DownloadTask) (string, map[string]interface{}) { - name := downloadCompleteNotificationName(torrent, task) - body := "任务:" + name - data := downloadCompleteNotificationData(torrent, task) - return body, data -} - -func downloadCompleteNotificationName(torrent QBitTorrent, task *model.DownloadTask) string { - name := strings.TrimSpace(torrent.Name) - if name == "" { - name = strings.TrimSpace(filepath.Base(torrent.ContentPath)) - } - if task != nil && strings.TrimSpace(task.Title) != "" { - name = strings.TrimSpace(task.Title) - } - if name == "" { - name = "下载任务" - } - return name -} - -func downloadCompleteNotificationData(torrent QBitTorrent, task *model.DownloadTask) map[string]interface{} { - data := map[string]interface{}{} - if rt := strings.TrimSpace(torrent.Name); rt != "" { - data["resource_title"] = rt - } - if task == nil { - return data - } - addTrimmedString(data, "poster_url", task.PosterURL) - addTrimmedString(data, "backdrop_url", task.BackdropURL) - addTrimmedString(data, "media_type", task.MediaType) - addTrimmedString(data, "media_category", task.MediaCategory) - addTrimmedString(data, "title", task.Title) - addTrimmedString(data, "overview", task.Overview) - addTrimmedString(data, "original_title", task.OriginalName) - addTrimmedString(data, "original_language", task.OriginalLanguage) - if task.Year > 0 { - data["year"] = task.Year - } - if task.Rating > 0 { - data["rating"] = task.Rating - } - addTrimmedString(data, "genres", task.Genres) - return data -} - -func addTrimmedString(data map[string]interface{}, key, value string) { - if strings.TrimSpace(value) != "" { - data[key] = value - } -} diff --git a/internal/service/download_polling.go b/internal/service/download_polling.go deleted file mode 100644 index 780c13f..0000000 --- a/internal/service/download_polling.go +++ /dev/null @@ -1,201 +0,0 @@ -package service - -import ( - "context" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -const completedTorrentOrganizeQueueSize = 64 - -var completedTorrentOrganizeCooldown = 3 * time.Second - -// poll aggregates every enabled downloader every 5 s as WS events. The -// payload is opaque to the client; the React store merges by hash. -func (d *DownloadService) poll(ctx context.Context) { - t := time.NewTicker(5 * time.Second) - defer t.Stop() - // prevStates tracks previous completion states to detect "just finished" - if d.prevStates == nil { - d.prevStates = make(map[string]bool) - } - for { - select { - case <-ctx.Done(): - return - case <-d.stopCh: - return - case <-t.C: - } - live, err := d.listLiveTorrents(ctx, "") - if err != nil && len(live) == 0 { - continue - } - rows, _ := d.repo.Download.List(ctx) - taskByKey := tasksByTorrentIdentity(rows) - d.processDownloadSnapshot(ctx, live, taskByKey) - d.hub.Publish("download", map[string]any{"torrents": live}) - } -} - -func (d *DownloadService) processDownloadSnapshot(ctx context.Context, live []QBitTorrent, taskByKey map[string]model.DownloadTask) { - d.recordLiveTorrentSnapshot(live) - firstSnapshot := d.beginDownloadSnapshot() - for _, torrent := range live { - d.processTorrentSnapshot(ctx, torrent, taskByKey, firstSnapshot) - } -} - -func (d *DownloadService) beginDownloadSnapshot() bool { - d.mu.Lock() - defer d.mu.Unlock() - if d.prevStates == nil { - d.prevStates = make(map[string]bool) - } - firstSnapshot := !d.pollInitialized - if firstSnapshot { - d.pollInitialized = true - } - return firstSnapshot -} - -func (d *DownloadService) processTorrentSnapshot(ctx context.Context, torrent QBitTorrent, taskByKey map[string]model.DownloadTask, firstSnapshot bool) { - stateKey := completedTorrentQueueKey(torrent) - taskNeedsOrganize := d.downloadSnapshotTaskNeedsOrganize(ctx, torrent, taskByKey) - d.syncDownloadTaskProgress(ctx, torrent, taskByKey) - if stateKey == "" { - return - } - if d.completedTorrentShouldQueue(stateKey, qbitTorrentCompleted(torrent), firstSnapshot, taskNeedsOrganize) && - d.enqueueCompletedTorrent(torrent) { - d.markCompletedTorrentState(stateKey) - } -} - -func (d *DownloadService) downloadSnapshotTaskNeedsOrganize(ctx context.Context, torrent QBitTorrent, taskByKey map[string]model.DownloadTask) bool { - matchedTask, hasTask := findMatchingTaskForTorrent(torrent, taskByKey) - if !hasTask || d.completedTorrentCatchupRecorded(ctx, torrent) || !d.downloadAutoOrganizeEnabled(ctx) { - return false - } - return downloadTaskNeedsCompletion(matchedTask) || recentlyCompletedTorrent(torrent, time.Now()) -} - -func (d *DownloadService) completedTorrentShouldQueue(stateKey string, complete, firstSnapshot, taskNeedsOrganize bool) bool { - d.mu.Lock() - defer d.mu.Unlock() - if d.prevStates == nil { - d.prevStates = make(map[string]bool) - } - wasComplete, wasSeen := d.prevStates[stateKey] - switch { - case complete && (firstSnapshot || !wasSeen): - // 首次快照里已完成的种子:此前一律标记「已见过」并跳过整理, - // 导致「下载完成时应用恰好不在线/正在重启」的种子永远不会被 - // 自动整理入库。现在对最近完成的种子补一次整理 - // (onTorrentComplete 内部仍受 organize.auto 开关约束,且 - // 整理对已存在的目标文件幂等跳过)。 - d.prevStates[stateKey] = true - return taskNeedsOrganize - case complete && !wasComplete: - return true - case complete && taskNeedsOrganize: - return true - case complete: - d.prevStates[stateKey] = true - default: - d.prevStates[stateKey] = false - } - return false -} - -func (d *DownloadService) markCompletedTorrentState(stateKey string) { - d.mu.Lock() - d.prevStates[stateKey] = true - d.mu.Unlock() -} - -func (d *DownloadService) startAutoOrganizeWorker(ctx context.Context) { - d.mu.Lock() - if d.organizeQueue == nil { - d.organizeQueue = make(chan QBitTorrent, completedTorrentOrganizeQueueSize) - } - if d.organizeQueued == nil { - d.organizeQueued = make(map[string]struct{}) - } - d.mu.Unlock() - d.organizeOnce.Do(func() { - go d.autoOrganizeWorker(ctx) - }) -} - -func (d *DownloadService) enqueueCompletedTorrent(torrent QBitTorrent) bool { - key := completedTorrentQueueKey(torrent) - if key == "" { - return false - } - d.mu.Lock() - if d.organizeQueue == nil { - d.organizeQueue = make(chan QBitTorrent, completedTorrentOrganizeQueueSize) - } - if d.organizeQueued == nil { - d.organizeQueued = make(map[string]struct{}) - } - if _, ok := d.organizeQueued[key]; ok { - d.mu.Unlock() - return true - } - select { - case d.organizeQueue <- torrent: - d.organizeQueued[key] = struct{}{} - d.mu.Unlock() - return true - default: - d.mu.Unlock() - if d.log != nil { - d.log.Warn("auto organize queue full; will retry completed torrent later", - zap.String("hash", torrent.Hash), - zap.String("name", torrent.Name)) - } - return false - } -} - -func (d *DownloadService) autoOrganizeWorker(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - case <-d.stopCh: - return - case torrent := <-d.organizeQueue: - d.onTorrentComplete(ctx, torrent) - d.markCompletedTorrentOrganizeDone(torrent) - if completedTorrentOrganizeCooldown <= 0 { - continue - } - timer := time.NewTimer(completedTorrentOrganizeCooldown) - select { - case <-ctx.Done(): - timer.Stop() - return - case <-d.stopCh: - timer.Stop() - return - case <-timer.C: - } - } - } -} - -func (d *DownloadService) markCompletedTorrentOrganizeDone(torrent QBitTorrent) { - key := completedTorrentQueueKey(torrent) - if key == "" { - return - } - d.mu.Lock() - delete(d.organizeQueued, key) - d.mu.Unlock() -} diff --git a/internal/service/download_runtime.go b/internal/service/download_runtime.go deleted file mode 100644 index e05c64e..0000000 --- a/internal/service/download_runtime.go +++ /dev/null @@ -1,191 +0,0 @@ -package service - -import ( - "context" - "encoding/base32" - "encoding/hex" - "errors" - "fmt" - "net/url" - "strings" -) - -const legacyQBitDownloadClientID = "legacy-qbittorrent" - -type downloadTarget struct { - clientID string - typ string - adapter DownloadAdapter - legacyQB bool -} - -func (d *DownloadService) listLiveTorrents(ctx context.Context, filter string) ([]QBitTorrent, error) { - if d != nil && d.manager != nil && d.manager.hasClients() { - var live []QBitTorrent - var listErrs []error - for _, target := range d.manager.targets() { - items, err := target.adapter.List(ctx, filter) - if err != nil { - listErrs = append(listErrs, fmt.Errorf("%s (%s): %w", target.client.Name, target.client.Type, err)) - } - for _, item := range items { - torrent := TorrentInfoToQBit(item) - torrent.ClientID = target.client.ID - torrent.Source = target.client.Type - live = append(live, torrent) - } - } - return live, errors.Join(listErrs...) - } - if d != nil && d.qb != nil && d.qb.IsConfigured() { - live, err := d.qb.List(ctx, filter) - for i := range live { - live[i].ClientID = legacyQBitDownloadClientID - live[i].Source = "qbittorrent" - live[i].Progress = float32(normalizedTorrentProgress(float64(live[i].Progress))) - live[i].State = canonicalTorrentState(live[i].State, float64(live[i].Progress)) - } - return live, err - } - return nil, errors.New("no download client available") -} - -func torrentURLInfoHash(raw string) string { - parsed, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || !strings.EqualFold(parsed.Scheme, "magnet") { - return "" - } - for _, xt := range parsed.Query()["xt"] { - const prefix = "urn:btih:" - if strings.HasPrefix(strings.ToLower(xt), prefix) { - return normalizeTorrentInfoHash(xt[len(prefix):]) - } - } - return "" -} - -func normalizeTorrentInfoHash(value string) string { - value = strings.TrimSpace(value) - if len(value) == 40 { - if _, err := hex.DecodeString(value); err == nil { - return strings.ToLower(value) - } - } - if len(value) == 32 { - decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(value)) - if err == nil && len(decoded) == 20 { - return hex.EncodeToString(decoded) - } - } - return strings.ToLower(value) -} - -func (d *DownloadService) defaultDownloadTarget(ctx context.Context) (downloadTarget, error) { - if d != nil && d.manager != nil { - client, adapter, err := d.manager.GetDefault(ctx) - if err == nil && client != nil && adapter != nil { - return downloadTarget{clientID: client.ID, typ: client.Type, adapter: adapter}, nil - } - } - if d != nil && d.qb != nil && d.qb.IsConfigured() { - return downloadTarget{clientID: legacyQBitDownloadClientID, typ: "qbittorrent", legacyQB: true}, nil - } - return downloadTarget{}, errors.New("no default downloader configured: 请在下载客户端中配置并启用默认下载器") -} - -func (d *DownloadService) downloadTargetByID(ctx context.Context, clientID string) (downloadTarget, error) { - clientID = strings.TrimSpace(clientID) - if clientID == "" { - return d.defaultDownloadTarget(ctx) - } - if clientID == legacyQBitDownloadClientID { - if d != nil && d.qb != nil && d.qb.IsConfigured() { - return downloadTarget{clientID: legacyQBitDownloadClientID, typ: "qbittorrent", legacyQB: true}, nil - } - return downloadTarget{}, errors.New("legacy qbittorrent is not configured") - } - if d != nil && d.manager != nil { - target, err := d.manager.getTarget(clientID) - if err == nil { - return downloadTarget{clientID: target.client.ID, typ: target.client.Type, adapter: target.adapter}, nil - } - } - return downloadTarget{}, errors.New("download client not found or disabled") -} - -func (d *DownloadService) resolveOperationClientID(ctx context.Context, externalID, requestedClientID string) (string, string, error) { - requestedClientID = strings.TrimSpace(requestedClientID) - live, listErr := d.listLiveTorrents(ctx, "") - if clientID, name, err, resolved := resolveLiveOperationClient(live, externalID, requestedClientID); resolved { - return clientID, name, err - } - if requestedClientID != "" { - return requestedClientID, "", nil - } - if clientID, err := d.persistedOperationClientID(ctx, externalID); clientID != "" || err != nil { - return clientID, "", err - } - if clientID, err := d.singleAvailableOperationClientID(); clientID != "" || err != nil { - return clientID, "", err - } - if d != nil && d.qb != nil && d.qb.IsConfigured() { - return legacyQBitDownloadClientID, "", nil - } - if listErr != nil { - return "", "", listErr - } - return "", "", errors.New("download client not found") -} - -func resolveLiveOperationClient(live []QBitTorrent, externalID, requestedClientID string) (string, string, error, bool) { - var matched []QBitTorrent - for _, torrent := range live { - if requestedClientID != "" && torrent.ClientID != requestedClientID { - continue - } - if strings.EqualFold(strings.TrimSpace(torrent.Hash), strings.TrimSpace(externalID)) { - matched = append(matched, torrent) - } - } - if len(matched) == 1 { - return matched[0].ClientID, matched[0].Name, nil, true - } - if len(matched) > 1 && requestedClientID == "" { - return "", "", errors.New("multiple download clients contain this task; client_id is required"), true - } - return "", "", nil, false -} - -func (d *DownloadService) persistedOperationClientID(ctx context.Context, externalID string) (string, error) { - if d != nil && d.repo != nil && d.repo.Download != nil { - rows, err := d.repo.Download.List(ctx) - if err != nil { - return "", err - } - var clientID string - for _, row := range rows { - if !strings.EqualFold(strings.TrimSpace(row.ExternalID), strings.TrimSpace(externalID)) || strings.TrimSpace(row.DownloadClientID) == "" { - continue - } - if clientID != "" && clientID != row.DownloadClientID { - return "", errors.New("multiple download clients contain this task; client_id is required") - } - clientID = row.DownloadClientID - } - return clientID, nil - } - return "", nil -} - -func (d *DownloadService) singleAvailableOperationClientID() (string, error) { - if d != nil && d.manager != nil { - targets := d.manager.targets() - if len(targets) == 1 { - return targets[0].client.ID, nil - } - if len(targets) > 1 { - return "", errors.New("client_id is required when multiple download clients are enabled") - } - } - return "", nil -} diff --git a/internal/service/download_torrent_normalize.go b/internal/service/download_torrent_normalize.go deleted file mode 100644 index 0b3b98d..0000000 --- a/internal/service/download_torrent_normalize.go +++ /dev/null @@ -1,80 +0,0 @@ -package service - -import "strings" - -func normalizedTorrentProgress(progress float64) float64 { - if progress < 0 { - return 0 - } - if progress > 1 { - return 1 - } - return progress -} - -func canonicalTorrentState(state string, progress float64) string { - state = strings.ToLower(strings.TrimSpace(state)) - complete := normalizedTorrentProgress(progress) >= 1 - switch state { - case "completed", "complete", "seeding", "uploading", "stalledup", "pausedup", "queuedup", "forcedup": - if complete || state == "completed" || state == "complete" || state == "seeding" { - return "completed" - } - return "downloading" - case "downloading", "forceddl", "metadl", "stalleddl", "active": - if complete { - return "completed" - } - return "downloading" - case "queued", "queueddl", "download_pending", "seed_pending", "waiting": - if complete { - return "completed" - } - return "queued" - case "paused", "pauseddl", "stoppeddl", "stopped": - if complete { - return "completed" - } - return "paused" - case "checking", "checkingdl", "checkingup", "checkingresumedata", "check_pending", "moving": - return "checking" - case "error", "missingfiles": - return "error" - case "removed": - return "removed" - case "": - if complete { - return "completed" - } - return "" - default: - if complete { - return "completed" - } - return state - } -} - -func downloaderPayloadPath(dir, name string) string { - dir = strings.TrimSpace(dir) - name = strings.TrimSpace(name) - if name == "" { - return dir - } - if dir == "" { - return name - } - separator := "/" - if strings.Contains(dir, `\`) && !strings.Contains(dir, "/") { - separator = `\` - } - return strings.TrimRight(dir, `/\`) + separator + strings.TrimLeft(name, `/\`) -} - -func downloaderPathBase(value string) string { - value = strings.TrimRight(strings.TrimSpace(value), `/\`) - if i := strings.LastIndexAny(value, `/\`); i >= 0 { - return value[i+1:] - } - return value -} diff --git a/internal/service/download_views.go b/internal/service/download_views.go deleted file mode 100644 index b0f7384..0000000 --- a/internal/service/download_views.go +++ /dev/null @@ -1,217 +0,0 @@ -package service - -import ( - "math" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -type DownloadTaskView struct { - ID string `json:"id"` - Source string `json:"source"` - DownloadClientID string `json:"download_client_id,omitempty"` - ExternalID string `json:"external_id,omitempty"` - Title string `json:"title"` - PosterURL string `json:"poster_url,omitempty"` - BackdropURL string `json:"backdrop_url,omitempty"` - Overview string `json:"overview,omitempty"` - SavePath string `json:"save_path"` - MediaType string `json:"media_type,omitempty"` - MediaCategory string `json:"media_category,omitempty"` - Status string `json:"status"` - Progress float32 `json:"progress"` - State string `json:"state,omitempty"` - DLSpeed int64 `json:"dlspeed,omitempty"` - UpSpeed int64 `json:"upspeed,omitempty"` - Size int64 `json:"size,omitempty"` - Downloaded int64 `json:"downloaded,omitempty"` - NumSeeds int `json:"num_seeds,omitempty"` - NumLeechs int `json:"num_leechs,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type DownloadTorrentView struct { - Hash string `json:"hash"` - ClientID string `json:"client_id"` - Source string `json:"source"` - Name string `json:"name"` - Title string `json:"title"` - PosterURL string `json:"poster_url,omitempty"` - BackdropURL string `json:"backdrop_url,omitempty"` - Overview string `json:"overview,omitempty"` - MediaType string `json:"media_type,omitempty"` - MediaCategory string `json:"media_category,omitempty"` - State string `json:"state"` - Progress float32 `json:"progress"` - DLSpeed int64 `json:"dlspeed"` - UpSpeed int64 `json:"upspeed"` - NumSeeds int `json:"num_seeds"` - NumLeechs int `json:"num_leechs"` - Size int64 `json:"size"` - Downloaded int64 `json:"downloaded"` - SavePath string `json:"save_path"` -} - -func DownloadViews(rows []model.DownloadTask, live []QBitTorrent) ([]DownloadTaskView, []DownloadTorrentView) { - liveByKey := map[string]QBitTorrent{} - for _, torrent := range live { - key := normalizeTorrentName(torrent.Name) - if key != "" { - setLiveTorrentIndex(liveByKey, key, torrent) - setLiveTorrentIndex(liveByKey, downloadTaskClientTitleKey(torrent.ClientID, key), torrent) - } - setLiveTorrentIndex(liveByKey, downloadTaskExternalKey(torrent.ClientID, torrent.Hash), torrent) - setLiveTorrentIndex(liveByKey, downloadTaskAnyExternalKey(torrent.Hash), torrent) - } - taskByKey := tasksByTorrentIdentity(rows) - - taskViews := make([]DownloadTaskView, 0, len(rows)) - for _, row := range rows { - view := downloadTaskView(row, QBitTorrent{}) - if torrent, ok := findMatchingTorrentForTask(row, liveByKey); ok { - view = downloadTaskView(row, torrent) - } - taskViews = append(taskViews, view) - } - - torrentViews := make([]DownloadTorrentView, 0, len(live)) - for _, torrent := range live { - var row model.DownloadTask - if matched, ok := findMatchingTaskForTorrent(torrent, taskByKey); ok { - row = matched - } - torrentViews = append(torrentViews, downloadTorrentView(torrent, row)) - } - return taskViews, torrentViews -} - -func downloadTaskView(row model.DownloadTask, torrent QBitTorrent) DownloadTaskView { - progress := row.Progress - state := row.Status - if torrent.Name != "" { - progress = torrent.Progress - state = torrent.State - } - size := torrent.Size - return DownloadTaskView{ - ID: row.ID, - Source: row.Source, - DownloadClientID: row.DownloadClientID, - ExternalID: row.ExternalID, - Title: firstNonEmpty(row.Title, "下载任务"), - PosterURL: row.PosterURL, - BackdropURL: row.BackdropURL, - Overview: row.Overview, - SavePath: row.SavePath, - MediaType: row.MediaType, - MediaCategory: row.MediaCategory, - Status: row.Status, - Progress: progress, - State: state, - DLSpeed: torrent.DLSpeed, - UpSpeed: torrent.UpSpeed, - Size: size, - Downloaded: downloadedBytes(size, progress), - NumSeeds: torrent.NumSeeds, - NumLeechs: torrent.NumLeech, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - } -} - -func downloadTorrentView(torrent QBitTorrent, row model.DownloadTask) DownloadTorrentView { - title := torrent.Name - if row.Title != "" { - title = row.Title - } - return DownloadTorrentView{ - Hash: torrent.Hash, - ClientID: torrent.ClientID, - Source: torrent.Source, - Name: torrent.Name, - Title: firstNonEmpty(title, "下载任务"), - PosterURL: row.PosterURL, - BackdropURL: row.BackdropURL, - Overview: row.Overview, - MediaType: row.MediaType, - MediaCategory: firstNonEmpty(row.MediaCategory, torrent.Category), - State: torrent.State, - Progress: torrent.Progress, - DLSpeed: torrent.DLSpeed, - UpSpeed: torrent.UpSpeed, - NumSeeds: torrent.NumSeeds, - NumLeechs: torrent.NumLeech, - Size: torrent.Size, - Downloaded: downloadedBytes(torrent.Size, torrent.Progress), - SavePath: torrent.SavePath, - } -} - -func setLiveTorrentIndex(index map[string]QBitTorrent, key string, torrent QBitTorrent) { - if key == "" { - return - } - if _, exists := index[key]; !exists { - index[key] = torrent - } -} - -func findMatchingTorrentForTask(row model.DownloadTask, liveByKey map[string]QBitTorrent) (QBitTorrent, bool) { - if torrent, ok := liveByKey[downloadTaskExternalKey(row.DownloadClientID, row.ExternalID)]; ok { - return torrent, true - } - if torrent, ok := liveByKey[downloadTaskAnyExternalKey(row.ExternalID)]; ok { - if strings.TrimSpace(row.DownloadClientID) == "" || row.DownloadClientID == torrent.ClientID { - return torrent, true - } - } - key := normalizeTorrentName(row.Title) - if torrent, ok := liveByKey[downloadTaskClientTitleKey(row.DownloadClientID, key)]; ok { - return torrent, true - } - if strings.TrimSpace(row.DownloadClientID) != "" { - return QBitTorrent{}, false - } - return findMatchingTorrent(row.Title, liveByKey) -} - -func findMatchingTorrent(title string, liveByKey map[string]QBitTorrent) (QBitTorrent, bool) { - key := normalizeTorrentName(title) - if key == "" { - return QBitTorrent{}, false - } - if torrent, ok := liveByKey[key]; ok { - return torrent, true - } - for currentKey, torrent := range liveByKey { - if strings.HasPrefix(currentKey, "\x00") { - continue - } - if strings.Contains(currentKey, key) || strings.Contains(key, currentKey) { - return torrent, true - } - } - return QBitTorrent{}, false -} - -func downloadedBytes(size int64, progress float32) int64 { - if size <= 0 || progress <= 0 { - return 0 - } - if progress > 1 { - progress = 1 - } - return int64(math.Round(float64(size) * float64(progress))) -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} diff --git a/internal/service/downloads.go b/internal/service/downloads.go deleted file mode 100644 index a0df2d5..0000000 --- a/internal/service/downloads.go +++ /dev/null @@ -1,308 +0,0 @@ -// Package service — download manager. -// -// DownloadService persists user-initiated downloads, dispatches them to -// the configured qBittorrent, Transmission, or aria2 client and pushes live progress -// to the WS hub so the React UI can render a live table. -// -// Settings consumed (system Setting table): -// -// qbittorrent.url e.g. http://127.0.0.1:8080 -// qbittorrent.username qBittorrent WebUI user -// qbittorrent.password qBittorrent WebUI password -// qbittorrent.savepath optional default save dir -// -// Settings can be updated at runtime via the admin UI; ReloadConfig() -// re-reads them and re-authenticates. -package service - -import ( - "context" - "errors" - "fmt" - "strings" - "sync" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// DownloadService is the single download orchestrator. -type DownloadService struct { - log *zap.Logger - repo *repository.Container - hub *Hub - qb *QBitClient - manager *DownloadManager - organizer *OrganizerService - organizePipeline *OrganizePipelineService - scanner *ScannerService - site *SiteService - tasks *TaskTrackerService - notify *NotifyChannelService - - mu sync.Mutex - stopCh chan struct{} - pollOnce sync.Once - organizeOnce sync.Once - prevStates map[string]bool // client/task identity -> wasCompleted - pollInitialized bool - liveTorrents []QBitTorrent - liveTorrentsAt time.Time - now func() time.Time - organizeQueue chan QBitTorrent - organizeQueued map[string]struct{} -} - -func (d *DownloadService) SetScanner(scanner *ScannerService) { - d.scanner = scanner -} - -func (d *DownloadService) SetOrganizePipeline(pipeline *OrganizePipelineService) { - d.organizePipeline = pipeline -} - -func (d *DownloadService) SetTaskTracker(tasks *TaskTrackerService) { - d.tasks = tasks -} - -func (d *DownloadService) SetNotifyChannels(notify *NotifyChannelService) { - d.notify = notify -} - -func (d *DownloadService) SetDownloadManager(manager *DownloadManager) { - d.manager = manager -} - -// ErrDownloadAlreadyExists tells callers that the requested resource is already -// tracked locally or present in a downloader. Subscriptions treat this as a -// successful dedup hit, not as a retryable enqueue failure. -var ErrDownloadAlreadyExists = errors.New("download already exists") - -// ErrMediaAlreadyInLibrary tells callers that the requested movie/episode is -// already present in the scanned media library and must not be sent to the -// downloader again. -var ErrMediaAlreadyInLibrary = errors.New("media already exists in library") - -var ErrDownloadOperationUnsupported = errors.New("download client operation unsupported") - -func IsDownloadDedupError(err error) bool { - return errors.Is(err, ErrDownloadAlreadyExists) || errors.Is(err, ErrMediaAlreadyInLibrary) -} - -// NewDownloadService is the constructor. -func NewDownloadService(log *zap.Logger, repo *repository.Container, hub *Hub, organizer *OrganizerService, site ...*SiteService) *DownloadService { - var siteSvc *SiteService - if len(site) > 0 { - siteSvc = site[0] - } - return &DownloadService{ - log: log, - repo: repo, - hub: hub, - qb: NewQBitClient(log, QBitConfig{}), - organizer: organizer, - site: siteSvc, - prevStates: make(map[string]bool), - now: time.Now, - organizeQueue: make(chan QBitTorrent, completedTorrentOrganizeQueueSize), - organizeQueued: make(map[string]struct{}), - stopCh: make(chan struct{}), - } -} - -// Start kicks off the background poller (idempotent). -func (d *DownloadService) Start(ctx context.Context) { - d.pollOnce.Do(func() { - if err := d.ReloadConfig(ctx); err != nil && d.log != nil { - d.log.Warn("initial download client reload failed", zap.Error(err)) - } - d.startAutoOrganizeWorker(ctx) - go d.poll(ctx) - }) -} - -// Stop terminates the poller. -func (d *DownloadService) Stop() { - close(d.stopCh) -} - -func (d *DownloadService) TorrentExistsByName(ctx context.Context, name string) bool { - query := normalizeTorrentName(name) - if query == "" { - return false - } - live, err := d.listLiveTorrents(ctx, "") - if err != nil && len(live) == 0 { - return false - } - for _, torrent := range live { - if downloadTitleCoversRequest(torrent.Name, name) { - return true - } - current := normalizeTorrentName(torrent.Name) - if current == "" { - continue - } - if current == query { - return true - } - } - return false -} - -// List returns every persisted download task augmented with live downloader data. -func (d *DownloadService) List(ctx context.Context) ([]model.DownloadTask, []QBitTorrent, error) { - rows, err := d.repo.Download.List(ctx) - if err != nil { - return nil, nil, err - } - live, err := d.listLiveTorrents(ctx, "") - if err != nil { - // A failed client must not hide healthy clients or persisted rows. - d.log.Debug("download client list failed", zap.Error(err)) - if len(live) == 0 { - return rows, nil, nil - } - } - return rows, live, nil -} - -// Delete removes a task from its native downloader. clientID is optional for -// legacy callers, but disambiguates equal native IDs across multiple clients. -func (d *DownloadService) Delete(ctx context.Context, hash string, withFiles bool, clientID ...string) error { - hash = strings.TrimSpace(hash) - if hash == "" { - return errors.New("hash is required") - } - requestedClientID := "" - if len(clientID) > 0 { - requestedClientID = clientID[0] - } - resolvedClientID, torrentName, err := d.resolveOperationClientID(ctx, hash, requestedClientID) - if err != nil { - return err - } - target, err := d.downloadTargetByID(ctx, resolvedClientID) - if err != nil { - return err - } - if target.legacyQB { - err = d.qb.Delete(ctx, hash, withFiles) - } else { - err = target.adapter.Remove(ctx, hash, withFiles) - } - if err != nil { - return err - } - d.markDownloadTaskDeleted(ctx, hash, torrentName, resolvedClientID) - stateKey := completedTorrentQueueKey(QBitTorrent{ClientID: resolvedClientID, Hash: hash}) - d.mu.Lock() - delete(d.prevStates, stateKey) - delete(d.organizeQueued, stateKey) - d.mu.Unlock() - return nil -} - -func (d *DownloadService) markDownloadTaskDeleted(ctx context.Context, hash, torrentName string, clientID ...string) { - if d == nil || d.repo == nil || d.repo.DB == nil { - return - } - rows, err := d.repo.Download.List(ctx) - if err != nil { - return - } - if matched, ok := findDownloadTaskByHash(rows, hash, clientID...); ok { - _ = d.repo.DB.WithContext(ctx).Model(&model.DownloadTask{}). - Where("id = ?", matched.ID). - Updates(map[string]any{ - "status": "deleted", - "progress": matched.Progress, - }).Error - return - } - if strings.TrimSpace(torrentName) == "" { - return - } - taskByKey := tasksByTorrentIdentity(rows) - matched, ok := findMatchingTaskForTorrent(QBitTorrent{Name: torrentName, Hash: hash, ClientID: firstString(clientID)}, taskByKey) - if !ok { - return - } - _ = d.repo.DB.WithContext(ctx).Model(&model.DownloadTask{}). - Where("id = ?", matched.ID). - Updates(map[string]any{ - "status": "deleted", - "progress": matched.Progress, - }).Error -} - -func findDownloadTaskByHash(rows []model.DownloadTask, hash string, clientID ...string) (model.DownloadTask, bool) { - hash = strings.ToLower(strings.TrimSpace(hash)) - if hash == "" { - return model.DownloadTask{}, false - } - wantClientID := strings.TrimSpace(firstString(clientID)) - for _, row := range rows { - if wantClientID != "" && strings.TrimSpace(row.DownloadClientID) != "" && row.DownloadClientID != wantClientID { - continue - } - if strings.EqualFold(strings.TrimSpace(row.ExternalID), hash) { - return row, true - } - } - for _, row := range rows { - if wantClientID != "" && strings.TrimSpace(row.DownloadClientID) != "" && row.DownloadClientID != wantClientID { - continue - } - if strings.Contains(strings.ToLower(row.URL), hash) { - return row, true - } - } - return model.DownloadTask{}, false -} - -func firstString(values []string) string { - if len(values) == 0 { - return "" - } - return values[0] -} - -// RelocateTorrent moves a torrent's data to a new save directory while keeping -// it seeding (qBittorrent performs the physical move and resumes seeding). -// 用于「移动 PT 种子文件且转移后继续做种上传」的整盘迁移场景。 -func (d *DownloadService) RelocateTorrent(ctx context.Context, hash, location string, clientID ...string) error { - if strings.TrimSpace(hash) == "" { - return errors.New("hash is required") - } - if strings.TrimSpace(location) == "" { - return errors.New("location is required") - } - requestedClientID := firstString(clientID) - resolvedClientID := strings.TrimSpace(requestedClientID) - if resolvedClientID == "" { - var err error - resolvedClientID, _, err = d.resolveOperationClientID(ctx, hash, "") - if err != nil { - return err - } - } - target, err := d.downloadTargetByID(ctx, resolvedClientID) - if err != nil { - return err - } - if target.typ != "qbittorrent" { - return fmt.Errorf("%w: %s does not support torrent relocation; only qBittorrent is supported", ErrDownloadOperationUnsupported, target.typ) - } - if target.legacyQB { - return d.qb.SetLocation(ctx, hash, strings.TrimSpace(location)) - } - relocator, ok := target.adapter.(TorrentRelocateAdapter) - if !ok { - return fmt.Errorf("%w: configured qBittorrent adapter cannot relocate torrents", ErrDownloadOperationUnsupported) - } - return relocator.Relocate(ctx, hash, strings.TrimSpace(location)) -} diff --git a/internal/service/downloads_completion_test.go b/internal/service/downloads_completion_test.go deleted file mode 100644 index 3f0982c..0000000 --- a/internal/service/downloads_completion_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package service - -import ( - "os" - "path/filepath" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestCompletedTorrentSourceDoesNotFallbackToSavePath(t *testing.T) { - root := t.TempDir() - savePath := filepath.Join(root, "downloads", "日番") - if err := os.MkdirAll(savePath, 0o755); err != nil { - t.Fatal(err) - } - svc := NewDownloadService(zap.NewNop(), newOrganizerTestRepo(t), NewHub(zap.NewNop()), nil) - - got := svc.completedTorrentSource(t.Context(), QBitTorrent{ - Hash: "done123", - Name: "Missing.Payload.S01", - SavePath: savePath, - ContentPath: filepath.Join(savePath, "Missing.Payload.S01", "Missing.Payload.S01E01.mkv"), - }) - - if got != "" { - t.Fatalf("completedTorrentSource fell back to whole save_path %q; want empty", got) - } -} - -func TestDownloadCompleteRecordsUnsupportedVideoAsHandled(t *testing.T) { - root := t.TempDir() - src := filepath.Join(root, "downloads", "Toy.Story.4.2019.iso") - dest := filepath.Join(root, "media") - writeOrgFile(t, src, "iso") - - repos := newOrganizerTestRepo(t) - if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil { - t.Fatal(err) - } - for key, value := range map[string]string{ - "organizer.auto_after_download": "true", - "organize.target_dir": dest, - "organize.transfer_mode": "copy", - } { - if err := repos.Setting.Set(t.Context(), key, value); err != nil { - t.Fatal(err) - } - } - torrent := QBitTorrent{ - Hash: "unsupported-iso", - Name: "Toy.Story.4.2019", - Progress: 1, - SavePath: filepath.Dir(src), - ContentPath: src, - CompletionOn: time.Now().Add(-time.Hour).Unix(), - } - - org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), org) - svc.onTorrentComplete(t.Context(), torrent) - - if !svc.completedTorrentCatchupRecorded(t.Context(), torrent) { - t.Fatalf("unsupported completed torrent should be marked handled to avoid repeated auto-organize retries") - } -} - -func TestAutoOrganizeSyncsVisibilityWhenTargetAlreadyExists(t *testing.T) { - root := t.TempDir() - src := filepath.Join(root, "downloads", "国产剧", "狂飙.S01E01.2023.1080p.mkv") - dest := filepath.Join(root, "media") - writeOrgFile(t, src, "episode") - - repos := newOrganizerTestRepo(t) - for key, value := range map[string]string{ - "organizer.auto_after_download": "true", - "organize.target_dir": dest, - "organize.transfer_mode": "copy", - } { - if err := repos.Setting.Set(t.Context(), key, value); err != nil { - t.Fatal(err) - } - } - lib := model.Library{Name: "国产剧", Path: filepath.Join(dest, "电视剧", "国产剧"), Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) - if _, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ - SourcePath: src, - DestPath: dest, - TransferMode: TransferCopy, - }); err != nil { - t.Fatalf("seed organized destination: %v", err) - } - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), org) - svc.SetScanner(scanner) - - svc.onTorrentComplete(t.Context(), QBitTorrent{ - Hash: "done123", - Name: "狂飙.S01E01.2023.1080p", - Progress: 1, - SavePath: filepath.Dir(src), - ContentPath: src, - }) - - var count int64 - if err := repos.DB.Model(&model.Media{}).Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("target already exists should still be scanned into DB, count=%d want 1", count) - } -} - -func TestCompletedTorrentSourceUsesConfiguredMapping(t *testing.T) { - root := t.TempDir() - localRoot := filepath.Join(root, "localdl") - payload := filepath.Join(localRoot, "Show.S01") - if err := os.MkdirAll(payload, 0o755); err != nil { - t.Fatal(err) - } - repos := newOrganizerTestRepo(t) - if err := repos.Setting.Set(t.Context(), DownloadPathMappingsSettingKey, "/qb/downloads="+localRoot); err != nil { - t.Fatal(err) - } - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - - got := svc.completedTorrentSource(t.Context(), QBitTorrent{ContentPath: "/qb/downloads/Show.S01"}) - if got != payload { - t.Fatalf("completedTorrentSource = %q, want %q", got, payload) - } -} - -func TestUserPathMappingsParsing(t *testing.T) { - repos := newOrganizerTestRepo(t) - raw := "# comment\n/a=/b\n/c => /d\n/e:/f\nbad-line\n" - if err := repos.Setting.Set(t.Context(), DownloadPathMappingsSettingKey, raw); err != nil { - t.Fatal(err) - } - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - got := svc.userPathMappings(t.Context()) - want := map[string]string{"/a": "/b", "/c": "/d", "/e": "/f"} - if len(got) != len(want) { - t.Fatalf("userPathMappings = %v, want %v", got, want) - } - for k, v := range want { - if got[k] != v { - t.Fatalf("mapping %q = %q, want %q", k, got[k], v) - } - } -} diff --git a/internal/service/downloads_config_runtime.go b/internal/service/downloads_config_runtime.go deleted file mode 100644 index a228df7..0000000 --- a/internal/service/downloads_config_runtime.go +++ /dev/null @@ -1,134 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -const settingDownloadClientsManaged = "download_clients.managed" - -// ReloadConfig reloads managed adapters and preserves the legacy qBittorrent -// settings fallback for deployments that never used download_clients. -// -// 配置来源优先级: -// -// 1. download_clients 表中已启用的显式默认客户端;没有默认时持久化最早 -// 创建的已启用客户端。 -// 2. 从未使用多客户端下载器配置的旧部署,读取 system Setting 表中的 -// qbittorrent.url / username / password -// (旧版「系统设置」表单写入的数据;保留作向后兼容)。 -// -// 这避免了两套配置各跑各的:之前操作员明明已经在「下载器」页面填好 -// 默认 qb,但实际下载链路读的还是 Setting 表,导致一直连不上。 -func (d *DownloadService) ReloadConfig(ctx context.Context) error { - if d.manager != nil { - if err := d.manager.LoadAll(ctx); err != nil { - return err - } - if d.manager.hasClients() { - // Managed clients use their native adapters. Keep the legacy qB - // client blank so a Transmission/aria2 default cannot be silently - // overridden by an unrelated qB row. - d.qb.Configure(QBitConfig{}) - return nil - } - } - cfg := QBitConfig{} - hasConfiguredClients := false - managedByDownloadClients := false - - // Path 1: download_clients 表。此分支主要服务未注入 DownloadManager 的 - // 单元/兼容调用;生产容器已在上方通过原生适配器返回。 - if d.repo.DownloadClient != nil { - hasConfiguredClients, _ = d.repo.DownloadClient.HasAnyIncludingDeleted(ctx) - selected, _ := d.repo.DownloadClient.FindDefault(ctx) - if selected == nil { - selected, _ = d.preferredEnabledClient(ctx) - if selected != nil { - _ = d.repo.DownloadClient.SetDefault(ctx, selected.ID) - selected.IsDefault = true - } - } - if selected != nil { - if d.log != nil { - d.log.Debug("selected managed default downloader", - zap.String("client_id", selected.ID), - zap.String("client", selected.Name), - zap.String("type", selected.Type)) - } - if selected.Type == "qbittorrent" { - cfg.BaseURL = strings.TrimRight(selected.Host, "/") - cfg.Username = selected.Username - cfg.Password = selected.Password - } - } - } - if d.repo.Setting != nil { - managedRaw, _ := d.repo.Setting.Get(ctx, settingDownloadClientsManaged) - managedByDownloadClients = strings.EqualFold(strings.TrimSpace(managedRaw), "true") - } - - // Path 2: legacy Setting 表。 - // 仅在旧部署“从未使用过 download_clients 表”时回退。只要操作员曾经 - // 配置过下载器,删除/禁用全部下载器就表示应停止投递,不能再偷偷用 - // qbittorrent.* 旧设置继续往下载器添加任务。 - if cfg.BaseURL == "" && !hasConfiguredClients && !managedByDownloadClients { - get := func(k string) string { - v, _ := d.repo.Setting.Get(ctx, k) - return v - } - cfg.BaseURL = get("qbittorrent.url") - cfg.Username = get("qbittorrent.username") - cfg.Password = get("qbittorrent.password") - } - - d.qb.Configure(cfg) - return nil -} - -func (d *DownloadService) preferredEnabledClient(ctx context.Context) (*model.DownloadClient, error) { - if d == nil || d.repo == nil || d.repo.DownloadClient == nil { - return nil, nil - } - rows, err := d.repo.DownloadClient.ListEnabled(ctx) - if err != nil { - return nil, err - } - if len(rows) == 0 { - return nil, nil - } - selected := rows[0] - return &selected, nil -} - -func (d *DownloadService) defaultDownloaderNotConfiguredError(ctx context.Context) error { - const prefix = "no default downloader configured" - if d == nil || d.repo == nil || d.repo.DownloadClient == nil { - return errors.New(prefix + ": 请在下载客户端中配置并启用下载器") - } - rows, err := d.repo.DownloadClient.ListEnabled(ctx) - if err != nil { - return fmt.Errorf("%s: 读取下载客户端配置失败: %w", prefix, err) - } - if len(rows) == 0 { - return errors.New(prefix + ": 请在下载客户端中启用下载器;当前没有已启用的下载器") - } - - var enabled []string - for _, row := range rows { - label := strings.TrimSpace(row.Name) - if label == "" { - label = row.Type - } else if row.Type != "" { - label += "(" + row.Type + ")" - } - enabled = append(enabled, label) - } - return fmt.Errorf("%s: 请检查已启用下载器的连接和默认设置;当前启用的下载器为 %s", prefix, strings.Join(enabled, ", ")) -} diff --git a/internal/service/downloads_organize_test.go b/internal/service/downloads_organize_test.go deleted file mode 100644 index a1a8ebf..0000000 --- a/internal/service/downloads_organize_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package service - -import ( - "os" - "path/filepath" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestDownloadCompleteAutoOrganizesContentPath(t *testing.T) { - root := t.TempDir() - src := filepath.Join(root, "downloads", "国产剧", "狂飙.S01E01.2023.1080p.mkv") - dest := filepath.Join(root, "media") - if err := os.MkdirAll(filepath.Dir(src), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(src, []byte("episode"), 0o644); err != nil { - t.Fatal(err) - } - - repos := newOrganizerTestRepo(t) - if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil { - t.Fatal(err) - } - for key, value := range map[string]string{ - "organizer.auto_after_download": "true", - "organize.target_dir": dest, - "organize.transfer_mode": "copy", - } { - if err := repos.Setting.Set(t.Context(), key, value); err != nil { - t.Fatal(err) - } - } - org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), org) - svc.onTorrentComplete(t.Context(), QBitTorrent{ - Hash: "done123", - Name: "狂飙.S01E01.2023.1080p", - Progress: 1, - SavePath: filepath.Join(root, "downloads", "国产剧"), - ContentPath: src, - }) - - want := filepath.Join(dest, "电视剧", "国产剧", "狂飙", "Season 01", "狂飙 - S01E01.mkv") - if _, err := os.Stat(want); err != nil { - t.Fatalf("auto organized file missing at %q: %v", want, err) - } - if _, err := os.Stat(src); err != nil { - t.Fatalf("copy mode should keep source: %v", err) - } -} - -func TestDownloadCompleteAutoOrganizeUsesTaskMediaCategory(t *testing.T) { - root := t.TempDir() - src := filepath.Join(root, "downloads", "Motherhood.of.Taihang.S01E01.2026.1080p.mkv") - dest := filepath.Join(root, "media") - writeOrgFile(t, src, "episode") - - repos := newOrganizerTestRepo(t) - if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil { - t.Fatal(err) - } - for key, value := range map[string]string{ - "organizer.auto_after_download": "true", - "organize.target_dir": dest, - "organize.transfer_mode": "copy", - } { - if err := repos.Setting.Set(t.Context(), key, value); err != nil { - t.Fatal(err) - } - } - task := &model.DownloadTask{ - UserID: "u1", - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:motherhood", - Title: "Motherhood.of.Taihang.S01E01.2026.1080p", - SavePath: filepath.Join(root, "downloads"), - MediaType: "tv", - MediaCategory: "国产剧", - Status: "completed", - Progress: 1, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - - org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), org) - svc.onTorrentComplete(t.Context(), QBitTorrent{ - Hash: "done-category", - Name: "Motherhood.of.Taihang.S01E01.2026.1080p", - Progress: 1, - SavePath: filepath.Join(root, "downloads"), - ContentPath: src, - }) - - categoryRoot := filepath.Join(dest, "电视剧", "国产剧") - var organized string - err := filepath.WalkDir(categoryRoot, func(path string, entry os.DirEntry, err error) error { - if err != nil || entry.IsDir() { - return err - } - if filepath.Ext(path) == ".mkv" { - organized = path - } - return nil - }) - if err != nil { - t.Fatalf("walk organized category: %v", err) - } - if organized == "" { - t.Fatalf("expected organized file under %q", categoryRoot) - } - wrongRoot := filepath.Join(dest, "电视剧", "Motherhood Of Taihang") - if _, err := os.Stat(wrongRoot); !os.IsNotExist(err) { - t.Fatalf("unexpected uncategorized organize root %q, err=%v", wrongRoot, err) - } -} - -func TestDownloadCompleteOnlyReplacesExistingWhenTaskAllowsWash(t *testing.T) { - for _, tc := range []struct { - name string - allowWash bool - wantContent string - }{ - {name: "wash disabled keeps existing", allowWash: false, wantContent: "inception-1080p"}, - {name: "wash enabled replaces existing", allowWash: true, wantContent: "inception-2160p"}, - } { - t.Run(tc.name, func(t *testing.T) { - root := t.TempDir() - src := filepath.Join(root, "downloads", "Inception 2010 2160p BluRay.mkv") - dest := filepath.Join(root, "media") - existing := filepath.Join(dest, "电影", "Inception (2010)", "Inception (2010).mkv") - writeOrgFile(t, src, "inception-2160p") - writeOrgFile(t, existing, "inception-1080p") - - repos := newOrganizerTestRepo(t) - if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil { - t.Fatal(err) - } - for key, value := range map[string]string{ - "organizer.auto_after_download": "true", - "organize.target_dir": dest, - "organize.transfer_mode": "copy", - } { - if err := repos.Setting.Set(t.Context(), key, value); err != nil { - t.Fatal(err) - } - } - if err := repos.Media.Upsert(t.Context(), &model.Media{ - Title: "Inception", - Path: existing, - Year: 2010, - Container: "mkv", - Width: 1920, - Height: 1080, - }); err != nil { - t.Fatal(err) - } - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:inception", - Title: "Inception 2010 2160p BluRay", - SavePath: filepath.Dir(src), - Status: "completed", - Progress: 1, - AllowExistingLibrary: tc.allowWash, - }); err != nil { - t.Fatal(err) - } - - org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), org) - svc.onTorrentComplete(t.Context(), QBitTorrent{ - Hash: "inception", - Name: "Inception 2010 2160p BluRay", - Progress: 1, - SavePath: filepath.Dir(src), - ContentPath: src, - }) - - got, err := os.ReadFile(existing) - if err != nil { - t.Fatal(err) - } - if string(got) != tc.wantContent { - t.Fatalf("existing content = %q, want %q", string(got), tc.wantContent) - } - }) - } -} diff --git a/internal/service/downloads_poll_test.go b/internal/service/downloads_poll_test.go deleted file mode 100644 index f4ccfba..0000000 --- a/internal/service/downloads_poll_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package service - -import ( - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestDownloadPollBaselinesAlreadyCompletedTorrents(t *testing.T) { - repos := newOrganizerTestRepo(t) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ - Hash: "already-complete", - Name: "Already Complete S01E01", - Progress: 1, - State: "stalledUP", - }}, nil) - - if got := len(svc.organizeQueue); got != 0 { - t.Fatalf("first poll queued %d organize jobs, want 0", got) - } - if !svc.prevStates["already-complete"] { - t.Fatal("first poll should remember completed baseline state") - } - - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ - Hash: "late-complete", - Name: "Late Complete S01E01", - Progress: 1, - State: "stalledUP", - }}, nil) - if got := len(svc.organizeQueue); got != 0 { - t.Fatalf("newly discovered completed torrent queued %d organize jobs, want 0", got) - } - - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ - Hash: "new-download", - Name: "New Download S01E01", - Progress: 0.5, - }}, nil) - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ - Hash: "new-download", - Name: "New Download S01E01", - Progress: 1, - State: "stalledUP", - }}, nil) - - if got := len(svc.organizeQueue); got != 1 { - t.Fatalf("completion transition queued %d organize jobs, want 1", got) - } -} - -func TestDownloadPollCatchesUpRecentlyCompletedTorrents(t *testing.T) { - repos := newOrganizerTestRepo(t) - if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil { - t.Fatal(err) - } - task := &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:fresh", - Title: "Fresh Complete S01E01", - SavePath: "/downloads", - Status: "queued", - Progress: 0, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "organizer.auto_after_download", "true"); err != nil { - t.Fatal(err) - } - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{ - {Hash: "fresh-complete", Name: "Fresh Complete S01E01", Progress: 1, State: "stalledUP", CompletionOn: time.Now().Add(-time.Hour).Unix()}, - {Hash: "stale-complete", Name: "Stale Complete S01E01", Progress: 1, State: "stalledUP", CompletionOn: time.Now().Add(-48 * time.Hour).Unix()}, - {Hash: "no-timestamp", Name: "No Timestamp S01E01", Progress: 1, State: "stalledUP"}, - }, tasksByTorrentIdentity([]model.DownloadTask{*task})) - - // 只有补整理时间窗内、且存在本地追踪任务的种子会被补整理;无 completion_on 的保守跳过。 - if got := len(svc.organizeQueue); got != 1 { - t.Fatalf("first poll queued %d organize jobs, want 1 (recent tracked completion only)", got) - } -} - -func TestDownloadPollDoesNotCatchUpWhenAutoOrganizeDisabled(t *testing.T) { - repos := newOrganizerTestRepo(t) - if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil { - t.Fatal(err) - } - task := &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:fresh", - Title: "Fresh Complete S01E01", - SavePath: "/downloads", - Status: "queued", - Progress: 0, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - torrent := QBitTorrent{ - Hash: "fresh-complete", - Name: "Fresh Complete S01E01", - Progress: 1, - State: "stalledUP", - CompletionOn: time.Now().Add(-time.Hour).Unix(), - } - - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{torrent}, tasksByTorrentIdentity([]model.DownloadTask{*task})) - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{torrent}, tasksByTorrentIdentity([]model.DownloadTask{*task})) - - if got := len(svc.organizeQueue); got != 0 { - t.Fatalf("auto-organize disabled queued %d completed jobs, want 0", got) - } -} - -func TestDownloadPollSkipsRecordedCompletedTorrentCatchup(t *testing.T) { - repos := newOrganizerTestRepo(t) - torrent := QBitTorrent{ - Hash: "fresh-complete", - Name: "Fresh Complete S01E01", - Progress: 1, - State: "stalledUP", - CompletionOn: time.Now().Add(-time.Hour).Unix(), - } - if err := repos.Setting.Set(t.Context(), completedTorrentCatchupSettingKey(torrent), "true"); err != nil { - t.Fatal(err) - } - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{torrent}, nil) - - if got := len(svc.organizeQueue); got != 0 { - t.Fatalf("recorded completed torrent queued %d organize jobs, want 0", got) - } -} diff --git a/internal/service/downloads_progress_test.go b/internal/service/downloads_progress_test.go deleted file mode 100644 index d204318..0000000 --- a/internal/service/downloads_progress_test.go +++ /dev/null @@ -1,325 +0,0 @@ -package service - -import ( - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSyncDownloadTaskProgressSkipsUnchangedCompletedTask(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}) - repos := repository.New(db) - task := &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:test", - Title: "Already.Done.S01E01", - SavePath: "/downloads", - Status: "completed", - Progress: 1, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - var before model.DownloadTask - if err := db.First(&before, "id = ?", task.ID).Error; err != nil { - t.Fatal(err) - } - time.Sleep(10 * time.Millisecond) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.syncDownloadTaskProgress(t.Context(), QBitTorrent{ - Name: task.Title, - Progress: 1, - State: "completed", - }, tasksByIdentity([]model.DownloadTask{before})) - var after model.DownloadTask - if err := db.First(&after, "id = ?", task.ID).Error; err != nil { - t.Fatal(err) - } - if !after.UpdatedAt.Equal(before.UpdatedAt) { - t.Fatalf("unchanged completed torrent touched updated_at: before=%s after=%s", before.UpdatedAt, after.UpdatedAt) - } -} - -func TestSyncDownloadTaskProgressMatchesSeasonFolderTorrentName(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}) - repos := repository.New(db) - task := &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:test", - Title: "The First Jasmine S01E01 1080p TX WEB-DL AAC2.0 H.264-MWeb", - SavePath: "/downloads/欧美剧", - Status: "queued", - Progress: 0.5, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.syncDownloadTaskProgress(t.Context(), QBitTorrent{ - Name: "The.First.Jasmine.S01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb", - Progress: 1, - State: "stalledUP", - }, tasksByTorrentIdentity([]model.DownloadTask{*task})) - - var after model.DownloadTask - if err := db.First(&after, "id = ?", task.ID).Error; err != nil { - t.Fatal(err) - } - if after.Status != "completed" || after.Progress != 1 { - t.Fatalf("task completion = %s/%v, want completed/1", after.Status, after.Progress) - } -} - -func TestSyncDownloadTaskProgressBackfillsDownloaderIdentity(t *testing.T) { - repos := newOrganizerTestRepo(t) - if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil { - t.Fatal(err) - } - task := &model.DownloadTask{ - UserID: "u1", - Source: "qbittorrent", - URL: "https://pt.example/download?id=legacy", - Title: "Legacy Identity Movie 2026", - Status: "queued", - Progress: 0, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - torrent := QBitTorrent{ - Hash: "transmission-hash", - ClientID: "transmission-client", - Source: "transmission", - Name: "Legacy Identity Movie 2026", - State: "downloading", - Progress: 0.4, - } - svc.syncDownloadTaskProgress(t.Context(), torrent, tasksByTorrentIdentity([]model.DownloadTask{*task})) - - var updated model.DownloadTask - if err := repos.DB.Where("id = ?", task.ID).First(&updated).Error; err != nil { - t.Fatal(err) - } - if updated.DownloadClientID != torrent.ClientID || updated.ExternalID != torrent.Hash || updated.Source != torrent.Source { - t.Fatalf("downloader identity = %#v", updated) - } -} - -func TestProcessDownloadSnapshotCompletesTransmissionTask(t *testing.T) { - repos := newOrganizerTestRepo(t) - if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "organizer.auto_after_download", "true"); err != nil { - t.Fatal(err) - } - task := &model.DownloadTask{ - UserID: "u1", - Source: "transmission", - DownloadClientID: "transmission-client", - ExternalID: "shared-native-id", - URL: "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - Title: "Transmission Complete Movie", - Status: "downloading", - Progress: 0.8, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - torrent := QBitTorrent{ - Hash: task.ExternalID, - ClientID: task.DownloadClientID, - Source: task.Source, - Name: task.Title, - State: "completed", - Progress: 1, - ContentPath: "/downloads/Transmission Complete Movie", - CompletionOn: time.Now().Unix(), - } - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{torrent}, tasksByTorrentIdentity([]model.DownloadTask{*task})) - if got := len(svc.organizeQueue); got != 1 { - t.Fatalf("queued completed organize jobs = %d", got) - } - var updated model.DownloadTask - if err := repos.DB.Where("id = ?", task.ID).First(&updated).Error; err != nil { - t.Fatal(err) - } - if updated.Status != "completed" || updated.Progress != 1 { - t.Fatalf("task completion = %q/%v", updated.Status, updated.Progress) - } - other := torrent - other.ClientID = "another-client" - if completedTorrentQueueKey(torrent) == completedTorrentQueueKey(other) { - t.Fatal("completion queue keys collided across download clients") - } -} - -func TestProcessDownloadSnapshotQueuesCompletedPendingTaskOnFirstSnapshot(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - task := &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:test", - Title: "Blades of the Guardians S02E01 1080p TX WEB-DL AAC2.0 H.264-MWeb", - SavePath: "/downloads/欧美剧", - Status: "queued", - Progress: 0, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "organizer.auto_after_download", "true"); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ - Hash: "quickdone", - Name: "Blades.of.the.Guardians.S02.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb", - Progress: 1, - State: "stalledUP", - }}, tasksByTorrentIdentity([]model.DownloadTask{*task})) - - if got := len(svc.organizeQueue); got != 1 { - t.Fatalf("queued completed organize jobs = %d, want 1", got) - } -} - -func TestProcessDownloadSnapshotDoesNotQueueActiveDownloadAtFullProgress(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - task := &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:test", - Title: "Still Downloading S01E01", - SavePath: "/downloads/欧美剧", - Status: "downloading", - Progress: 0.99, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "organizer.auto_after_download", "true"); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ - Hash: "notdone", - Name: "Still.Downloading.S01E01", - Progress: 1, - State: "downloading", - }}, tasksByTorrentIdentity([]model.DownloadTask{*task})) - - if got := len(svc.organizeQueue); got != 0 { - t.Fatalf("queued active download organize jobs = %d, want 0", got) - } - var after model.DownloadTask - if err := db.First(&after, "id = ?", task.ID).Error; err != nil { - t.Fatal(err) - } - if after.Status == "completed" { - t.Fatalf("active download status = %q, should not be completed", after.Status) - } -} - -func TestProcessDownloadSnapshotDoesNotQueueFullProgressWithoutQBitState(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - task := &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:test", - Title: "Missing State S01E01", - SavePath: "/downloads/欧美剧", - Status: "downloading", - Progress: 0.99, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "organizer.auto_after_download", "true"); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ - Hash: "missing-state", - Name: "Missing.State.S01E01", - Progress: 1, - }}, tasksByTorrentIdentity([]model.DownloadTask{*task})) - - if got := len(svc.organizeQueue); got != 0 { - t.Fatalf("queued full-progress torrent without state = %d, want 0", got) - } - var after model.DownloadTask - if err := db.First(&after, "id = ?", task.ID).Error; err != nil { - t.Fatal(err) - } - if after.Status == "completed" { - t.Fatalf("missing-state torrent status = %q, should not be completed", after.Status) - } -} - -func TestProcessDownloadSnapshotDoesNotTrustCompletionOnForActiveDownload(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - task := &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:test", - Title: "Still Downloading With Completion Timestamp S01E01", - SavePath: "/downloads/欧美剧", - Status: "downloading", - Progress: 0.5, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "organizer.auto_after_download", "true"); err != nil { - t.Fatal(err) - } - - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ - Hash: "notdone-completion-on", - Name: "Still.Downloading.With.Completion.Timestamp.S01E01", - Progress: 0.5, - State: "downloading", - CompletionOn: time.Now().Unix(), - }}, tasksByTorrentIdentity([]model.DownloadTask{*task})) - - if got := len(svc.organizeQueue); got != 0 { - t.Fatalf("queued active download organize jobs = %d, want 0", got) - } - var after model.DownloadTask - if err := db.First(&after, "id = ?", task.ID).Error; err != nil { - t.Fatal(err) - } - if after.Status == "completed" || after.Progress >= 1 { - t.Fatalf("active download mutated to completed state: status=%q progress=%v", after.Status, after.Progress) - } -} - -func TestProcessDownloadSnapshotSkipsUntrackedCompletedTorrentOnFirstSnapshot(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - - svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ - Hash: "historydone", - Name: "Large.History.Pack.2026.1080p", - Progress: 1, - State: "stalledUP", - CompletionOn: time.Now().Unix(), - }}, tasksByTorrentIdentity(nil)) - - if got := len(svc.organizeQueue); got != 0 { - t.Fatalf("queued untracked historical torrents = %d, want 0", got) - } -} diff --git a/internal/service/downloads_test.go b/internal/service/downloads_test.go deleted file mode 100644 index 33e63dd..0000000 --- a/internal/service/downloads_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package service - -import ( - "testing" - "time" - - "go.uber.org/zap" -) - -func TestLiveTorrentSnapshotUsesPollingSnapshot(t *testing.T) { - now := time.Date(2026, 6, 23, 0, 0, 0, 0, time.UTC) - svc := NewDownloadService(zap.NewNop(), nil, NewHub(zap.NewNop()), nil) - svc.now = func() time.Time { return now } - - live := []QBitTorrent{{ - Hash: "hash-1", - Name: "Release.Name.S01E01", - State: "downloading", - Progress: 0.5, - }} - svc.processDownloadSnapshot(t.Context(), live, nil) - live[0].Name = "mutated" - - got := svc.LiveTorrentSnapshot(30 * time.Second) - if len(got) != 1 || got[0].Name != "Release.Name.S01E01" { - t.Fatalf("snapshot = %#v, want cloned live torrent", got) - } - got[0].Name = "changed" - again := svc.LiveTorrentSnapshot(30 * time.Second) - if len(again) != 1 || again[0].Name != "Release.Name.S01E01" { - t.Fatalf("snapshot was mutable through caller: %#v", again) - } - - now = now.Add(31 * time.Second) - if stale := svc.LiveTorrentSnapshot(30 * time.Second); len(stale) != 0 { - t.Fatalf("stale snapshot = %#v, want empty", stale) - } -} diff --git a/internal/service/downloads_view_test.go b/internal/service/downloads_view_test.go deleted file mode 100644 index 00168ab..0000000 --- a/internal/service/downloads_view_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package service - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestDownloadViewsDoNotExposePrivateURL(t *testing.T) { - rows := []model.DownloadTask{{ - UserID: "u1", - Source: "qbittorrent", - URL: "https://tracker.example/download?id=1&passkey=private-token", - Title: "测试影片", - SavePath: "/downloads", - Status: "queued", - }} - - tasks, torrents := DownloadViews(rows, nil) - data, err := json.Marshal(map[string]any{ - "tasks": tasks, - "torrents": torrents, - }) - if err != nil { - t.Fatal(err) - } - body := string(data) - if strings.Contains(body, "private-token") || strings.Contains(body, "passkey") || strings.Contains(body, "tracker.example") { - t.Fatalf("download views leaked private URL: %s", body) - } - if !strings.Contains(body, "测试影片") { - t.Fatalf("download views should keep public title: %s", body) - } -} - -func TestDownloadCompleteNotificationPayloadUsesTaskMetadata(t *testing.T) { - body, data := downloadCompleteNotificationPayload(QBitTorrent{ - Hash: "done123", - Name: "Release.Name.S01E02.1080p", - SavePath: "/downloads/show", - ContentPath: "/downloads/show/Release.Name.S01E02.1080p.mkv", - }, &model.DownloadTask{ - Title: "正式标题", - PosterURL: "https://img.example/poster.jpg", - BackdropURL: "https://img.example/backdrop.jpg", - MediaType: "tv", - MediaCategory: "日番", - Overview: "简介", - OriginalName: "Original Title", - OriginalLanguage: "ja", - Year: 2026, - Rating: 8.7, - Genres: "动画,剧情", - }) - - if !strings.Contains(body, "任务:正式标题") { - t.Fatalf("body should prefer task title, got %q", body) - } - for _, private := range []string{"保存路径", "/downloads/show", "Hash", "done123"} { - if strings.Contains(body, private) { - t.Fatalf("body should not expose %q, got %q", private, body) - } - } - for key, want := range map[string]interface{}{ - "resource_title": "Release.Name.S01E02.1080p", - "title": "正式标题", - "poster_url": "https://img.example/poster.jpg", - "backdrop_url": "https://img.example/backdrop.jpg", - "media_type": "tv", - "media_category": "日番", - "overview": "简介", - "original_title": "Original Title", - "original_language": "ja", - "year": 2026, - "rating": float32(8.7), - "genres": "动画,剧情", - } { - if got := data[key]; got != want { - t.Fatalf("data[%s] = %#v, want %#v", key, got, want) - } - } -} diff --git a/internal/service/duplicate.go b/internal/service/duplicate.go deleted file mode 100644 index b39396b..0000000 --- a/internal/service/duplicate.go +++ /dev/null @@ -1,216 +0,0 @@ -// Package service — duplicate-file finder. -// -// DuplicateService finds duplicate media by two signals: -// -// - external identity: same TMDb / Bangumi / Douban / TheTVDB id and, for -// episodes, same season+episode; -// - sparse file hash: same head + middle + tail SHA-256 and same size. -// -// The first row (preferring scraped + larger files) is kept as the primary; -// the rest get is_duplicate = true and duplicate_of pointing at the primary. -// -// Why sparse: a full hash on a 50 GB Blu-ray remux takes minutes; the -// 3-window 3 MiB sample is enough to differentiate real-world copies -// while finishing per-file in well under a second. -package service - -import ( - "context" - "os" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// DuplicateService is the entry point for the duplicate finder. -type DuplicateService struct { - log *zap.Logger - repo *repository.Container - hub *Hub -} - -// NewDuplicateService is the constructor. -func NewDuplicateService(log *zap.Logger, repo *repository.Container, hub *Hub) *DuplicateService { - return &DuplicateService{log: log, repo: repo, hub: hub} -} - -// Group describes one set of duplicates returned by Detect. -type Group struct { - Hash string `json:"hash"` - Primary model.Media `json:"primary"` - Duplicates []model.Media `json:"duplicates"` -} - -// Report is the summary the React UI displays. -type Report struct { - TotalScanned int `json:"total_scanned"` - GroupsFound int `json:"groups_found"` - ItemsMarked int `json:"items_marked"` - MissingRemoved int64 `json:"missing_removed"` - Groups []Group `json:"groups"` -} - -// Detect walks every media row in the given library (or all libraries -// when libraryID is empty), computes a hash for the ones missing it, -// then groups by hash and marks duplicates in the DB. -func (d *DuplicateService) Detect(ctx context.Context, libraryID string) (*Report, error) { - var rows []model.Media - q := d.repo.DB.WithContext(ctx).Model(&model.Media{}) - if libraryID != "" { - q = q.Where("library_id = ?", libraryID) - } - if err := q.Find(&rows).Error; err != nil { - return nil, err - } - - rep := &Report{Groups: []Group{}} - rows = d.removeMissingRows(ctx, rows, rep) - rep.TotalScanned = len(rows) - totalToHash := 0 - for i := range rows { - if rows[i].FileHash == "" && rows[i].Path != "" { - totalToHash++ - } - } - - hashed := 0 - for i := range rows { - select { - case <-ctx.Done(): - return rep, ctx.Err() - default: - } - if rows[i].FileHash != "" || rows[i].Path == "" { - continue - } - h, err := SparseFileHash(rows[i].Path) - if err != nil { - d.log.Debug("hash failed", zap.String("path", rows[i].Path), zap.Error(err)) - continue - } - rows[i].FileHash = h - if err := d.repo.DB.WithContext(ctx). - Model(&model.Media{}). - Where("id = ?", rows[i].ID). - Update("file_hash", h).Error; err != nil { - d.log.Warn("hash persist failed", zap.Error(err)) - } - hashed++ - if d.hub != nil && totalToHash > 0 { - d.hub.Publish("duplicate", map[string]any{ - "hashed": hashed, - "total": totalToHash, - "current": rows[i].Title, - }) - } - } - - // Group rows by file_hash. - groups := make(map[string][]model.Media) - for _, r := range rows { - if r.FileHash == "" { - continue - } - groups[r.FileHash] = append(groups[r.FileHash], r) - } - - markedIDs := map[string]struct{}{} - for hash, group := range groups { - if len(group) < 2 { - continue - } - d.markDuplicateGroup(ctx, rep, hash, group, markedIDs) - } - - for key, group := range groupByExternalIdentity(rows) { - if len(group) < 2 { - continue - } - d.markDuplicateGroup(ctx, rep, key, group, markedIDs) - } - rep.GroupsFound = len(rep.Groups) - if d.hub != nil { - d.hub.Publish("duplicate", map[string]any{ - "finished": true, - "groups": rep.GroupsFound, - "marked": rep.ItemsMarked, - }) - } - return rep, nil -} - -// Current returns duplicate groups already marked in the database. It keeps -// the UI useful after a prior scan and avoids requiring POST on page load. -func (d *DuplicateService) Current(ctx context.Context, libraryID string) (*Report, error) { - var rows []model.Media - q := d.repo.DB.WithContext(ctx).Where("is_duplicate = ? OR duplicate_of <> ''", true) - if libraryID != "" { - q = q.Where("library_id = ?", libraryID) - } - if err := q.Find(&rows).Error; err != nil { - return nil, err - } - rep := &Report{TotalScanned: len(rows), Groups: []Group{}} - byPrimary := make(map[string][]model.Media) - for _, row := range rows { - if row.DuplicateOf == "" { - continue - } - byPrimary[row.DuplicateOf] = append(byPrimary[row.DuplicateOf], row) - } - for primaryID, dupes := range byPrimary { - primary, err := d.repo.Media.FindByID(ctx, primaryID) - if err != nil || primary == nil { - continue - } - hash := primary.FileHash - if hash == "" && len(dupes) > 0 { - hash = dupes[0].FileHash - } - rep.Groups = append(rep.Groups, Group{ - Hash: hash, - Primary: *primary, - Duplicates: dupes, - }) - } - rep.GroupsFound = len(rep.Groups) - return rep, nil -} - -func (d *DuplicateService) removeMissingRows(ctx context.Context, rows []model.Media, rep *Report) []model.Media { - kept := make([]model.Media, 0, len(rows)) - for _, row := range rows { - if row.Path == "" { - kept = append(kept, row) - continue - } - if _, err := os.Stat(row.Path); err == nil { - kept = append(kept, row) - continue - } else if !os.IsNotExist(err) { - kept = append(kept, row) - continue - } - res := d.repo.DB.WithContext(ctx).Where("id = ?", row.ID).Delete(&model.Media{}) - if res.Error != nil { - d.log.Warn("remove missing duplicate candidate failed", zap.String("media", row.ID), zap.Error(res.Error)) - continue - } - rep.MissingRemoved += res.RowsAffected - } - return kept -} - -// Unmark clears the is_duplicate flag for every row in the given library -// (or all when libraryID is empty). Useful when the operator deletes the -// physical duplicates manually. -func (d *DuplicateService) Unmark(ctx context.Context, libraryID string) (int64, error) { - q := d.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("is_duplicate = ?", true) - if libraryID != "" { - q = q.Where("library_id = ?", libraryID) - } - res := q.Updates(map[string]any{"is_duplicate": false, "duplicate_of": ""}) - return res.RowsAffected, res.Error -} diff --git a/internal/service/duplicate_groups.go b/internal/service/duplicate_groups.go deleted file mode 100644 index 3695361..0000000 --- a/internal/service/duplicate_groups.go +++ /dev/null @@ -1,93 +0,0 @@ -// Package service — duplicate grouping helpers. -package service - -import ( - "context" - "fmt" - "sort" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (d *DuplicateService) markDuplicateGroup(ctx context.Context, rep *Report, key string, group []model.Media, markedIDs map[string]struct{}) { - primary := pickPrimary(group) - dupes := make([]model.Media, 0, len(group)-1) - for _, m := range group { - if m.ID == primary.ID || m.DuplicateOf == primary.ID { - continue - } - if _, ok := markedIDs[m.ID]; ok { - continue - } - dupes = append(dupes, m) - if err := d.repo.DB.WithContext(ctx). - Model(&model.Media{}). - Where("id = ?", m.ID). - Updates(map[string]any{ - "is_duplicate": true, - "duplicate_of": primary.ID, - }).Error; err != nil { - d.log.Warn("dup mark failed", zap.Error(err)) - continue - } - markedIDs[m.ID] = struct{}{} - rep.ItemsMarked++ - } - if len(dupes) == 0 { - return - } - rep.Groups = append(rep.Groups, Group{ - Hash: key, - Primary: primary, - Duplicates: dupes, - }) -} - -func groupByExternalIdentity(rows []model.Media) map[string][]model.Media { - groups := map[string][]model.Media{} - for _, row := range rows { - key := mediaExternalIdentityKey(row) - if key == "" { - continue - } - groups[key] = append(groups[key], row) - } - return groups -} - -func mediaExternalIdentityKey(row model.Media) string { - var key string - switch { - case row.TMDbID > 0: - key = fmt.Sprintf("tmdb:%d", row.TMDbID) - case row.BangumiID > 0: - key = fmt.Sprintf("bangumi:%d", row.BangumiID) - case row.DoubanID != "": - key = "douban:" + row.DoubanID - case row.TheTVDBID != "": - key = "thetvdb:" + row.TheTVDBID - default: - return "" - } - if row.SeasonNum > 0 || row.EpisodeNum > 0 { - key += fmt.Sprintf(":s%d:e%d", row.SeasonNum, row.EpisodeNum) - } - return key -} - -// pickPrimary picks the "best" media row to keep: prefer scraped > size > id. -func pickPrimary(group []model.Media) model.Media { - sort.SliceStable(group, func(i, j int) bool { - ai, aj := group[i].ScrapeStatus == "matched", group[j].ScrapeStatus == "matched" - if ai != aj { - return ai - } - if group[i].SizeBytes != group[j].SizeBytes { - return group[i].SizeBytes > group[j].SizeBytes - } - return group[i].ID < group[j].ID - }) - return group[0] -} diff --git a/internal/service/duplicate_hash.go b/internal/service/duplicate_hash.go deleted file mode 100644 index 9847fda..0000000 --- a/internal/service/duplicate_hash.go +++ /dev/null @@ -1,62 +0,0 @@ -// Package service — duplicate sparse file hashing. -package service - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "os" -) - -const sampleSize = 1 << 20 // 1 MiB per sample window - -// SparseFileHash computes the head+mid+tail SHA-256 of a file, suffixed with -// the file size so two files that happen to collide on the sample window -// but differ in length are still distinguishable. -func SparseFileHash(path string) (string, error) { - if path == "" { - return "", errors.New("empty path") - } - f, err := os.Open(path) // #nosec G304 -- path is selected from configured media library files for duplicate detection. - if err != nil { - return "", err - } - defer f.Close() - st, err := f.Stat() - if err != nil { - return "", err - } - size := st.Size() - h := sha256.New() - if size <= int64(sampleSize)*3 { - if _, err := io.Copy(h, f); err != nil { - return "", err - } - return fmt.Sprintf("%s-%d", hex.EncodeToString(h.Sum(nil)), size), nil - } - buf := make([]byte, sampleSize) - // head - if _, err := io.ReadFull(f, buf); err != nil { - return "", err - } - h.Write(buf) - // middle - if _, err := f.Seek(size/2-int64(sampleSize)/2, io.SeekStart); err != nil { - return "", err - } - if _, err := io.ReadFull(f, buf); err != nil { - return "", err - } - h.Write(buf) - // tail - if _, err := f.Seek(size-int64(sampleSize), io.SeekStart); err != nil { - return "", err - } - if _, err := io.ReadFull(f, buf); err != nil { - return "", err - } - h.Write(buf) - return fmt.Sprintf("%s-%d", hex.EncodeToString(h.Sum(nil)), size), nil -} diff --git a/internal/service/duplicate_test.go b/internal/service/duplicate_test.go deleted file mode 100644 index c0c2a9a..0000000 --- a/internal/service/duplicate_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package service - -import ( - "path/filepath" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestDuplicateDetectMarksExternalIdentityDuplicates(t *testing.T) { - repos := newOrganizerTestRepo(t) - root := t.TempDir() - firstPath := filepath.Join(root, "show-a.mkv") - secondPath := filepath.Join(root, "show-b.mkv") - writeOrgFile(t, firstPath, "first-release") - writeOrgFile(t, secondPath, "second-release") - - lib := model.Library{Name: "剧集", Path: root, Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - first := model.Media{ - LibraryID: lib.ID, - Title: "间谍过家家", - Path: firstPath, - SizeBytes: 13, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 12345, - ScrapeStatus: "matched", - } - second := model.Media{ - LibraryID: lib.ID, - Title: "Spy Family", - Path: secondPath, - SizeBytes: 14, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 12345, - ScrapeStatus: "matched", - } - if err := repos.DB.Create(&first).Error; err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&second).Error; err != nil { - t.Fatal(err) - } - - report, err := NewDuplicateService(zap.NewNop(), repos, nil).Detect(t.Context(), lib.ID) - if err != nil { - t.Fatal(err) - } - if report.ItemsMarked != 1 || report.GroupsFound != 1 { - t.Fatalf("report = %#v, want one external identity duplicate", report) - } - var rows []model.Media - if err := repos.DB.Find(&rows).Error; err != nil { - t.Fatal(err) - } - marked := 0 - for _, row := range rows { - if row.IsDuplicate && row.DuplicateOf != "" { - marked++ - } - } - if marked != 1 { - t.Fatalf("marked duplicate rows = %d, want 1; rows=%#v", marked, rows) - } -} diff --git a/internal/service/emby_cloud_library_merge_test.go b/internal/service/emby_cloud_library_merge_test.go deleted file mode 100644 index 745f5fd..0000000 --- a/internal/service/emby_cloud_library_merge_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package service - -import ( - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestEmbyViewsMergeEpisodicCloudLibrariesIntoUserLibrary(t *testing.T) { - svc := newTestEmbyService(t) - local := model.Library{Name: "国漫", Path: "/media/动漫/国漫", Type: "tv", Enabled: true} - cloud := model.Library{Name: "OpenList · 国漫", Path: BuildCloudLibraryPath("openlist", "/国漫", "/国漫"), Type: "anime", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud} { - if err := svc.repo.Library.Create(t.Context(), lib); err != nil { - t.Fatalf("create library: %v", err) - } - } - if err := svc.repo.DB.Create(&model.Media{ - Base: model.Base{ID: "cloud-show-1"}, - LibraryID: cloud.ID, - Title: "云盘国漫", - Path: "cloud://openlist/国漫/云盘国漫/Season 01/云盘国漫.S01E01.mkv", - SeasonNum: 1, - EpisodeNum: 1, - }).Error; err != nil { - t.Fatalf("create media: %v", err) - } - - views, err := svc.Views(t.Context(), "user-1") - if err != nil { - t.Fatalf("views: %v", err) - } - viewItems := views["Items"].([]map[string]any) - if len(viewItems) != 1 { - t.Fatalf("emby views = %#v, want one merged user-facing library", viewItems) - } - if viewItems[0]["Id"] != local.ID || viewItems[0]["Name"] != "国漫" { - t.Fatalf("merged view should use local library identity, got %#v", viewItems[0]) - } - - items, err := svc.Items(t.Context(), ItemsParams{ParentID: local.ID, Recursive: true, IncludeItemTypes: []string{"Episode"}, Limit: 50}) - if err != nil { - t.Fatalf("items: %v", err) - } - episodes := items["Items"].([]map[string]any) - if len(episodes) != 1 || episodes[0]["Id"] != "cloud-show-1" { - t.Fatalf("merged local library should include cloud episodes, got %#v", episodes) - } -} - -func TestEmbyViewsMergeCloudCategoryAliasesIntoUserLibrary(t *testing.T) { - svc := newTestEmbyService(t) - local := model.Library{Name: "日番", Path: "/media/动漫/日番", Type: "tv", Enabled: true} - cloud := model.Library{Name: "OpenList · 日漫", Path: BuildCloudLibraryPath("openlist", "/日漫", "/日漫"), Type: "anime", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud} { - if err := svc.repo.Library.Create(t.Context(), lib); err != nil { - t.Fatalf("create library: %v", err) - } - } - if err := svc.repo.DB.Create(&model.Media{ - Base: model.Base{ID: "cloud-anime-1"}, - LibraryID: cloud.ID, - Title: "云盘日漫", - Path: "cloud://openlist/日漫/云盘日漫/Season 01/云盘日漫.S01E01.mkv", - SeasonNum: 1, - EpisodeNum: 1, - }).Error; err != nil { - t.Fatalf("create media: %v", err) - } - - views, err := svc.Views(t.Context(), "user-1") - if err != nil { - t.Fatalf("views: %v", err) - } - viewItems := views["Items"].([]map[string]any) - if len(viewItems) != 1 || viewItems[0]["Id"] != local.ID || viewItems[0]["Name"] != "日番" { - t.Fatalf("emby views = %#v, want cloud alias merged into local 日番", viewItems) - } - - items, err := svc.Items(t.Context(), ItemsParams{ParentID: local.ID, Recursive: true, IncludeItemTypes: []string{"Episode"}, Limit: 50}) - if err != nil { - t.Fatalf("items: %v", err) - } - episodes := items["Items"].([]map[string]any) - if len(episodes) != 1 || episodes[0]["Id"] != "cloud-anime-1" { - t.Fatalf("merged local 日番 library should include 日漫 cloud episodes, got %#v", episodes) - } -} diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index fcf395d..b024b3e 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -19,7 +19,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/repository" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" "go.uber.org/zap" ) @@ -47,8 +46,6 @@ type EmbyService struct { cfg *config.Config log *zap.Logger repo *repository.Container - storage cloudPlaybackResolver - probe cloudPlaybackProber cache *RuntimeCacheService subtitle *SubtitleService @@ -60,21 +57,10 @@ type EmbyService struct { visibilityMu sync.RWMutex visibilityCache map[string]embyVisibilityCacheEntry - cloudProbeMu sync.Mutex - cloudProbeInFlight map[string]struct{} - libraryCoverMu sync.Mutex libraryCoverCache map[string]embyArtworkCacheEntry } -type cloudPlaybackResolver interface { - CloudResolve(ctx context.Context, typ, fileRef, clientUA string) (*cloud.DirectLink, error) -} - -type cloudPlaybackProber interface { - ProbeHTTP(ctx context.Context, rawURL string, headers map[string]string) (*ProbeResult, error) -} - // NewEmbyService is the constructor. func NewEmbyService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *EmbyService { return &EmbyService{cfg: cfg, log: log, repo: repo} @@ -87,14 +73,6 @@ func (e *EmbyService) SetRuntimeCache(cache *RuntimeCacheService) *EmbyService { return e } -func (e *EmbyService) SetCloudProbe(storage cloudPlaybackResolver, probe cloudPlaybackProber) { - if e == nil { - return - } - e.storage = storage - e.probe = probe -} - // SetSubtitleService wires the external-subtitle discovery service into the // Emby shim so MediaStreams can advertise sideloaded subtitle tracks. It is // nil-safe: when subtitle is nil, mediaStreams simply emits no subtitle diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go index 2d321be..2dd036e 100644 --- a/internal/service/emby_compat_test.go +++ b/internal/service/emby_compat_test.go @@ -1,7 +1,6 @@ package service import ( - "context" "testing" "time" @@ -10,7 +9,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/repository" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" ) func newTestEmbyService(t *testing.T) *EmbyService { @@ -67,29 +65,3 @@ func TestEmbyLatestItemsOrderByReleaseDate(t *testing.T) { t.Fatalf("latest item should expose PremiereDate for Emby clients: %#v", items[0]) } } - -type fakeCloudPlaybackResolver struct { - link *cloud.DirectLink - typ string - ref string - ua string -} - -func (f *fakeCloudPlaybackResolver) CloudResolve(_ context.Context, typ, fileRef, clientUA string) (*cloud.DirectLink, error) { - f.typ = typ - f.ref = fileRef - f.ua = clientUA - return f.link, nil -} - -type fakeCloudPlaybackProber struct { - probe *ProbeResult - rawURL string - headers map[string]string -} - -func (f *fakeCloudPlaybackProber) ProbeHTTP(_ context.Context, rawURL string, headers map[string]string) (*ProbeResult, error) { - f.rawURL = rawURL - f.headers = headers - return f.probe, nil -} diff --git a/internal/service/emby_playback.go b/internal/service/emby_playback.go index e33a679..5f84715 100644 --- a/internal/service/emby_playback.go +++ b/internal/service/emby_playback.go @@ -10,8 +10,6 @@ import ( "strings" "time" - "go.uber.org/zap" - "github.com/ShukeBta/MediaStationGo/internal/model" ) @@ -21,7 +19,6 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string) if err != nil || m == nil { return nil, err } - e.ensureCloudTrackMetadata(ctx, m) return map[string]any{ "MediaSources": e.mediaSourcesForItem(ctx, m, false, e.directPlayOnly(ctx)), "PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()), @@ -108,60 +105,6 @@ var ( // 拖长到秒级,又让每一次点开详情/起播都可能触发一次云盘数据下载,是 // Docker 部署下 CPU/带宽长期居高的来源之一。探测结果落库后,下一次 // 请求自然能读到完整元数据。 -func (e *EmbyService) ensureCloudTrackMetadata(ctx context.Context, m *model.Media) { - if e == nil || m == nil || e.storage == nil || e.probe == nil || !mediaTrackMetadataMissing(m) { - return - } - typ, ref, ok := parseCloudMediaPlaybackURL(m.STRMURL) - if !ok { - return - } - mediaID := m.ID - e.cloudProbeMu.Lock() - if e.cloudProbeInFlight == nil { - e.cloudProbeInFlight = make(map[string]struct{}) - } - if _, busy := e.cloudProbeInFlight[mediaID]; busy { - e.cloudProbeMu.Unlock() - return - } - e.cloudProbeInFlight[mediaID] = struct{}{} - e.cloudProbeMu.Unlock() - - go e.probeCloudTrackMetadata(mediaID, typ, ref) -} - -func (e *EmbyService) probeCloudTrackMetadata(mediaID, typ, ref string) { - defer func() { - e.cloudProbeMu.Lock() - delete(e.cloudProbeInFlight, mediaID) - e.cloudProbeMu.Unlock() - }() - probeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - link, err := e.storage.CloudResolve(probeCtx, typ, ref, "") - if err != nil { - if e.log != nil { - e.log.Debug("resolve cloud media for playback probe failed", zap.String("media_id", mediaID), zap.Error(err)) - } - return - } - probe, err := e.probe.ProbeHTTP(probeCtx, link.URL, link.Headers) - if err != nil { - if e.log != nil { - e.log.Debug("playback cloud ffprobe failed", zap.String("media_id", mediaID), zap.Error(err)) - } - return - } - updates := probeResultUpdates(probe) - if len(updates) == 0 { - return - } - if err := e.repo.DB.WithContext(probeCtx).Model(&model.Media{}).Where("id = ?", mediaID).Updates(updates).Error; err != nil && e.log != nil { - e.log.Debug("persist playback cloud probe failed", zap.String("media_id", mediaID), zap.Error(err)) - } -} - func mediaTrackMetadataMissing(m *model.Media) bool { return m.DurationSec <= 0 || m.Width <= 0 || diff --git a/internal/service/emby_playback_test.go b/internal/service/emby_playback_test.go index bc52251..6a4b6ee 100644 --- a/internal/service/emby_playback_test.go +++ b/internal/service/emby_playback_test.go @@ -5,7 +5,6 @@ import ( "time" "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" ) func TestEmbyRootItemsExposeLibraries(t *testing.T) { @@ -303,165 +302,3 @@ func TestEmbyPlaybackInfoRespectsDirectPlayOnly(t *testing.T) { t.Fatalf("direct-only must still allow direct play: %#v", src) } } - -func TestEmbyPlaybackInfoKeepsSTRMBehindStreamEndpoint(t *testing.T) { - svc := newTestEmbyService(t) - if err := svc.repo.Setting.Set(t.Context(), CloudPlaybackModeSettingKey, CloudPlaybackModeSTRM); err != nil { - t.Fatalf("set cloud playback mode: %v", err) - } - lib := model.Library{Name: "OpenList", Path: `cloud://openlist/Movies`, Type: "movie", Enabled: true} - if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { - t.Fatalf("create library: %v", err) - } - media := model.Media{ - Base: model.Base{ID: "cloud-1"}, - LibraryID: lib.ID, - Title: "Cloud Movie", - Path: `cloud://openlist/Movies/f1.mkv`, - STRMURL: `/api/cloud/play/openlist?ref=%2FMovies%2Ff1.mkv`, - } - if err := svc.repo.DB.Create(&media).Error; err != nil { - t.Fatalf("create media: %v", err) - } - - pb, err := svc.PlaybackInfo(t.Context(), "cloud-1", "user-1") - if err != nil { - t.Fatalf("playback info: %v", err) - } - src := pb["MediaSources"].([]map[string]any)[0] - if src["IsRemote"] != true { - t.Fatalf("strm media should be marked remote: %#v", src) - } - if src["DirectStreamUrl"] != "/api/stream/cloud-1" { - t.Fatalf("strm playback should prefer /api/stream when enabled: %#v", src) - } - if src["Path"] != "/Movies/f1.mkv" { - t.Fatalf("path should expose the OpenList source path: %#v", src) - } - streams := src["MediaStreams"].([]map[string]any) - if len(streams) == 0 || streams[0]["Type"] != "Video" { - t.Fatalf("strm media should expose a fallback video stream for Android clients: %#v", src) - } -} - -func TestEmbyPlaybackInfoUsesVideoStreamWhenSTRMDisabled(t *testing.T) { - svc := newTestEmbyService(t) - if err := svc.repo.Setting.Set(t.Context(), CloudPlaybackModeSettingKey, CloudPlaybackModeRedirectProxy); err != nil { - t.Fatalf("set cloud playback mode: %v", err) - } - lib := model.Library{Name: "OpenList", Path: `cloud://openlist/Movies`, Type: "movie", Enabled: true} - if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { - t.Fatalf("create library: %v", err) - } - media := model.Media{ - Base: model.Base{ID: "cloud-302"}, - LibraryID: lib.ID, - Title: "Cloud 302 Movie", - Path: `cloud://openlist/Movies/Movie.mkv`, - STRMURL: `/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv`, - Container: "mkv", - } - if err := svc.repo.DB.Create(&media).Error; err != nil { - t.Fatalf("create media: %v", err) - } - - pb, err := svc.PlaybackInfo(t.Context(), "cloud-302", "user-1") - if err != nil { - t.Fatalf("playback info: %v", err) - } - src := pb["MediaSources"].([]map[string]any)[0] - if src["DirectStreamUrl"] != "/Videos/cloud-302/stream.mkv" { - t.Fatalf("302/proxy mode should use Emby video stream URL: %#v", src) - } - if src["Path"] != "/Movies/Movie.mkv" { - t.Fatalf("302/proxy mode should preserve the OpenList source path: %#v", src) - } -} - -func TestEmbyMediaSourcePathFallsBackToCloudDisplayPath(t *testing.T) { - media := &model.Media{ - Path: `cloud://cloud115/Shows/My%20Show/S01E01.mkv`, - STRMURL: `/api/cloud/play/cloud115?ref=opaque-file-id`, - } - if got := embyMediaSourcePath(media); got != "/Shows/My Show/S01E01.mkv" { - t.Fatalf("source path = %q", got) - } - - local := &model.Media{Path: `D:\\Media\\Movie.mkv`, STRMURL: `https://example.test/movie.mkv`} - if got := embyMediaSourcePath(local); got != local.Path { - t.Fatalf("local path = %q, want %q", got, local.Path) - } -} - -func TestEmbyPlaybackInfoProbesMissingCloudTrackMetadata(t *testing.T) { - svc := newTestEmbyService(t) - lib := model.Library{Name: "OpenList", Path: `cloud://openlist/Movies`, Type: "movie", Enabled: true} - if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { - t.Fatalf("create library: %v", err) - } - media := model.Media{ - Base: model.Base{ID: "cloud-probe-1"}, - LibraryID: lib.ID, - Title: "云盘电影", - Path: `cloud://openlist/Movies/Movie.mkv`, - STRMURL: `http://nas.local/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv`, - } - if err := svc.repo.DB.Create(&media).Error; err != nil { - t.Fatalf("create media: %v", err) - } - resolver := &fakeCloudPlaybackResolver{ - link: &cloud.DirectLink{ - URL: "http://cdn.example.test/Movie.mkv", - Headers: map[string]string{"Authorization": "Bearer probe-token"}, - }, - } - prober := &fakeCloudPlaybackProber{ - probe: &ProbeResult{ - DurationSec: 3661, - Width: 3840, - Height: 2160, - VideoCodec: "hevc", - AudioCodec: "eac3", - Container: "matroska,webm", - }, - } - svc.SetCloudProbe(resolver, prober) - - if _, err := svc.PlaybackInfo(t.Context(), "cloud-probe-1", "user-1"); err != nil { - t.Fatalf("playback info: %v", err) - } - - var persisted model.Media - deadline := time.Now().Add(3 * time.Second) - for { - if err := svc.repo.DB.First(&persisted, "id = ?", "cloud-probe-1").Error; err != nil { - t.Fatalf("reload media: %v", err) - } - if persisted.DurationSec > 0 || time.Now().After(deadline) { - break - } - time.Sleep(10 * time.Millisecond) - } - if persisted.DurationSec != 3661 || persisted.Width != 3840 || persisted.Height != 2160 || persisted.VideoCodec != "hevc" || persisted.AudioCodec != "eac3" { - t.Fatalf("probe metadata not persisted: %#v", persisted) - } - if resolver.typ != "openlist" || resolver.ref != "/Movies/Movie.mkv" { - t.Fatalf("resolver called with typ=%q ref=%q", resolver.typ, resolver.ref) - } - if prober.rawURL != "http://cdn.example.test/Movie.mkv" || prober.headers["Authorization"] != "Bearer probe-token" { - t.Fatalf("probe called with url=%q headers=%#v", prober.rawURL, prober.headers) - } - - pb, err := svc.PlaybackInfo(t.Context(), "cloud-probe-1", "user-1") - if err != nil { - t.Fatalf("playback info (second): %v", err) - } - src := pb["MediaSources"].([]map[string]any)[0] - if src["RunTimeTicks"] != int64(3661)*10_000_000 { - t.Fatalf("runtime ticks not filled after async probe: %#v", src) - } - streams := src["MediaStreams"].([]map[string]any) - if len(streams) != 2 || streams[0]["Codec"] != "hevc" || streams[1]["Codec"] != "eac3" { - t.Fatalf("media streams not filled after async probe: %#v", streams) - } -} diff --git a/internal/service/emby_versions_test.go b/internal/service/emby_versions_test.go index 2fb7954..2a73ae0 100644 --- a/internal/service/emby_versions_test.go +++ b/internal/service/emby_versions_test.go @@ -7,107 +7,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/model" ) -func TestEmbyLatestItemsIncludesMergedCloudMovieLibrary(t *testing.T) { - svc := newTestEmbyService(t) - local := model.Library{Name: "国产电影", Path: `/media/国产电影`, Type: "movie", Enabled: true} - cloud := model.Library{Name: "OpenList · 国产电影", Path: BuildCloudLibraryPath("openlist", "/国产电影", "/国产电影"), Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud} { - if err := svc.repo.Library.Create(t.Context(), lib); err != nil { - t.Fatalf("create library: %v", err) - } - } - for _, media := range []model.Media{ - { - Base: model.Base{ID: "local-movie", CreatedAt: time.Now().Add(-time.Minute)}, - LibraryID: local.ID, - Title: "本地版本", - Path: `/media/国产电影/local.mkv`, - }, - { - Base: model.Base{ID: "cloud-movie", CreatedAt: time.Now()}, - LibraryID: cloud.ID, - Title: "云盘版本", - Path: `cloud://openlist/国产电影/cloud.mkv`, - }, - } { - if err := svc.repo.DB.Create(&media).Error; err != nil { - t.Fatalf("create media: %v", err) - } - } - - latest, err := svc.LatestItems(t.Context(), "user-1", local.ID, 10) - if err != nil { - t.Fatalf("latest items: %v", err) - } - if len(latest) != 2 { - t.Fatalf("latest items = %#v, want local and merged cloud media", latest) - } - if latest[0]["Id"] != "cloud-movie" || latest[1]["Id"] != "local-movie" { - t.Fatalf("latest order/items = %#v, want cloud then local", latest) - } -} - -func TestEmbyMergedLocalCloudMovieVersionsShareMediaSources(t *testing.T) { - svc := newTestEmbyService(t) - local := model.Library{Name: "国产电影", Path: `/media/国产电影`, Type: "movie", Enabled: true} - cloud := model.Library{Name: "OpenList · 国产电影", Path: BuildCloudLibraryPath("openlist", "/国产电影", "/国产电影"), Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud} { - if err := svc.repo.Library.Create(t.Context(), lib); err != nil { - t.Fatalf("create library: %v", err) - } - } - for _, media := range []model.Media{ - { - Base: model.Base{ID: "local-version", CreatedAt: time.Now()}, - LibraryID: local.ID, - Title: "流浪地球", - Year: 2019, - Path: `/media/国产电影/流浪地球.2019.1080p.mkv`, - Container: "mkv", - Width: 1920, - }, - { - Base: model.Base{ID: "cloud-version", CreatedAt: time.Now().Add(time.Minute)}, - LibraryID: cloud.ID, - Title: "流浪地球", - Year: 2019, - Path: `cloud://openlist/国产电影/流浪地球.2019.2160p.mkv`, - Container: "mkv", - STRMURL: "https://example.invalid/cloud", - Width: 3840, - }, - } { - if err := svc.repo.DB.Create(&media).Error; err != nil { - t.Fatalf("create media: %v", err) - } - } - - items, err := svc.Items(t.Context(), ItemsParams{ParentID: local.ID, IncludeItemTypes: []string{"Movie"}, Recursive: true, Limit: 10}) - if err != nil { - t.Fatalf("items: %v", err) - } - rows := items["Items"].([]map[string]any) - if len(rows) != 1 { - t.Fatalf("merged local/cloud versions should show as one item, got %#v", rows) - } - if rows[0]["Id"] != "local-version" { - t.Fatalf("local media should be the representative item, got %#v", rows[0]) - } - sources := rows[0]["MediaSources"].([]map[string]any) - if len(sources) != 2 { - t.Fatalf("merged item should expose two media sources, got %#v", sources) - } - - playback, err := svc.PlaybackInfo(t.Context(), "local-version", "user-1") - if err != nil { - t.Fatalf("playback: %v", err) - } - playSources := playback["MediaSources"].([]map[string]any) - if len(playSources) != 2 { - t.Fatalf("playback should expose local and cloud versions, got %#v", playSources) - } -} - func TestEmbyLatestItemsCollapsesMovieVersions(t *testing.T) { svc := newTestEmbyService(t) lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true} diff --git a/internal/service/external_search_test.go b/internal/service/external_search_test.go index f4741a2..4f9c07c 100644 --- a/internal/service/external_search_test.go +++ b/internal/service/external_search_test.go @@ -2,6 +2,15 @@ package service import "testing" +func containsString(values []string, want string) bool { + for _, v := range values { + if v == want { + return true + } + } + return false +} + func TestBuildSubscribeKeyword(t *testing.T) { if got := buildSubscribeKeyword("沙丘", 2024); got != "沙丘 2024" { t.Fatalf("keyword = %q", got) diff --git a/internal/service/image_proxy_cloud.go b/internal/service/image_proxy_cloud.go deleted file mode 100644 index 95afffe..0000000 --- a/internal/service/image_proxy_cloud.go +++ /dev/null @@ -1,151 +0,0 @@ -package service - -import ( - "bytes" - "context" - "errors" - "io" - "net/http" - "os" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -// CloudImageCached reports whether a stable cloud-image ref already has a -// usable positive or short-lived negative cache entry. Scanner pre-warm uses it -// to avoid repeatedly resolving the same cloud sidecar image. -func (p *ImageProxy) CloudImageCached(stableKey string) bool { - if p == nil { - return false - } - _, cachePath, failPath := p.cloudImageCachePaths(stableKey) - if data, err := os.ReadFile(cachePath); err == nil { - if _, ok := validImageContentType(data); ok { - return true - } - _ = os.Remove(cachePath) - _ = os.Remove(failPath) - } - return freshNegativeImageCache(failPath) -} - -// ServeCloudCached serves an already-local cloud sidecar image without asking -// the cloud provider for a fresh direct link. It returns true when it wrote a -// response, including a fresh negative-cache placeholder. -func (p *ImageProxy) ServeCloudCached(w http.ResponseWriter, r *http.Request, stableKey string) bool { - if p == nil { - return false - } - key, cachePath, failPath := p.cloudImageCachePaths(stableKey) - p.removeUnusableImageCache(cachePath, failPath) - if serveCachedImageFile(w, r, key, cachePath) { - return true - } - if freshNegativeImageCache(failPath) { - serveCachedPlaceholder(w) - return true - } - return false -} - -// ServeCloudResolved stores a cloud sidecar image in the same disk cache used -// by remote posters, then serves it with long browser-cache headers. -func (p *ImageProxy) ServeCloudResolved(ctx context.Context, w http.ResponseWriter, r *http.Request, stableKey string, link *cloud.DirectLink) error { - if p == nil || link == nil || strings.TrimSpace(link.URL) == "" { - servePlaceholder(w) - return nil - } - stableKey = strings.TrimSpace(stableKey) - if stableKey == "" { - stableKey = link.URL - } - key, cachePath, failPath := p.cloudImageCachePaths(stableKey) - p.removeUnusableImageCache(cachePath, failPath) - if serveCachedImageFile(w, r, key, cachePath) { - return nil - } - if freshNegativeImageCache(failPath) { - serveCachedPlaceholder(w) - return nil - } - data, ctype, err := p.fetchAndCacheCloudImage(ctx, stableKey, link, r.UserAgent()) - if err != nil { - p.log.Warn("imageproxy: cloud image fetch failed", zap.String("url", link.URL), zap.Error(err)) - serveCachedPlaceholder(w) - return nil - } - w.Header().Set("Content-Type", ctype) - w.Header().Set("Cache-Control", imageBrowserCacheControl) - modTime := time.Now() - if stat, err := os.Stat(cachePath); err == nil && stat.Size() > 0 { - modTime = stat.ModTime() - w.Header().Set("ETag", imageFileETag(key, stat)) - } - http.ServeContent(w, r, key, modTime, bytes.NewReader(data)) - return nil -} - -// PrefetchCloudResolved downloads a cloud sidecar image into the local cache -// without writing an HTTP response. -func (p *ImageProxy) PrefetchCloudResolved(ctx context.Context, stableKey string, link *cloud.DirectLink) error { - if p == nil || link == nil || strings.TrimSpace(link.URL) == "" { - return nil - } - if p.CloudImageCached(stableKey) { - return nil - } - _, _, err := p.fetchAndCacheCloudImage(ctx, stableKey, link, "MediaStationGo/0.1") - return err -} - -func (p *ImageProxy) fetchAndCacheCloudImage(ctx context.Context, stableKey string, link *cloud.DirectLink, userAgent string) ([]byte, string, error) { - if err := os.MkdirAll(p.cacheDir, 0o750); err != nil { - return nil, "", err - } - _, cachePath, failPath := p.cloudImageCachePaths(stableKey) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil) - if err != nil { - return nil, "", err - } - for k, v := range link.Headers { - req.Header.Set(k, v) - } - if req.Header.Get("User-Agent") == "" { - if strings.TrimSpace(userAgent) != "" { - req.Header.Set("User-Agent", userAgent) - } else { - req.Header.Set("User-Agent", "MediaStationGo/0.1") - } - } - req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") - resp, err := p.client.Do(req) - if err != nil { - p.markImageFetchFailed(failPath) - return nil, "", err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - p.markImageFetchFailed(failPath) - return nil, "", errors.New("cloud image returned " + resp.Status) - } - data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) - if err != nil { - p.markImageFetchFailed(failPath) - return nil, "", err - } - if len(data) == 0 { - p.markImageFetchFailed(failPath) - return nil, "", errors.New("cloud image body is empty") - } - ctype, ok := validImageContentType(data) - if !ok { - p.markImageFetchFailed(failPath) - return nil, "", errors.New("cloud image returned non-image content") - } - p.writeImageCache(cachePath, failPath, "img-cloud-*.tmp", data) - return data, ctype, nil -} diff --git a/internal/service/image_proxy_cloud_test.go b/internal/service/image_proxy_cloud_test.go deleted file mode 100644 index 59c545b..0000000 --- a/internal/service/image_proxy_cloud_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package service - -import ( - "bytes" - "errors" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -func TestImageProxyCachesCloudResolvedImage(t *testing.T) { - var calls int32 - proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop()) - proxy.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) { - atomic.AddInt32(&calls, 1) - return &http.Response{ - StatusCode: http.StatusOK, - Status: "200 OK", - Header: http.Header{"Content-Type": []string{"image/png"}}, - Body: io.NopCloser(bytes.NewReader(testJPEG)), - Request: req, - }, nil - })} - - link := &cloud.DirectLink{URL: "http://cloud-provider.invalid/poster.png"} - if proxy.ServeCloudCached(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/cloud/play/openlist?ref=poster.png", nil), "openlist:poster.png") { - t.Fatal("ServeCloudCached returned true before the cloud image was cached") - } - for i := 0; i < 2; i++ { - rec := httptest.NewRecorder() - if err := proxy.ServeCloudResolved(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/cloud/play/openlist?ref=poster.png", nil), "openlist:poster.png", link); err != nil { - t.Fatal(err) - } - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - if got := rec.Header().Get("Cache-Control"); got != imageBrowserCacheControl { - t.Fatalf("Cache-Control = %q, want %q", got, imageBrowserCacheControl) - } - } - if got := atomic.LoadInt32(&calls); got != 1 { - t.Fatalf("upstream calls = %d, want 1 due to cloud image cache", got) - } - - rec := httptest.NewRecorder() - if !proxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/play/openlist?ref=poster.png", nil), "openlist:poster.png") { - t.Fatal("ServeCloudCached returned false after the cloud image was cached") - } - if got := atomic.LoadInt32(&calls); got != 1 { - t.Fatalf("upstream calls after ServeCloudCached = %d, want 1", got) - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached body = %x, want cached cloud image", got) - } -} - -func TestImageProxyPrefetchCloudResolvedImage(t *testing.T) { - var calls int32 - proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop()) - proxy.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) { - atomic.AddInt32(&calls, 1) - return &http.Response{ - StatusCode: http.StatusOK, - Status: "200 OK", - Header: http.Header{"Content-Type": []string{"image/png"}}, - Body: io.NopCloser(bytes.NewReader(testJPEG)), - Request: req, - }, nil - })} - - link := &cloud.DirectLink{URL: "http://cloud-provider.invalid/folder.png"} - if err := proxy.PrefetchCloudResolved(t.Context(), "openlist:folder.png", link); err != nil { - t.Fatal(err) - } - if err := proxy.PrefetchCloudResolved(t.Context(), "openlist:folder.png", link); err != nil { - t.Fatal(err) - } - if got := atomic.LoadInt32(&calls); got != 1 { - t.Fatalf("upstream calls = %d, want 1 after prefetch cache hit", got) - } - rec := httptest.NewRecorder() - if !proxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/play/openlist?ref=folder.png", nil), "openlist:folder.png") { - t.Fatal("prefetched cloud image was not served from cache") - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached body = %x, want prefetched cloud image", got) - } -} - -func TestImageProxyPrefetchCloudResolvedRefetchesInvalidCache(t *testing.T) { - var calls int32 - proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop()) - proxy.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) { - atomic.AddInt32(&calls, 1) - return &http.Response{ - StatusCode: http.StatusOK, - Status: "200 OK", - Header: http.Header{"Content-Type": []string{"image/jpeg"}}, - Body: io.NopCloser(bytes.NewReader(testJPEG)), - Request: req, - }, nil - })} - - stableKey := "openlist:bad-cache-poster.jpg" - _, cachePath, failPath := proxy.cloudImageCachePaths(stableKey) - if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(cachePath, []byte("old bad cache"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(failPath, []byte("failed"), 0o600); err != nil { - t.Fatal(err) - } - - link := &cloud.DirectLink{URL: "http://cloud-provider.invalid/bad-cache-poster.jpg"} - if err := proxy.PrefetchCloudResolved(t.Context(), stableKey, link); err != nil { - t.Fatal(err) - } - if got := atomic.LoadInt32(&calls); got != 1 { - t.Fatalf("upstream calls = %d, want 1 after invalid cache cleanup", got) - } - rec := httptest.NewRecorder() - if !proxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/play/openlist?ref=bad-cache-poster.jpg", nil), stableKey) { - t.Fatal("refetched cloud image was not served from cache") - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached body = %x, want refetched cloud image", got) - } -} - -func TestImageProxyServeCloudCachedSkipsInvalidCache(t *testing.T) { - proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop()) - stableKey := "openlist:invalid-cached-poster.jpg" - _, cachePath, failPath := proxy.cloudImageCachePaths(stableKey) - if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(cachePath, transparent1x1PNG, 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(failPath, []byte("failed"), 0o600); err != nil { - t.Fatal(err) - } - - rec := httptest.NewRecorder() - if proxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/play/openlist?ref=invalid-cached-poster.jpg", nil), stableKey) { - t.Fatal("ServeCloudCached should skip invalid cached cloud artwork") - } - if _, err := os.Stat(cachePath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("invalid cache should be removed, stat err=%v", err) - } - if _, err := os.Stat(failPath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("stale fail marker should be removed with invalid cache, stat err=%v", err) - } -} diff --git a/internal/service/license.go b/internal/service/license.go deleted file mode 100644 index 63eb145..0000000 --- a/internal/service/license.go +++ /dev/null @@ -1,81 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "math" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -const ( - OpenSourceUserLimit = 20 - LicensedUserLimit = 100 - - LicenseSettingActivation = "license.activation" -) - -type LicenseActivationState struct { - Valid bool `json:"valid"` - LicenseKey string `json:"license_key,omitempty"` - LicenseType string `json:"license_type,omitempty"` - ExpiryDate string `json:"expiry_date,omitempty"` - MaxDevices int `json:"max_devices,omitempty"` - MaxUsers *int `json:"max_users,omitempty"` - UnlimitedUsers bool `json:"unlimited_users,omitempty"` - DaysRemaining *int `json:"days_remaining,omitempty"` - NextHeartbeat string `json:"next_heartbeat,omitempty"` - DeviceID string `json:"device_id,omitempty"` - DeviceName string `json:"device_name,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` -} - -func LicensedMaxUsers(ctx context.Context, repos *repository.Container) int64 { - state, ok := loadLicenseActivationState(ctx, repos) - if ok && state.Valid && !licenseExpired(state.ExpiryDate) { - if state.UnlimitedUsers { - return math.MaxInt64 - } - if state.MaxUsers != nil && *state.MaxUsers > 0 { - return int64(*state.MaxUsers) - } - return LicensedUserLimit - } - return OpenSourceUserLimit -} - -func LicenseActive(ctx context.Context, repos *repository.Container) bool { - if repos == nil || repos.Setting == nil { - return false - } - state, ok := loadLicenseActivationState(ctx, repos) - return ok && state.Valid && !licenseExpired(state.ExpiryDate) -} - -func loadLicenseActivationState(ctx context.Context, repos *repository.Container) (LicenseActivationState, bool) { - if repos == nil || repos.Setting == nil { - return LicenseActivationState{}, false - } - raw, err := repos.Setting.Get(ctx, LicenseSettingActivation) - if err != nil || raw == "" { - return LicenseActivationState{}, false - } - var state LicenseActivationState - if err := json.Unmarshal([]byte(raw), &state); err != nil { - return LicenseActivationState{}, false - } - return state, true -} - -func licenseExpired(expiry string) bool { - if expiry == "" { - return false - } - for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} { - if t, err := time.Parse(layout, expiry); err == nil { - return time.Now().After(t) - } - } - return false -} diff --git a/internal/service/local_availability.go b/internal/service/local_availability.go index edb59d3..da11769 100644 --- a/internal/service/local_availability.go +++ b/internal/service/local_availability.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "regexp" - "sort" "strings" "github.com/ShukeBta/MediaStationGo/internal/model" @@ -37,27 +36,6 @@ func EnrichExternalMediaAvailability(ctx context.Context, repo *repository.Conta } } -func EnrichSubscriptionProgress(ctx context.Context, repo *repository.Container, items []model.Subscription) { - for i := range items { - availability := SubscriptionLocalAvailability(ctx, repo, &items[i]) - items[i].DownloadedEpisodes = availability.DownloadedEpisodes - items[i].LocalMediaCount = availability.LocalMediaCount - items[i].MissingEpisodes = availability.MissingEpisodes - items[i].InLibrary = availability.InLibrary - if items[i].TotalEpisodes == 0 { - items[i].TotalEpisodes = availability.TotalEpisodes - } - } -} - -func SubscriptionLocalAvailability(ctx context.Context, repo *repository.Container, sub *model.Subscription) LocalAvailability { - if sub == nil { - return LocalAvailability{} - } - expected := sub.TotalEpisodes - return LookupLocalAvailability(ctx, repo, sub.Name, sub.Filter, sub.MediaType, expected) -} - func LookupLocalAvailability(ctx context.Context, repo *repository.Container, title, keyword, mediaType string, expectedTotal int) LocalAvailability { out := LocalAvailability{ TotalEpisodes: expectedTotal, @@ -116,6 +94,15 @@ func LookupLocalAvailability(ctx context.Context, repo *repository.Container, ti return out } +func isSubscriptionSeriesType(mediaType string) bool { + switch normalizeMediaType(mediaType, "", "") { + case "tv", "anime", "variety": + return true + default: + return false + } +} + func missingEpisodes(existing map[string]struct{}, total int) []int { if total <= 0 { return nil @@ -154,76 +141,3 @@ func episodeKey(season, episode int) string { } return fmt.Sprintf("%02dE%03d", season, episode) } - -func missingEpisodeSet(availability LocalAvailability) map[int]struct{} { - out := make(map[int]struct{}, len(availability.MissingEpisodes)) - for _, episode := range availability.MissingEpisodes { - out[episode] = struct{}{} - } - return out -} - -func sortedEpisodeCandidates(candidates []siteSearchCandidate) []siteSearchCandidate { - selected := make([]siteSearchCandidate, 0, len(candidates)) - covered := make(map[string]struct{}, len(candidates)) - for _, candidate := range candidates { - keys := candidateEpisodeKeys(candidate) - if len(keys) == 0 { - continue - } - if episodeKeysOverlap(covered, keys) { - continue - } - selected = append(selected, candidate) - for _, key := range keys { - covered[key] = struct{}{} - } - } - sort.SliceStable(selected, func(i, j int) bool { - return candidateFirstEpisodeKey(selected[i]) < candidateFirstEpisodeKey(selected[j]) - }) - return selected -} - -func candidateEpisodeKeys(candidate siteSearchCandidate) []string { - episodes := candidateEpisodeNumbers(candidate) - if len(episodes) == 0 { - return nil - } - season := candidate.Season - if season <= 0 { - season = 1 - } - keys := make([]string, 0, len(episodes)) - seen := make(map[string]struct{}, len(episodes)) - for _, episode := range episodes { - if episode <= 0 { - continue - } - key := episodeKey(season, episode) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - keys = append(keys, key) - } - sort.Strings(keys) - return keys -} - -func candidateFirstEpisodeKey(candidate siteSearchCandidate) string { - keys := candidateEpisodeKeys(candidate) - if len(keys) == 0 { - return "" - } - return keys[0] -} - -func episodeKeysOverlap(covered map[string]struct{}, keys []string) bool { - for _, key := range keys { - if _, ok := covered[key]; ok { - return true - } - } - return false -} diff --git a/internal/service/media_classifier_test.go b/internal/service/media_classifier_test.go index d317998..e90fda8 100644 --- a/internal/service/media_classifier_test.go +++ b/internal/service/media_classifier_test.go @@ -1,14 +1,6 @@ package service -import ( - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) +import "testing" func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { tests := []struct { @@ -438,26 +430,3 @@ func TestNormalizeMediaTypeDoesNotTreatReleaseTokensAsTV(t *testing.T) { t.Fatalf("standalone tv path token = %q, want tv", got) } } - -func TestSubscriptionResolveClassifiedSavePath(t *testing.T) { - 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) - } - if err := repos.Setting.Set(t.Context(), "qbittorrent.savepath", `D:\Downloads`); err != nil { - t.Fatal(err) - } - svc := NewSubscriptionService(&config.Config{}, zap.NewNop(), repos, nil, nil, nil) - sub := &model.Subscription{Name: "声生不息 自动订阅", MediaType: "tv"} - - mediaType, category := svc.classifySubscriptionItem(t.Context(), sub, "声生不息 S01E01", "综艺") - if mediaType != "tv" || category != "综艺" { - t.Fatalf("classification = %q/%q, want tv/综艺", mediaType, category) - } - got := svc.resolveSubscriptionSavePath(t.Context(), sub, mediaType, category) - want := `D:\Downloads\综艺` - if got != want { - t.Fatalf("save path = %q, want %q", got, want) - } -} diff --git a/internal/service/media_cloud_test.go b/internal/service/media_cloud_test.go deleted file mode 100644 index c6dbda9..0000000 --- a/internal/service/media_cloud_test.go +++ /dev/null @@ -1,285 +0,0 @@ -package service - -import ( - "path/filepath" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestDeleteLibraryHardDeletesLibraryRoots(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}) - repos := repository.New(db) - rootA := filepath.Join(t.TempDir(), "movies-a") - rootB := filepath.Join(t.TempDir(), "movies-b") - lib := &model.Library{Name: "电影", Path: rootA, Type: "movie", Enabled: true} - if err := repos.Library.CreateWithRoots(t.Context(), lib, []model.LibraryRoot{ - {Path: rootA, Enabled: true, SortOrder: 0}, - {Path: rootB, Enabled: true, SortOrder: 1}, - }); err != nil { - t.Fatal(err) - } - if err := repos.Media.Upsert(t.Context(), &model.Media{ - LibraryID: lib.ID, - LibraryRootID: lib.Roots[0].ID, - Title: "测试电影", - Path: filepath.Join(rootA, "movie.mkv"), - }); err != nil { - t.Fatal(err) - } - - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - if err := svc.DeleteLibrary(t.Context(), lib.ID); err != nil { - t.Fatal(err) - } - - var rootCount int64 - if err := db.Unscoped().Model(&model.LibraryRoot{}).Where("library_id = ?", lib.ID).Count(&rootCount).Error; err != nil { - t.Fatal(err) - } - if rootCount != 0 { - t.Fatalf("library roots should be hard deleted, count=%d", rootCount) - } - var visibleLibraryCount int64 - if err := db.Model(&model.Library{}).Where("id = ?", lib.ID).Count(&visibleLibraryCount).Error; err != nil { - t.Fatal(err) - } - if visibleLibraryCount != 0 { - t.Fatalf("deleted library should not remain visible, count=%d", visibleLibraryCount) - } - var mediaCount int64 - if err := db.Unscoped().Model(&model.Media{}).Where("library_id = ?", lib.ID).Count(&mediaCount).Error; err != nil { - t.Fatal(err) - } - if mediaCount != 0 { - t.Fatalf("local library media rows should be hard deleted, count=%d", mediaCount) - } -} - -func TestDeleteCloudLibraryPurgesMountWithoutRecycleBin(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &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 { - t.Fatal(err) - } - if err := repos.DB.Create(&model.LibraryRoot{ - LibraryID: lib.ID, - Name: "JianLai", - Path: lib.Path, - Enabled: true, - }).Error; err != nil { - t.Fatal(err) - } - if err := repos.Media.Upsert(t.Context(), &model.Media{ - LibraryID: lib.ID, - Title: "剑来", - Path: "cloud://openlist/Anime/JianLai/Season 1/01.mkv", - STRMURL: "/api/cloud/play/openlist?ref=/Anime/JianLai/Season%201/01.mkv", - }); err != nil { - t.Fatal(err) - } - - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - if err := svc.DeleteLibrary(t.Context(), lib.ID); err != nil { - t.Fatal(err) - } - - var mediaCount int64 - if err := db.Unscoped().Model(&model.Media{}).Where("library_id = ?", lib.ID).Count(&mediaCount).Error; err != nil { - t.Fatal(err) - } - if mediaCount != 0 { - t.Fatalf("cloud mount media should be purged, count=%d", mediaCount) - } - recycle, err := svc.ListRecycleBin(t.Context(), 100) - if err != nil { - t.Fatal(err) - } - if len(recycle) != 0 { - t.Fatalf("cloud mount removal must not populate recycle bin: %#v", recycle) - } - var libCount int64 - if err := db.Unscoped().Model(&model.Library{}).Where("id = ?", lib.ID).Count(&libCount).Error; err != nil { - t.Fatal(err) - } - if libCount != 0 { - t.Fatalf("cloud mount library should be purged, count=%d", libCount) - } - var rootCount int64 - if err := db.Unscoped().Model(&model.LibraryRoot{}).Where("library_id = ?", lib.ID).Count(&rootCount).Error; err != nil { - t.Fatal(err) - } - if rootCount != 0 { - t.Fatalf("cloud mount roots should be purged, count=%d", rootCount) - } -} - -func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) { - 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{ - LibraryID: "cloud-tv", - Title: "折腰", - Path: path, - SeasonNum: 1, - EpisodeNum: 1, - ScrapeStatus: "pending", - }).Error; err != nil { - t.Fatal(err) - } - if err := repos.Media.Upsert(t.Context(), &model.Media{ - LibraryID: "cloud-tv", - Title: "折腰", - Path: path, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 296753, - Year: 2025, - ScrapeStatus: "pending", - }); err != nil { - t.Fatal(err) - } - var got model.Media - if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { - t.Fatal(err) - } - if got.TMDbID != 296753 || got.Year != 2025 || got.ScrapeStatus != "pending" { - t.Fatalf("pending cloud row was not backfilled correctly: tmdb=%d year=%d status=%q", got.TMDbID, got.Year, got.ScrapeStatus) - } -} - -func TestMediaUpsertCorrectsCloudExternalIDConflicts(t *testing.T) { - 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{ - LibraryID: "cloud-tv", - Title: "折腰", - Path: path, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 220269, - ScrapeStatus: "matched", - }).Error; err != nil { - t.Fatal(err) - } - if err := repos.Media.Upsert(t.Context(), &model.Media{ - LibraryID: "cloud-tv", - Title: "折腰", - Path: path, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 296753, - Year: 2025, - ScrapeStatus: "pending", - }); err != nil { - t.Fatal(err) - } - var got model.Media - if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { - t.Fatal(err) - } - if got.TMDbID != 296753 || got.ScrapeStatus != "pending" { - t.Fatalf("cloud external id conflict was not corrected: tmdb=%d status=%q", got.TMDbID, got.ScrapeStatus) - } -} - -func TestRepairCloudPathMetadataBackfillsExistingPlaceholders(t *testing.T) { - 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{ - LibraryID: "cloud-movie", - Title: "雄狮少年2 adweb", - Path: path, - ScrapeStatus: "no_match", - }).Error; err != nil { - t.Fatal(err) - } - container := &Container{Repo: repos, Log: zap.NewNop()} - repaired, err := container.RepairCloudPathMetadata(t.Context()) - if err != nil { - t.Fatal(err) - } - if repaired != 1 { - t.Fatalf("repaired = %d, want 1", repaired) - } - var got model.Media - if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { - t.Fatal(err) - } - if got.TMDbID != 1154478 || got.Year != 2024 || got.Title != "雄狮少年2" || got.ScrapeStatus != "pending" { - t.Fatalf("placeholder was not repaired: title=%q tmdb=%d year=%d status=%q", got.Title, got.TMDbID, got.Year, got.ScrapeStatus) - } -} - -func TestRepairCloudPathMetadataCorrectsConflictingMatchedID(t *testing.T) { - 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{ - LibraryID: "cloud-tv", - Title: "折腰", - Path: path, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 220269, - ScrapeStatus: "matched", - }).Error; err != nil { - t.Fatal(err) - } - container := &Container{Repo: repos, Log: zap.NewNop()} - repaired, err := container.RepairCloudPathMetadata(t.Context()) - if err != nil { - t.Fatal(err) - } - if repaired != 1 { - t.Fatalf("repaired = %d, want 1", repaired) - } - var got model.Media - if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { - t.Fatal(err) - } - if got.TMDbID != 296753 || got.ScrapeStatus != "pending" { - t.Fatalf("conflicting matched id was not repaired: tmdb=%d status=%q", got.TMDbID, got.ScrapeStatus) - } -} - -func TestSoftDeleteCloudMediaPurgesRecordWithoutRecycleBin(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - media := model.Media{ - Base: model.Base{ID: "cloud-media"}, - Title: "网盘电影", - Path: "cloud://openlist/电影/Movie.mkv", - STRMURL: "/api/cloud/play/openlist?ref=/电影/Movie.mkv", - } - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - if err := svc.SoftDelete(t.Context(), media.ID); err != nil { - t.Fatal(err) - } - var count int64 - if err := db.Unscoped().Model(&model.Media{}).Where("id = ?", media.ID).Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 0 { - t.Fatalf("cloud media should be purged, count=%d", count) - } - recycle, err := svc.ListRecycleBin(t.Context(), 100) - if err != nil { - t.Fatal(err) - } - if len(recycle) != 0 { - t.Fatalf("cloud media removal must not populate recycle bin: %#v", recycle) - } -} diff --git a/internal/service/media_display_library_test.go b/internal/service/media_display_library_test.go deleted file mode 100644 index 526e710..0000000 --- a/internal/service/media_display_library_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package service - -import ( - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// TestAttachLibraryMetadataAutoCategoryKeepsOwnDisplayLibrary reproduces issue #61. -// -// A movie scanned from a source cloud library ("115 云下载") is auto-categorized into a -// separate auto-category library ("成人"): its library_id points to the auto-category -// library, but its physical cloud path still lives under the source scan directory -// (cloud://cloud115/云下载/...). Display resolution must attribute the media to the -// library it is actually browsed under (the auto-category "成人" library), not path-match -// it back to the source cloud library — otherwise the detail page "返回媒体库" button -// jumps to the wrong library. -func TestAttachLibraryMetadataAutoCategoryKeepsOwnDisplayLibrary(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}) - repos := repository.New(db) - - source := model.Library{Name: "115 云下载", Path: "cloud://cloud115/云下载", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &source); err != nil { - t.Fatal(err) - } - - autoPath := BuildCloudAutoCategoryLibraryPathWithScanDir("cloud115", "成人/成人", "成人") - adult := model.Library{Name: "成人", Path: autoPath, Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &adult); err != nil { - t.Fatal(err) - } - if !CloudLibraryAutoCategory(adult) { - t.Fatalf("adult library should be auto-category, got path %q", adult.Path) - } - - mediaPath := "cloud://cloud115/云下载/Some.Movie.2024/Some.Movie.2024.mp4" - if err := repos.Media.Upsert(t.Context(), &model.Media{ - LibraryID: adult.ID, - Title: "Some Movie", - Path: mediaPath, - }); err != nil { - t.Fatal(err) - } - - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - items := []model.Media{{LibraryID: adult.ID, Title: "Some Movie", Path: mediaPath}} - svc.attachLibraryMetadata(t.Context(), items) - - got := items[0] - if got.DisplayLibraryID != adult.ID { - t.Fatalf("display_library_id = %s (%s), want auto-category library %s (成人)", - got.DisplayLibraryID, got.DisplayLibraryName, adult.ID) - } - if got.DisplayLibraryName != "成人" { - t.Fatalf("display_library_name = %q, want 成人", got.DisplayLibraryName) - } - if got.LibraryName != "成人" { - t.Fatalf("library_name = %q, want 成人 (not the source cloud library)", got.LibraryName) - } -} diff --git a/internal/service/media_library_roots.go b/internal/service/media_library_roots.go index abd0d4f..47a57fb 100644 --- a/internal/service/media_library_roots.go +++ b/internal/service/media_library_roots.go @@ -293,37 +293,14 @@ func (s *MediaService) ensureLibraryRootPathUnique(ctx context.Context, libraryI func normalizeLibraryRootPath(rawPath string) (string, error) { rawPath = strings.TrimSpace(rawPath) - if info, ok := ParseCloudLibraryMount(rawPath); ok { - displayDir := canonicalLibraryDisplayDir(firstNonEmpty(info.DisplayDir, info.ScanDir)) - if displayDir == "" { - displayDir = firstNonEmpty(info.DisplayDir, info.ScanDir) - } - if CloudLibraryAutoCategory(model.Library{Path: rawPath}) { - return BuildCloudAutoCategoryLibraryPathWithScanDir(info.Provider, info.ScanDir, displayDir), nil - } - return BuildCloudLibraryPath(info.Provider, info.ScanDir, displayDir), nil - } return resolveAccessibleLibraryPath(rawPath) } func libraryRootPathKey(pathValue string) string { pathValue = strings.TrimSpace(pathValue) - if info, ok := ParseCloudLibraryMount(pathValue); ok { - auto := "0" - if CloudLibraryAutoCategory(model.Library{Path: pathValue}) { - auto = "1" - } - return strings.ToLower(info.Provider + "\x00" + info.ScanDir + "\x00" + info.DisplayDir + "\x00" + auto) - } return strings.ToLower(filepath.Clean(pathValue)) } func libraryRootNameForPath(pathValue string) string { - if info, ok := ParseCloudLibraryMount(pathValue); ok { - if base := cloudMountDirBase(firstNonEmpty(info.DisplayDir, info.ScanDir)); base != "" { - return base - } - return CloudMountProviderLabel(info.Provider) - } return filepath.Base(filepath.Clean(pathValue)) } diff --git a/internal/service/media_library_roots_test.go b/internal/service/media_library_roots_test.go index 10c01b5..6b9d7ae 100644 --- a/internal/service/media_library_roots_test.go +++ b/internal/service/media_library_roots_test.go @@ -98,28 +98,3 @@ func TestCreateLibraryWithRootsKeepsDifferentTypesSeparate(t *testing.T) { t.Fatalf("library count = %d, want separate libraries for different types", libraryCount) } } - -func TestCreateLibraryWithRootsAcceptsCloudRoot(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}) - repos := repository.New(db) - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - - lib, err := svc.CreateLibraryWithRoots(t.Context(), "国漫", "anime", []LibraryRootInput{{ - Name: "OpenList", - Path: "cloud://openlist/动漫/国漫?dir=国漫&auto_category=1", - }}) - if err != nil { - t.Fatal(err) - } - roots, err := repos.Library.ListRoots(t.Context(), lib.ID) - if err != nil { - t.Fatal(err) - } - if len(roots) != 1 { - t.Fatalf("roots = %#v, want one cloud root", roots) - } - info, ok := ParseCloudLibraryMount(roots[0].Path) - if !ok || info.DisplayDir != "动漫/国漫" || info.ScanDir != "国漫" || !CloudLibraryAutoCategory(model.Library{Path: roots[0].Path}) { - t.Fatalf("cloud root = %#v info=%#v", roots[0], info) - } -} diff --git a/internal/service/media_visibility_test.go b/internal/service/media_visibility_test.go index 7200f5f..42dfd2a 100644 --- a/internal/service/media_visibility_test.go +++ b/internal/service/media_visibility_test.go @@ -82,53 +82,6 @@ func TestMediaVisibilityFiltersNSFWAndLibraries(t *testing.T) { } } -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 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) diff --git a/internal/service/notifier.go b/internal/service/notifier.go deleted file mode 100644 index b93e2a0..0000000 --- a/internal/service/notifier.go +++ /dev/null @@ -1,145 +0,0 @@ -// Package service — multi-channel push notifications. -// -// NotifierService dispatches structured messages to one or more channels -// configured in the system settings table: -// -// notify.telegram.bot_token + notify.telegram.chat_id -// notify.bark.server + notify.bark.key -// notify.wechat.sendkey -// notify.webhook.url + notify.webhook.method -// -// Notifications are triggered by the subscription poller, the download -// poller, the scan / scrape completions, and any future event worth -// surfacing to the operator's phone. -package service - -import ( - "context" - "fmt" - "net/http" - "net/url" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// NotifierService dispatches push notifications. -type NotifierService struct { - log *zap.Logger - repo *repository.Container - client *http.Client -} - -// NewNotifierService is the constructor. -func NewNotifierService(log *zap.Logger, repo *repository.Container) *NotifierService { - return &NotifierService{ - log: log, - repo: repo, - client: NewExternalHTTPClient(10 * time.Second), - } -} - -// Send dispatches a notification to every configured channel. Failures -// are logged but do not propagate — notifications are best-effort. -func (n *NotifierService) Send(ctx context.Context, title, body, eventType string) { - n.sendTelegram(ctx, title, body) - n.sendBark(ctx, title, body) - n.sendWechat(ctx, title, body) - n.sendWebhook(ctx, title, body, eventType) -} - -func (n *NotifierService) get(ctx context.Context, key string) string { - v, _ := n.repo.Setting.Get(ctx, key) - return strings.TrimSpace(v) -} - -func (n *NotifierService) sendTelegram(ctx context.Context, title, body string) { - token := n.get(ctx, "notify.telegram.bot_token") - chatID := n.get(ctx, "notify.telegram.chat_id") - if token == "" || chatID == "" { - return - } - text := fmt.Sprintf("%s\n\n%s", escapeHTML(title), escapeHTML(body)) - form := url.Values{} - form.Set("chat_id", chatID) - form.Set("text", text) - form.Set("parse_mode", "HTML") - cfg := map[string]string{"bot_token": token} - if apiBase := n.get(ctx, "notify.telegram.api_base_url"); apiBase != "" { - cfg["api_base_url"] = apiBase - } - if proxyURL := n.get(ctx, "notify.telegram.proxy_url"); proxyURL != "" { - cfg["proxy_url"] = proxyURL - } - if err := telegramPostForm(ctx, cfg, "sendMessage", form, 15*time.Second); err != nil { - n.log.Debug("telegram notify failed", zap.Error(err)) - } -} - -func (n *NotifierService) sendBark(ctx context.Context, title, body string) { - server := n.get(ctx, "notify.bark.server") - key := n.get(ctx, "notify.bark.key") - if key == "" { - return - } - if server == "" { - server = "https://api.day.app" - } - u := fmt.Sprintf("%s/%s/%s/%s", - strings.TrimRight(server, "/"), - url.PathEscape(key), - url.PathEscape(title), - url.PathEscape(body), - ) - resp, err := n.client.Get(u) - if err != nil { - n.log.Debug("bark notify failed", zap.Error(err)) - return - } - defer resp.Body.Close() -} - -func (n *NotifierService) sendWechat(ctx context.Context, title, body string) { - sendkey := n.get(ctx, "notify.wechat.sendkey") - if sendkey == "" { - return - } - u := fmt.Sprintf("https://sctapi.ftqq.com/%s.send", sendkey) - form := url.Values{} - form.Set("title", title) - form.Set("desp", body) - resp, err := n.client.PostForm(u, form) - if err != nil { - n.log.Debug("wechat notify failed", zap.Error(err)) - return - } - defer resp.Body.Close() -} - -func (n *NotifierService) sendWebhook(ctx context.Context, title, body, eventType string) { - webhookURL := n.get(ctx, "notify.webhook.url") - if webhookURL == "" { - return - } - payload := fmt.Sprintf(`{"title":%q,"content":%q,"type":%q}`, title, body, eventType) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, - strings.NewReader(payload)) - if err != nil { - return - } - req.Header.Set("Content-Type", "application/json") - resp, err := n.client.Do(req) - if err != nil { - n.log.Debug("webhook notify failed", zap.Error(err)) - return - } - defer resp.Body.Close() -} - -func escapeHTML(s string) string { - r := strings.NewReplacer("&", "&", "<", "<", ">", ">") - return r.Replace(s) -} diff --git a/internal/service/notify_bark.go b/internal/service/notify_bark.go deleted file mode 100644 index b96c413..0000000 --- a/internal/service/notify_bark.go +++ /dev/null @@ -1,77 +0,0 @@ -// Package service — Bark 通知 Provider。 -package service - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -// BarkProvider 通过 Bark 推送通知到 iOS 设备。 -// Bark API 文档: https://github.com/Finb/bark-server -type BarkProvider struct{} - -// Send 发送 Bark 推送通知。 -func (p *BarkProvider) Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error { - serverURL := cfg["server_url"] - deviceKey := cfg["device_key"] - if serverURL == "" { - serverURL = "https://api.day.app" - } - serverURL = strings.TrimRight(serverURL, "/") - if deviceKey == "" { - return fmt.Errorf("bark: device_key is required") - } - - payload := map[string]interface{}{ - "title": event.Title, - "body": event.Message, - "group": "MediaStationGo", - } - - if len(event.Data) > 0 { - var extra string - for k, v := range event.Data { - extra += fmt.Sprintf("%s: %v\n", k, v) - } - payload["body"] = event.Message + "\n\n" + extra - } - - body, err := json.Marshal(payload) - if err != nil { - return err - } - - apiURL := fmt.Sprintf("%s/%s", serverURL, deviceKey) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - respBody, _ := io.ReadAll(resp.Body) - if resp.StatusCode >= 400 { - return fmt.Errorf("bark api error %d: %s", resp.StatusCode, string(respBody)) - } - return nil -} - -// ValidateConfig 验证 Bark 配置。 -func (p *BarkProvider) ValidateConfig(cfg map[string]string) error { - if cfg["device_key"] == "" { - return fmt.Errorf("bark: device_key is required") - } - return nil -} diff --git a/internal/service/notify_channels.go b/internal/service/notify_channels.go deleted file mode 100644 index 35bad4b..0000000 --- a/internal/service/notify_channels.go +++ /dev/null @@ -1,272 +0,0 @@ -// Package service — notification channel CRUD + multi-channel dispatch. -// -// The original NotifierService reads a single set of keys from the -// settings table. NotifyChannelService persists *named* channels in -// their own table so the operator can add multiple Telegram bots, Bark -// servers, etc. and pick which events flow to which channel. -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// NotifyChannelService manages notify channels and dispatches messages. -type NotifyChannelService struct { - log *zap.Logger - repo *repository.Container - client *http.Client -} - -// NewNotifyChannelService is the constructor. -func NewNotifyChannelService(log *zap.Logger, repo *repository.Container) *NotifyChannelService { - return &NotifyChannelService{ - log: log, - repo: repo, - client: NewExternalHTTPClient(10 * time.Second), - } -} - -// ChannelInput is the shape accepted by Create / Update. Config is a -// generic map; it gets serialised to JSON before being persisted. -type ChannelInput struct { - Name string `json:"name" binding:"required"` - Type string `json:"type" binding:"required"` - Config map[string]any `json:"config"` - Events []string `json:"events"` - Enabled *bool `json:"enabled,omitempty"` -} - -// channelView is the public shape — Config is decoded back to a map so -// the React form can edit it directly without unwrapping JSON twice. -type channelView struct { - model.NotifyChannel - Config map[string]any `json:"config"` - Events []string `json:"events"` -} - -// toView decodes Config + Events from their persisted JSON strings. -func toView(n model.NotifyChannel) channelView { - v := channelView{NotifyChannel: n} - if n.Config != "" { - _ = json.Unmarshal([]byte(n.Config), &v.Config) - } - if v.Config == nil { - v.Config = map[string]any{} - } - if n.Events != "" { - _ = json.Unmarshal([]byte(n.Events), &v.Events) - } - if v.Events == nil { - v.Events = []string{} - } - return v -} - -// List returns every channel as a decoded view. -func (s *NotifyChannelService) List(ctx context.Context) ([]channelView, error) { - rows, err := s.repo.NotifyChannel.List(ctx) - if err != nil { - return nil, err - } - out := make([]channelView, 0, len(rows)) - for _, r := range rows { - out = append(out, toView(r)) - } - return out, nil -} - -// Create persists a new channel. -func (s *NotifyChannelService) Create(ctx context.Context, in ChannelInput) (*channelView, error) { - normalizeChannelInput(&in) - if err := validateChannel(in); err != nil { - return nil, err - } - cfgBlob, _ := json.Marshal(in.Config) - evBlob, _ := json.Marshal(in.Events) - n := &model.NotifyChannel{ - Name: strings.TrimSpace(in.Name), - Type: in.Type, - Config: string(cfgBlob), - Events: string(evBlob), - Enabled: true, - } - if in.Enabled != nil { - n.Enabled = *in.Enabled - } - if err := s.repo.NotifyChannel.Create(ctx, n); err != nil { - return nil, err - } - if n.Type == "telegram" && n.Enabled { - if err := registerTelegramBotCommands(ctx, telegramStringConfigFromAny(in.Config)); err != nil && s.log != nil { - s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err))) - } - } - v := toView(*n) - return &v, nil -} - -// Update applies a partial patch to an existing channel. -func (s *NotifyChannelService) Update(ctx context.Context, id string, in ChannelInput) (*channelView, error) { - normalizeChannelInput(&in) - if err := validateChannel(in); err != nil { - return nil, err - } - cfgBlob, _ := json.Marshal(in.Config) - evBlob, _ := json.Marshal(in.Events) - patch := map[string]any{ - "name": strings.TrimSpace(in.Name), - "type": in.Type, - "config": string(cfgBlob), - "events": string(evBlob), - } - if in.Enabled != nil { - patch["enabled"] = *in.Enabled - } - // Fetch existing row, apply patch via repo Update - existing, err := s.repo.NotifyChannel.FindByID(ctx, id) - if err != nil { - return nil, err - } - if existing == nil { - return nil, errors.New("channel not found") - } - existing.Name = patch["name"].(string) - existing.Type = patch["type"].(string) - existing.Config = patch["config"].(string) - existing.Events = patch["events"].(string) - if en, ok := patch["enabled"]; ok { - existing.Enabled = en.(bool) - } - if err := s.repo.NotifyChannel.Update(ctx, existing); err != nil { - return nil, err - } - if existing.Type == "telegram" && existing.Enabled { - if err := registerTelegramBotCommands(ctx, telegramStringConfigFromAny(in.Config)); err != nil && s.log != nil { - s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err))) - } - } - row, err := s.repo.NotifyChannel.FindByID(ctx, id) - if err != nil || row == nil { - return nil, err - } - v := toView(*row) - return &v, nil -} - -// Delete removes the channel. -func (s *NotifyChannelService) Delete(ctx context.Context, id string) error { - return s.repo.NotifyChannel.Delete(ctx, id) -} - -// Test sends a "测试通知" through a single channel. -func (s *NotifyChannelService) Test(ctx context.Context, id string) error { - row, err := s.repo.NotifyChannel.FindByID(ctx, id) - if err != nil { - return err - } - if row == nil { - return errors.New("channel not found") - } - return s.dispatchOne(ctx, *row, "MediaStationGo 测试通知", "如果你看到这条消息,说明该通道工作正常。") -} - -const ( - NotifyEventAll = "__all__" - NotifyEventNone = "__none__" -) - -// Broadcast sends a message to every enabled channel that subscribes to -// `event`. Legacy empty Events values mean "all events"; the explicit -// NotifyEventNone sentinel means the channel stays enabled but receives no -// event push. -func (s *NotifyChannelService) Broadcast(ctx context.Context, title, body, event string) { - s.BroadcastEvent(ctx, NotifyEvent{ - Type: event, - Title: title, - Message: body, - }) -} - -// BroadcastEvent sends one structured event to every subscribed enabled -// channel. Rich channels such as Telegram can use Data fields for artwork and -// cleaner formatting while simpler channels keep receiving title/body text. -func (s *NotifyChannelService) BroadcastEvent(ctx context.Context, event NotifyEvent) { - rows, err := s.repo.NotifyChannel.ListEnabled(ctx) - if err != nil { - s.log.Warn("notify list failed", zap.Error(err)) - return - } - for _, r := range rows { - if !channelSubscribes(r, event.Type) { - continue - } - if err := s.dispatchOneEvent(ctx, r, event); err != nil { - s.log.Warn("notify dispatch failed", zap.String("channel", r.Name), zap.Error(err)) - } - } -} - -// validateChannel rejects obviously-malformed inputs early so the API -// returns a useful 400 rather than a database constraint error. -func validateChannel(in ChannelInput) error { - if strings.TrimSpace(in.Name) == "" { - return errors.New("name required") - } - switch in.Type { - case "telegram", "wechat", "bark", "webhook", "email": - default: - return fmt.Errorf("unsupported channel type %q", in.Type) - } - if in.Type == "telegram" { - cfg := in.Config - if str(cfg["bot_token"]) == "" { - return errors.New("telegram bot_token required") - } - if str(cfg["admin_user_ids"]) == "" { - return errors.New("telegram admin_user_ids required") - } - } - return nil -} - -func normalizeChannelInput(in *ChannelInput) { - if in == nil || in.Type != "telegram" { - return - } - if in.Config == nil { - in.Config = map[string]any{} - } - chatID := str(in.Config["chat_id"]) - if chatID == "" { - return - } - if strings.HasPrefix(chatID, "-") && str(in.Config["group_chat_id"]) == "" && str(in.Config["channel_chat_id"]) == "" && str(in.Config["command_chat_id"]) == "" { - in.Config["group_chat_id"] = chatID - return - } - if !strings.HasPrefix(chatID, "-") && str(in.Config["admin_user_ids"]) == "" { - in.Config["admin_user_ids"] = chatID - } -} - -// str safely extracts a string from an interface{} loaded from JSON. -func str(v any) string { - if v == nil { - return "" - } - if s, ok := v.(string); ok { - return strings.TrimSpace(s) - } - return strings.TrimSpace(fmt.Sprint(v)) -} diff --git a/internal/service/notify_channels_test.go b/internal/service/notify_channels_test.go deleted file mode 100644 index 83a75a0..0000000 --- a/internal/service/notify_channels_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "go.uber.org/zap" -) - -func TestChannelSubscribesCanDisableAllEvents(t *testing.T) { - channel := model.NotifyChannel{Events: `["` + NotifyEventNone + `"]`} - - if channelSubscribes(channel, EventDownloadComplete) { - t.Fatal("explicit none sentinel should disable event pushes") - } -} - -func TestChannelSubscribesKeepsLegacyEmptyAsAllEvents(t *testing.T) { - for _, raw := range []string{"", "[]"} { - channel := model.NotifyChannel{Events: raw} - if !channelSubscribes(channel, EventDownloadComplete) { - t.Fatalf("legacy events %q should still subscribe to all events", raw) - } - } -} - -func TestChannelSubscribesSupportsExplicitAllAndSpecificEvents(t *testing.T) { - all := model.NotifyChannel{Events: `["` + NotifyEventAll + `"]`} - if !channelSubscribes(all, EventScrapeFailed) { - t.Fatal("explicit all sentinel should subscribe to every event") - } - - specific := model.NotifyChannel{Events: `["` + EventDownloadComplete + `"]`} - if !channelSubscribes(specific, EventDownloadComplete) { - t.Fatal("specific event should be subscribed") - } - if channelSubscribes(specific, EventScrapeFailed) { - t.Fatal("unlisted event should not be subscribed") - } -} - -func TestTelegramDispatchUsesPhotoAndFormattedCaption(t *testing.T) { - var gotPath string - var gotForm map[string]string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - if err := r.ParseForm(); err != nil { - t.Fatalf("parse form: %v", err) - } - gotForm = map[string]string{} - for key := range r.Form { - gotForm[key] = r.Form.Get(key) - } - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer server.Close() - - svc := NewNotifyChannelService(zap.NewNop(), nil) - channel := model.NotifyChannel{ - Type: "telegram", - Config: `{ - "bot_token":"123456:ABC", - "group_chat_id":"-10001", - "api_base_url":"` + server.URL + `" - }`, - } - err := svc.dispatchOneEvent(t.Context(), channel, NotifyEvent{ - Type: EventDownloadComplete, - Title: "MediaStationGo 下载完成", - Message: "任务:示例影片\n保存路径:/downloads/movie\nHash:abcdef", - Data: map[string]interface{}{ - "poster_url": server.URL + "/poster.jpg", - "media_type": "tv", - "media_category": "纪录片剧集", - "title": "美国甜心:达拉斯牛仔啦啦队", - "original_title": "AMERICA'S SWEETHEARTS: Dallas Cowboys Cheerleaders", - "original_language": "en", - "year": 2024, - "season_episode": "S03E07", - "size": "3.0GB / 5.7Mbps", - "version": "H264.NF.FHD-HHWEB", - "rating": 8.2, - "genres": "纪录", - "overview": "从试镜到训练营再到 NFL 赛季,一路跟随达拉斯牛仔队啦啦队队员们追逐梦想。", - "tmdb_url": server.URL + "/tmdb", - "imdb_url": server.URL + "/imdb", - "douban_url": server.URL + "/douban", - }, - }) - if err != nil { - t.Fatal(err) - } - if gotPath != "/bot123456:ABC/sendPhoto" { - t.Fatalf("path = %q, want sendPhoto", gotPath) - } - if gotForm["chat_id"] != "-10001" || gotForm["photo"] == "" { - t.Fatalf("telegram form = %#v", gotForm) - } - caption := gotForm["caption"] - for _, want := range []string{ - "#下载完成", - "📺 任务:示例影片", - } { - if !strings.Contains(caption, want) { - t.Fatalf("caption missing %q: %s", want, caption) - } - } - for _, unwanted := range []string{ - "✅ 下载完成", - "🎯 订阅命中新资源", - "保存路径", - "abcdef", - "美国甜心:达拉斯牛仔啦啦队", - "AMERICA'S SWEETHEARTS", - "2024", - "3.0GB", - "简介", - server.URL + "/tmdb", - } { - if strings.Contains(caption, unwanted) { - t.Fatalf("caption should not include %q: %s", unwanted, caption) - } - } -} - -func TestTelegramDownloadCompleteNeverFallsBackToSensitiveFields(t *testing.T) { - caption := formatTelegramNotification(NotifyEvent{ - Type: EventDownloadComplete, - Data: map[string]interface{}{ - "save_path": "/downloads/private/movie.mkv", - "hash": "abcdef", - }, - }) - for _, want := range []string{"#下载完成", "📺 任务:下载任务"} { - if !strings.Contains(caption, want) { - t.Fatalf("caption missing %q: %s", want, caption) - } - } - for _, unwanted := range []string{"保存路径", "/downloads/private", "Hash", "abcdef"} { - if strings.Contains(caption, unwanted) { - t.Fatalf("caption should not include %q: %s", unwanted, caption) - } - } -} - -// TestTelegramMediaTemplateRendersEnrichedFields 验证补齐的媒体字段 -// (年份/评分/类型/原名/语言)确实透传进 Telegram 富模板 caption。 -// 这是「模型/刮削链路补齐字段」与「作者富模板」对接的端到端断言。 -func TestTelegramMediaTemplateRendersEnrichedFields(t *testing.T) { - caption := formatTelegramNotification(NotifyEvent{ - Type: EventSubscriptionHit, - Title: "MediaStationGo 订阅命中新资源", - Message: "订阅:遮天\n新增资源:1", - Data: map[string]interface{}{ - "media_category": "国漫", - "title": "遮天", - "original_title": "Shrouding the Heavens", - "original_language": "zh", - "year": 2023, - "rating": 8.6, - "genres": "动画,奇幻", - "overview": "荒古禁地中走出的少年。", - "tmdb_url": "https://www.themoviedb.org/tv/223911", - "imdb_url": "https://www.imdb.com/title/tt12345678/", - }, - }) - for _, want := range []string{ - "#国漫", - "📺 中文片名:遮天", - "🧿 原始片名:Shrouding the Heavens", - "🌐 原始语言:中文", - "📅 发行年份:2023", - "⭐️ 评分:8.6", - "💎 类型:动画、奇幻", - "🪬 简介:", - `TMDB`, - `IMDB`, - } { - if !strings.Contains(caption, want) { - t.Fatalf("caption missing %q:\n%s", want, caption) - } - } -} diff --git a/internal/service/notify_dispatch.go b/internal/service/notify_dispatch.go deleted file mode 100644 index 38e8be8..0000000 --- a/internal/service/notify_dispatch.go +++ /dev/null @@ -1,163 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// channelSubscribes returns true when the channel's Events list contains the -// event, or when the list is the legacy empty/"all events" value. -func channelSubscribes(n model.NotifyChannel, event string) bool { - if event == "" || n.Events == "" { - return true - } - var ev []string - if err := json.Unmarshal([]byte(n.Events), &ev); err != nil { - return true - } - if len(ev) == 0 { - return true - } - for _, e := range ev { - switch e { - case NotifyEventNone: - return false - case NotifyEventAll: - return true - } - if e == event { - return true - } - } - return false -} - -// dispatchOne is the inner dispatcher; the channel type drives which -// HTTP request gets built. -func (s *NotifyChannelService) dispatchOne(ctx context.Context, n model.NotifyChannel, title, body string) error { - return s.dispatchOneEvent(ctx, n, NotifyEvent{Title: title, Message: body}) -} - -func (s *NotifyChannelService) dispatchOneEvent(ctx context.Context, n model.NotifyChannel, event NotifyEvent) error { - cfg := map[string]any{} - _ = json.Unmarshal([]byte(n.Config), &cfg) - title := event.Title - body := event.Message - - switch n.Type { - case "telegram": - telegramCfg := telegramStringConfigFromAny(cfg) - token := telegramCfg["bot_token"] - chats := telegramTargetChatIDs(telegramCfg) - if token == "" || len(chats) == 0 { - return errors.New("telegram missing bot_token / group_chat_id / channel_chat_id") - } - text := formatTelegramNotification(event) - photoURL := telegramEventPhotoURL(event) - var firstErr error - for _, chat := range chats { - if photoURL != "" && len(text) <= 1024 { - form := url.Values{} - form.Set("chat_id", chat) - form.Set("photo", photoURL) - form.Set("caption", text) - form.Set("parse_mode", "HTML") - if err := telegramPostForm(ctx, telegramCfg, "sendPhoto", form, 15*time.Second); err == nil { - continue - } else if firstErr == nil { - firstErr = err - } - } - form := url.Values{} - form.Set("chat_id", chat) - form.Set("text", text) - form.Set("parse_mode", "HTML") - if err := telegramPostForm(ctx, telegramCfg, "sendMessage", form, 15*time.Second); err != nil && firstErr == nil { - firstErr = err - } - } - return firstErr - - case "bark": - key := str(cfg["device_key"]) - if key == "" { - return errors.New("bark missing device_key") - } - server := str(cfg["server"]) - if server == "" { - server = "https://api.day.app" - } - u := fmt.Sprintf("%s/%s/%s/%s", - strings.TrimRight(server, "/"), - url.PathEscape(key), - url.PathEscape(title), - url.PathEscape(body), - ) - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - return s.do(req) - - case "wechat": - key := str(cfg["sendkey"]) - if key == "" { - return errors.New("wechat missing sendkey") - } - u := fmt.Sprintf("https://sctapi.ftqq.com/%s.send", url.PathEscape(key)) - form := url.Values{} - form.Set("title", title) - form.Set("desp", body) - req, _ := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return s.do(req) - - case "webhook": - urlS := str(cfg["url"]) - if urlS == "" { - return errors.New("webhook missing url") - } - method := strings.ToUpper(str(cfg["method"])) - if method == "" { - method = "POST" - } - // Substitute {{title}} / {{message}} in the body template. - bodyTpl := str(cfg["body_template"]) - if bodyTpl == "" { - bodyTpl = `{"title":"{{title}}","message":"{{message}}"}` - } - bodyStr := strings.NewReplacer("{{title}}", title, "{{message}}", body).Replace(bodyTpl) - req, _ := http.NewRequestWithContext(ctx, method, urlS, strings.NewReader(bodyStr)) - // Apply custom headers (encoded as JSON in the config). - if hdrRaw := str(cfg["headers"]); hdrRaw != "" { - var hdr map[string]string - if err := json.Unmarshal([]byte(hdrRaw), &hdr); err == nil { - for k, v := range hdr { - req.Header.Set(k, v) - } - } - } - if req.Header.Get("Content-Type") == "" && method != http.MethodGet { - req.Header.Set("Content-Type", "application/json") - } - return s.do(req) - } - return fmt.Errorf("unknown channel type %q", n.Type) -} - -func (s *NotifyChannelService) do(req *http.Request) error { - resp, err := s.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return fmt.Errorf("upstream returned %d", resp.StatusCode) - } - return nil -} diff --git a/internal/service/notify_email.go b/internal/service/notify_email.go deleted file mode 100644 index 429f66f..0000000 --- a/internal/service/notify_email.go +++ /dev/null @@ -1,147 +0,0 @@ -// Package service — Email(SMTP) 通知 Provider。 -package service - -import ( - "context" - "crypto/tls" - "fmt" - "net/mail" - "net/smtp" - "strconv" - "strings" -) - -// EmailProvider 通过 SMTP 发送邮件通知。 -type EmailProvider struct{} - -// Send 通过 SMTP 发送邮件。 -func (p *EmailProvider) Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error { - smtpHost := cfg["smtp_host"] - smtpPortStr := cfg["smtp_port"] - username := cfg["username"] - password := cfg["password"] - from := cfg["from"] - to := cfg["to"] - tlsStr := cfg["tls"] - - if smtpHost == "" || smtpPortStr == "" || username == "" || from == "" || to == "" { - return fmt.Errorf("email: smtp_host, smtp_port, username, from, and to are required") - } - - smtpPort, err := strconv.Atoi(smtpPortStr) - if err != nil { - return fmt.Errorf("email: invalid smtp_port: %s", smtpPortStr) - } - - useTLS := true - if tlsStr == "false" || tlsStr == "0" || tlsStr == "no" { - useTLS = false - } - - // 构建邮件内容 - subject := fmt.Sprintf("[MediaStationGo] %s", event.Title) - body := event.Message - if len(event.Data) > 0 { - body += "\n\n---\n详细信息:\n" - for k, v := range event.Data { - body += fmt.Sprintf(" %s: %v\n", k, v) - } - } - - recipients := strings.Split(to, ",") - for i, r := range recipients { - recipients[i] = strings.TrimSpace(r) - } - - // 构建邮件 - fromAddr := mail.Address{Name: "MediaStationGo", Address: from} - toAddrs := make([]mail.Address, 0, len(recipients)) - for _, r := range recipients { - toAddrs = append(toAddrs, mail.Address{Address: r}) - } - - msg := "From: " + fromAddr.String() + "\r\n" - msg += "To: " - for i, addr := range toAddrs { - if i > 0 { - msg += ", " - } - msg += addr.String() - } - msg += "\r\n" - msg += "Subject: " + subject + "\r\n" - msg += "MIME-Version: 1.0\r\n" - msg += "Content-Type: text/plain; charset=\"utf-8\"\r\n" - msg += "Content-Transfer-Encoding: base64\r\n" - msg += "\r\n" - msg += body - - addr := fmt.Sprintf("%s:%d", smtpHost, smtpPort) - auth := smtp.PlainAuth("", username, password, smtpHost) - - if useTLS { - // 使用 TLS 连接 - tlsConfig := &tls.Config{ - ServerName: smtpHost, - MinVersion: tls.VersionTLS12, - } - conn, err := tls.Dial("tcp", addr, tlsConfig) - if err != nil { - return fmt.Errorf("email tls dial: %w", err) - } - client, err := smtp.NewClient(conn, smtpHost) - if err != nil { - return fmt.Errorf("email smtp client: %w", err) - } - defer client.Close() - - if err = client.Auth(auth); err != nil { - return fmt.Errorf("email auth: %w", err) - } - if err = client.Mail(from); err != nil { - return fmt.Errorf("email mail from: %w", err) - } - for _, r := range recipients { - if err = client.Rcpt(r); err != nil { - return fmt.Errorf("email rcpt to: %w", err) - } - } - w, err := client.Data() - if err != nil { - return fmt.Errorf("email data: %w", err) - } - if _, err = w.Write([]byte(msg)); err != nil { - return fmt.Errorf("email write: %w", err) - } - if err = w.Close(); err != nil { - return fmt.Errorf("email close: %w", err) - } - return client.Quit() - } - - // 不使用 TLS(STARTTLS 或明文) - return smtp.SendMail(addr, auth, from, recipients, []byte(msg)) -} - -// ValidateConfig 验证 Email 配置。 -func (p *EmailProvider) ValidateConfig(cfg map[string]string) error { - if cfg["smtp_host"] == "" { - return fmt.Errorf("email: smtp_host is required") - } - if cfg["smtp_port"] == "" { - return fmt.Errorf("email: smtp_port is required") - } - if cfg["username"] == "" { - return fmt.Errorf("email: username is required") - } - if cfg["from"] == "" { - return fmt.Errorf("email: from is required") - } - if cfg["to"] == "" { - return fmt.Errorf("email: to is required") - } - if _, err := strconv.Atoi(cfg["smtp_port"]); err != nil { - return fmt.Errorf("email: invalid smtp_port") - } - return nil -} diff --git a/internal/service/notify_svc.go b/internal/service/notify_svc.go deleted file mode 100644 index 8aecc41..0000000 --- a/internal/service/notify_svc.go +++ /dev/null @@ -1,186 +0,0 @@ -// Package service — 通知服务事件分发引擎。 -// -// NotifyService 管理所有通知渠道,根据事件类型将通知分发给 -// 订阅了该事件的渠道。支持 4 种内置事件类型和 5 种通知渠道。 -package service - -import ( - "context" - "encoding/json" - "sync" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// 通知事件类型常量。 -const ( - EventSubscriptionHit = "subscription_hit" - EventDownloadComplete = "download_complete" - EventScrapeFailed = "scrape_failed" - EventSystemAlert = "system_alert" - EventLibraryIngest = "library_ingest" -) - -// NotifyEvent 是通知事件的数据结构。 -type NotifyEvent struct { - Type string `json:"type"` - Title string `json:"title"` - Message string `json:"message"` - Data map[string]interface{} `json:"data,omitempty"` -} - -// NotifyProvider 定义通知渠道的发送接口。 -type NotifyProvider interface { - Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error - ValidateConfig(cfg map[string]string) error -} - -// NotifyService 是事件驱动的通知分发引擎。 -type NotifyService struct { - log *zap.Logger - repo *repository.Container - crypto *CryptoService - - mu sync.RWMutex - providers map[string]NotifyProvider // type -> provider -} - -// NewNotifyService 创建通知服务。 -func NewNotifyService(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *NotifyService { - ns := &NotifyService{ - log: log, - repo: repo, - crypto: crypto, - providers: make(map[string]NotifyProvider), - } - // 注册内置 Provider - ns.registerProviders() - return ns -} - -// registerProviders 注册所有内置通知 Provider。 -func (s *NotifyService) registerProviders() { - s.mu.Lock() - defer s.mu.Unlock() - s.providers["telegram"] = &TelegramProvider{} - s.providers["wechat"] = &WechatProvider{} - s.providers["bark"] = &BarkProvider{} - s.providers["webhook"] = &WebhookProvider{} - s.providers["email"] = &EmailProvider{} -} - -// Dispatch 将事件分发给所有订阅了该事件类型的已启用渠道。 -func (s *NotifyService) Dispatch(ctx context.Context, event NotifyEvent) { - channels, err := s.repo.NotifyChannel.ListByEvent(ctx, event.Type) - if err != nil { - s.log.Error("failed to list channels for event", - zap.String("event", event.Type), - zap.Error(err), - ) - return - } - - for _, ch := range channels { - go func(channel model.NotifyChannel) { - if sendErr := s.sendToChannel(ctx, channel, event); sendErr != nil { - s.log.Error("failed to send notification", - zap.String("channel", channel.Name), - zap.String("type", channel.Type), - zap.String("event", event.Type), - zap.Error(sendErr), - ) - } - }(ch) - } -} - -// SendTest 向指定渠道发送测试通知。 -func (s *NotifyService) SendTest(ctx context.Context, channelID string) error { - ch, err := s.repo.NotifyChannel.FindByID(ctx, channelID) - if err != nil { - return err - } - if ch == nil { - return ErrNotifyChannelNotFound - } - - testEvent := NotifyEvent{ - Type: "test", - Title: "MediaStationGo 测试通知", - Message: "这是一条测试通知,如果您看到此消息,说明通知渠道配置正确。", - } - - return s.sendToChannel(ctx, *ch, testEvent) -} - -// ValidateChannelConfig 验证渠道配置是否合法。 -func (s *NotifyService) ValidateChannelConfig(channelType string, config map[string]string) error { - s.mu.RLock() - provider, ok := s.providers[channelType] - s.mu.RUnlock() - if !ok { - return ErrUnknownNotifyType - } - return provider.ValidateConfig(config) -} - -// GetProviderTypes 返回支持的通知渠道类型列表。 -func (s *NotifyService) GetProviderTypes() []NotifyProviderInfo { - return []NotifyProviderInfo{ - {Type: "telegram", Name: "Telegram", Description: "通过 Telegram Bot 发送消息"}, - {Type: "wechat", Name: "Server酱", Description: "通过 Server酱 推送到微信"}, - {Type: "bark", Name: "Bark", Description: "通过 Bark 推送到 iOS"}, - {Type: "webhook", Name: "Webhook", Description: "通过自定义 HTTP Webhook 发送"}, - {Type: "email", Name: "Email", Description: "通过 SMTP 发送邮件"}, - } -} - -// NotifyProviderInfo 描述通知渠道类型信息。 -type NotifyProviderInfo struct { - Type string `json:"type"` - Name string `json:"name"` - Description string `json:"description"` -} - -// sendToChannel 解密渠道配置并通过对应的 Provider 发送通知。 -func (s *NotifyService) sendToChannel(ctx context.Context, channel model.NotifyChannel, event NotifyEvent) error { - // 解密配置 - 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 err - } - - s.mu.RLock() - provider, ok := s.providers[channel.Type] - s.mu.RUnlock() - if !ok { - return ErrUnknownNotifyType - } - - return provider.Send(ctx, cfg, event) -} - -// 通知服务错误定义。 -var ( - ErrNotifyChannelNotFound = &NotifyError{Code: "CHANNEL_NOT_FOUND", Message: "notification channel not found"} - ErrUnknownNotifyType = &NotifyError{Code: "UNKNOWN_TYPE", Message: "unknown notification type"} -) - -// NotifyError 是通知服务专用错误类型。 -type NotifyError struct { - Code string `json:"code"` - Message string `json:"message"` -} - -// Error 实现 error 接口。 -func (e *NotifyError) Error() string { - return e.Message -} diff --git a/internal/service/notify_telegram.go b/internal/service/notify_telegram.go deleted file mode 100644 index 933da6a..0000000 --- a/internal/service/notify_telegram.go +++ /dev/null @@ -1,141 +0,0 @@ -// Package service — Telegram 通知 Provider。 -package service - -import ( - "context" - "fmt" - "strings" - "time" - "unicode/utf8" -) - -// TelegramProvider 通过 Telegram Bot API 发送通知。 -type TelegramProvider struct{} - -// Send 发送 Telegram 消息。 -func (p *TelegramProvider) Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error { - botToken := cfg["bot_token"] - chatIDs := telegramTargetChatIDs(cfg) - parseMode := cfg["parse_mode"] - if parseMode == "" { - parseMode = "HTML" - } - - if botToken == "" || len(chatIDs) == 0 { - return fmt.Errorf("telegram: bot_token and group_chat_id/channel_chat_id are required") - } - - text := formatTelegramMessage(event, parseMode) - photoURL := telegramEventPhotoURL(event) - - var firstErr error - for _, chatID := range chatIDs { - if photoURL != "" && utf8.RuneCountInString(text) <= 1024 { - payload := map[string]string{ - "chat_id": chatID, - "photo": photoURL, - "caption": text, - "parse_mode": parseMode, - } - if err := telegramPostJSON(ctx, cfg, "sendPhoto", payload, 15*time.Second); err == nil { - continue - } else if firstErr == nil { - firstErr = err - } - if photo, _, err := telegramFetchRemotePhoto(ctx, cfg, photoURL, 15*time.Second); err == nil { - fields := map[string]string{ - "chat_id": chatID, - "caption": text, - "parse_mode": parseMode, - } - if err := telegramPostMultipart(ctx, cfg, "sendPhoto", fields, "photo", "poster.jpg", photo, 20*time.Second); err == nil { - continue - } else if firstErr == nil { - firstErr = err - } - } else if firstErr == nil { - firstErr = err - } - } - payload := map[string]string{ - "chat_id": chatID, - "text": text, - "parse_mode": parseMode, - } - if err := telegramPostJSON(ctx, cfg, "sendMessage", payload, 15*time.Second); err != nil && firstErr == nil { - firstErr = err - } - } - return firstErr -} - -// ValidateConfig 验证 Telegram 配置。 -func (p *TelegramProvider) ValidateConfig(cfg map[string]string) error { - if cfg["bot_token"] == "" { - return fmt.Errorf("telegram: bot_token is required") - } - if len(telegramTargetChatIDs(cfg)) == 0 { - return fmt.Errorf("telegram: group_chat_id or channel_chat_id is required") - } - return nil -} - -// formatTelegramMessage 格式化消息内容。 -func formatTelegramMessage(event NotifyEvent, parseMode string) string { - text := formatTelegramNotification(event) - if parseMode == "HTML" || parseMode == "" { - return text - } - result := text - result = strings.ReplaceAll(result, "", "**") - result = strings.ReplaceAll(result, "", "**") - result = strings.ReplaceAll(result, "", "`") - result = strings.ReplaceAll(result, "", "`") - result = strings.ReplaceAll(result, "<", "<") - result = strings.ReplaceAll(result, ">", ">") - result = strings.ReplaceAll(result, "&", "&") - return result -} - -func formatTelegramNotification(event NotifyEvent) string { - if text := formatTelegramMediaNotification(event); text != "" { - return text - } - - var sb strings.Builder - if tag := telegramEventTag(event); tag != "" { - sb.WriteString(tag) - if telegramShouldShowEventHeading(event) { - sb.WriteString("\n") - } - } - if telegramShouldShowEventHeading(event) { - sb.WriteString(telegramEventHeading(event)) - } - - message := strings.TrimSpace(event.Message) - if message != "" { - if sb.Len() > 0 { - sb.WriteString("\n\n") - } - sb.WriteString(formatTelegramBody(message)) - } - - fields := telegramDisplayData(event.Data) - if len(fields) > 0 { - if sb.Len() > 0 { - sb.WriteString("\n") - } - sb.WriteString("\n") - for _, field := range fields { - sb.WriteString(formatTelegramField(field.key, field.value)) - sb.WriteString("\n") - } - } - if links := telegramExternalLinks(event.Data); links != "" { - sb.WriteString("\n") - sb.WriteString(links) - } - - return strings.TrimSpace(sb.String()) -} diff --git a/internal/service/notify_telegram_body.go b/internal/service/notify_telegram_body.go deleted file mode 100644 index 3945438..0000000 --- a/internal/service/notify_telegram_body.go +++ /dev/null @@ -1,175 +0,0 @@ -package service - -import ( - "fmt" - "strings" -) - -func telegramShouldShowEventHeading(event NotifyEvent) bool { - if telegramMediaTag(event.Data) != "" { - return false - } - switch strings.TrimSpace(event.Type) { - case EventSubscriptionHit, EventDownloadComplete: - return false - default: - return true - } -} - -func telegramEventTag(event NotifyEvent) string { - if tag := telegramMediaTag(event.Data); tag != "" { - return tag - } - switch strings.TrimSpace(event.Type) { - case EventSubscriptionHit: - return "#订阅" - case EventDownloadComplete: - return "#下载完成" - case EventScrapeFailed: - return "#刮削失败" - case EventSystemAlert: - return "#系统提醒" - case EventLibraryIngest: - return "#入库" - default: - title := strings.TrimSpace(strings.TrimPrefix(event.Title, "MediaStationGo ")) - if title == "" { - return "#MediaStationGo" - } - return "#" + escapeHTML(strings.ReplaceAll(title, " ", "")) - } -} - -func telegramEventHeading(event NotifyEvent) string { - title := strings.TrimSpace(event.Title) - title = strings.TrimSpace(strings.TrimPrefix(title, "MediaStationGo ")) - if title == "" { - title = "MediaStationGo 通知" - } - icon := "🔔" - switch strings.TrimSpace(event.Type) { - case EventSubscriptionHit: - icon = "🎯" - case EventDownloadComplete: - icon = "✅" - case EventScrapeFailed: - icon = "⚠️" - case EventSystemAlert: - icon = "🚨" - case EventLibraryIngest: - icon = "📚" - } - return fmt.Sprintf("%s %s", icon, escapeHTML(title)) -} - -func formatTelegramBody(message string) string { - lines := strings.Split(message, "\n") - out := make([]string, 0, len(lines)) - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - out = append(out, "") - continue - } - if strings.HasPrefix(line, "- ") { - out = append(out, "- "+escapeHTML(strings.TrimSpace(strings.TrimPrefix(line, "- ")))) - continue - } - if key, ok := trimTelegramEmptyField(line); ok { - out = append(out, fmt.Sprintf("%s %s:", telegramFieldIcon(telegramFieldLabel(key)), escapeHTML(telegramFieldLabel(key)))) - continue - } - if key, value, ok := splitTelegramField(line); ok { - out = append(out, formatTelegramField(key, value)) - continue - } - out = append(out, escapeHTML(line)) - } - return strings.TrimSpace(strings.Join(out, "\n")) -} - -func trimTelegramEmptyField(line string) (string, bool) { - line = strings.TrimSpace(line) - for _, suffix := range []string{":", ":"} { - if strings.HasSuffix(line, suffix) { - key := strings.TrimSpace(strings.TrimSuffix(line, suffix)) - if key != "" && len([]rune(key)) <= 16 { - return key, true - } - } - } - return "", false -} - -func splitTelegramField(line string) (string, string, bool) { - idx := strings.Index(line, ":") - sepLen := len(":") - if idx < 0 { - idx = strings.Index(line, ":") - sepLen = len(":") - } - if idx <= 0 { - return "", "", false - } - key := strings.TrimSpace(line[:idx]) - value := strings.TrimSpace(line[idx+sepLen:]) - if key == "" || value == "" || len([]rune(key)) > 16 { - return "", "", false - } - return key, value, true -} - -func formatTelegramField(key, value string) string { - key = telegramFieldLabel(key) - escapedValue := escapeHTML(strings.TrimSpace(value)) - if telegramCodeField(key) { - escapedValue = "" + escapedValue + "" - } - return fmt.Sprintf("%s %s:%s", telegramFieldIcon(key), escapeHTML(key), escapedValue) -} - -func telegramCodeField(key string) bool { - key = strings.ToLower(strings.TrimSpace(key)) - return strings.Contains(key, "hash") || - strings.Contains(key, "路径") || - strings.Contains(key, "path") || - strings.Contains(key, "id") -} - -func telegramFieldIcon(key string) string { - switch strings.ToLower(strings.TrimSpace(key)) { - case "中文片名", "标题", "任务", "媒体", "资源": - return "📺" - case "原始片名": - return "🧿" - case "原始语言", "语言": - return "🌐" - case "发行年份", "年份": - return "📅" - case "类别", "分类", "媒体类型": - return "🐈‍⬛" - case "季集", "集数": - return "🫧" - case "大小", "质量", "规格": - return "🔎" - case "版本", "保存路径": - return "📁" - case "评分": - return "⭐️" - case "类型": - return "💎" - case "简介": - return "🪬" - case "订阅": - return "🎯" - case "新增资源": - return "✨" - case "Hash": - return "🧿" - case "错误": - return "⚠️" - default: - return "•" - } -} diff --git a/internal/service/notify_telegram_config.go b/internal/service/notify_telegram_config.go deleted file mode 100644 index 65a4dcf..0000000 --- a/internal/service/notify_telegram_config.go +++ /dev/null @@ -1,47 +0,0 @@ -package service - -import "strings" - -func telegramTargetChatIDs(cfg map[string]string) []string { - seen := map[string]bool{} - targets := []string{} - for _, key := range []string{"group_chat_id", "channel_chat_id"} { - chatID := strings.TrimSpace(cfg[key]) - if chatID == "" || seen[chatID] { - continue - } - seen[chatID] = true - targets = append(targets, chatID) - } - if len(targets) == 0 { - chatID := strings.TrimSpace(cfg["chat_id"]) - if strings.HasPrefix(chatID, "-") { - targets = append(targets, chatID) - } else if chatID != "" && strings.TrimSpace(cfg["admin_user_ids"]) == "" { - targets = append(targets, chatID) - } - } - if len(targets) == 0 { - for _, userID := range telegramConfiguredUserIDs(cfg["admin_user_ids"]) { - if seen[userID] { - continue - } - seen[userID] = true - targets = append(targets, userID) - } - } - return targets -} - -func telegramConfiguredUserIDs(raw string) []string { - out := []string{} - for _, value := range strings.FieldsFunc(raw, func(r rune) bool { - return r == ',' || r == ';' || r == ',' || r == ' ' || r == '\n' || r == '\t' - }) { - value = strings.TrimSpace(value) - if value != "" { - out = append(out, value) - } - } - return out -} diff --git a/internal/service/notify_telegram_data.go b/internal/service/notify_telegram_data.go deleted file mode 100644 index adf432c..0000000 --- a/internal/service/notify_telegram_data.go +++ /dev/null @@ -1,170 +0,0 @@ -package service - -import ( - "fmt" - "net/url" - "sort" - "strings" -) - -type telegramDataField struct { - key string - value string -} - -func telegramDisplayData(data map[string]interface{}) []telegramDataField { - if len(data) == 0 { - return nil - } - keys := make([]string, 0, len(data)) - for key := range data { - if telegramHiddenDataKey(key) { - continue - } - keys = append(keys, key) - } - sort.Strings(keys) - fields := make([]telegramDataField, 0, len(keys)) - for _, key := range keys { - value := strings.TrimSpace(fmt.Sprint(data[key])) - if value == "" || value == "" { - continue - } - fields = append(fields, telegramDataField{key: key, value: value}) - } - return fields -} - -func telegramHiddenDataKey(key string) bool { - switch strings.ToLower(strings.TrimSpace(key)) { - case "photo_url", "poster_url", "poster", "image_url", "backdrop_url", - "tmdb_url", "imdb_url", "douban_url", "detail_url", "external_url", - "resource_title", "torrent_title", "release_title": - return true - default: - return false - } -} - -func telegramFieldLabel(key string) string { - switch strings.ToLower(strings.TrimSpace(key)) { - case "title", "name": - return "标题" - case "original_title": - return "原始片名" - case "original_language": - return "原始语言" - case "year", "release_year": - return "发行年份" - case "save_path": - return "保存路径" - case "hash": - return "Hash" - case "media_type": - return "媒体类型" - case "media_category": - return "类别" - case "season_episode": - return "季集" - case "size", "bitrate": - return "大小" - case "version", "release_group": - return "版本" - case "rating": - return "评分" - case "genres": - return "类型" - case "overview": - return "简介" - case "subscription": - return "订阅" - case "queued": - return "新增资源" - default: - return strings.TrimSpace(key) - } -} - -func telegramExternalLinks(data map[string]interface{}) string { - if len(data) == 0 { - return "" - } - links := []string{} - for _, item := range []struct { - key string - name string - }{ - {key: "tmdb_url", name: "TMDB"}, - {key: "imdb_url", name: "IMDB"}, - {key: "douban_url", name: "豆瓣"}, - } { - value := telegramDataString(data, item.key) - if isTelegramRemotePhotoURL(value) { - links = append(links, fmt.Sprintf(`%s`, escapeHTML(value), escapeHTML(item.name))) - } - } - if len(links) == 0 { - return "" - } - return "🔗 外链:" + strings.Join(links, " / ") -} - -func telegramEventPhotoURL(event NotifyEvent) string { - for _, key := range []string{"photo_url", "poster_url", "poster", "image_url", "backdrop_url"} { - value := telegramDataString(event.Data, key) - if isTelegramRemotePhotoURL(value) { - return value - } - } - return "" -} - -func telegramDataString(data map[string]interface{}, key string) string { - if len(data) == 0 { - return "" - } - for k, value := range data { - if strings.EqualFold(strings.TrimSpace(k), key) { - return telegramValueString(value) - } - } - return "" -} - -func telegramValueString(value interface{}) string { - switch v := value.(type) { - case nil: - return "" - case string: - return strings.TrimSpace(v) - case []string: - return strings.TrimSpace(strings.Join(v, ",")) - case []interface{}: - out := make([]string, 0, len(v)) - for _, item := range v { - if s := telegramValueString(item); s != "" { - out = append(out, s) - } - } - return strings.Join(out, ",") - case float32: - return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.1f", v), "0"), ".") - case float64: - return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.1f", v), "0"), ".") - default: - text := strings.TrimSpace(fmt.Sprint(value)) - if text == "" { - return "" - } - return text - } -} - -func isTelegramRemotePhotoURL(raw string) bool { - raw = strings.TrimSpace(raw) - if raw == "" { - return false - } - u, err := url.Parse(raw) - return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" -} diff --git a/internal/service/notify_telegram_fields.go b/internal/service/notify_telegram_fields.go deleted file mode 100644 index c7da82c..0000000 --- a/internal/service/notify_telegram_fields.go +++ /dev/null @@ -1,226 +0,0 @@ -package service - -import ( - "fmt" - "strconv" - "strings" -) - -func telegramFirstValue(data map[string]interface{}, keys ...string) string { - for _, key := range keys { - if value := telegramDataString(data, key); value != "" { - return value - } - } - return "" -} - -func telegramMessageFieldValue(message string, keys ...string) string { - if strings.TrimSpace(message) == "" { - return "" - } - for _, line := range strings.Split(message, "\n") { - key, value, ok := splitTelegramField(line) - if !ok { - continue - } - for _, want := range keys { - if strings.EqualFold(strings.TrimSpace(key), strings.TrimSpace(want)) { - return value - } - } - } - return "" -} - -func telegramMediaCategory(data map[string]interface{}) string { - if category := telegramFirstValue(data, "media_category", "category"); category != "" { - return category - } - switch strings.ToLower(telegramFirstValue(data, "media_type")) { - case "movie": - return "电影" - case "tv", "series", "show": - return "剧集" - case "anime": - return "动漫" - case "variety": - return "综艺" - case "documentary": - return "纪录片" - default: - return telegramFirstValue(data, "media_type") - } -} - -func telegramLanguageName(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - parts := strings.FieldsFunc(raw, func(r rune) bool { - return r == ',' || r == ',' || r == '/' || r == '|' || r == '、' - }) - if len(parts) == 0 { - parts = []string{raw} - } - seen := map[string]struct{}{} - out := []string{} - for _, part := range parts { - part = strings.TrimSpace(strings.Trim(part, "[]")) - if part == "" { - continue - } - lower := strings.ToLower(strings.ReplaceAll(part, "_", "-")) - name := part - switch { - case strings.HasPrefix(lower, "zh") || lower == "cn" || lower == "cmn": - name = "中文" - case lower == "en" || strings.HasPrefix(lower, "en-"): - name = "英语" - case lower == "ja" || lower == "jp" || strings.HasPrefix(lower, "ja-"): - name = "日语" - case lower == "ko" || lower == "kr" || strings.HasPrefix(lower, "ko-"): - name = "韩语" - case lower == "fr" || strings.HasPrefix(lower, "fr-"): - name = "法语" - case lower == "de" || strings.HasPrefix(lower, "de-"): - name = "德语" - case lower == "es" || strings.HasPrefix(lower, "es-"): - name = "西班牙语" - case lower == "it" || strings.HasPrefix(lower, "it-"): - name = "意大利语" - case lower == "ru" || strings.HasPrefix(lower, "ru-"): - name = "俄语" - case lower == "th" || strings.HasPrefix(lower, "th-"): - name = "泰语" - } - if _, ok := seen[name]; ok { - continue - } - seen[name] = struct{}{} - out = append(out, name) - } - return strings.Join(out, "、") -} - -func telegramSeasonEpisodeValue(event NotifyEvent) string { - if value := telegramFirstValue(event.Data, "season_episode", "episode_tag"); value != "" { - return strings.ToUpper(value) - } - for _, raw := range []string{ - telegramFirstValue(event.Data, "resource_title", "torrent_title", "release_title"), - telegramFirstValue(event.Data, "title", "name"), - event.Message, - } { - if value := telegramExtractSeasonEpisode(raw); value != "" { - return value - } - } - season := telegramFirstValue(event.Data, "season") - episode := telegramFirstValue(event.Data, "episode") - if season != "" && episode != "" { - return fmt.Sprintf("S%02dE%02d", telegramEpisodeNumber(season), telegramEpisodeNumber(episode)) - } - return "" -} - -func telegramEpisodeNumber(raw string) int { - raw = strings.TrimSpace(strings.TrimLeft(strings.ToUpper(raw), "SE")) - raw = strings.TrimLeft(raw, "0") - if raw == "" { - return 0 - } - n, _ := strconv.Atoi(raw) - return n -} - -func telegramExtractSeasonEpisode(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - return strings.ToUpper(telegramSeasonEpisodePattern.FindString(raw)) -} - -func telegramSizeValue(data map[string]interface{}) string { - size := telegramFirstValue(data, "size") - bitrate := telegramFirstValue(data, "bitrate") - if size != "" && bitrate != "" { - return size + " / " + bitrate - } - if size != "" { - return size - } - return bitrate -} - -func telegramVersionValue(event NotifyEvent, seasonEpisode string) string { - if version := telegramFirstValue(event.Data, "version", "release_group"); version != "" && !strings.EqualFold(version, "best") { - return version - } - return telegramVersionFromResourceTitle( - telegramFirstValue(event.Data, "resource_title", "torrent_title", "release_title"), - seasonEpisode, - telegramFirstValue(event.Data, "year", "release_year"), - ) -} - -func telegramVersionFromResourceTitle(raw, seasonEpisode, year string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - tail := "" - if seasonEpisode != "" { - upperRaw := strings.ToUpper(raw) - upperEpisode := strings.ToUpper(seasonEpisode) - if idx := strings.Index(upperRaw, upperEpisode); idx >= 0 { - tail = raw[idx+len(seasonEpisode):] - } - } - if tail == "" && year != "" { - if idx := strings.LastIndex(raw, year); idx >= 0 { - tail = raw[idx+len(year):] - } - } - tail = strings.Trim(tail, " \t\r\n._-[]()【】") - if tail == "" { - return "" - } - tail = strings.TrimSuffix(tail, ".torrent") - tail = strings.TrimSuffix(tail, ".mkv") - tail = strings.TrimSuffix(tail, ".mp4") - tail = strings.Join(strings.Fields(tail), ".") - if len([]rune(tail)) > 72 { - tail = string([]rune(tail)[:72]) + "..." - } - return tail -} - -func telegramGenresValue(raw string) string { - raw = strings.TrimSpace(strings.Trim(raw, "[]")) - if raw == "" { - return "" - } - parts := strings.FieldsFunc(raw, func(r rune) bool { - return r == ',' || r == ',' || r == '/' || r == '|' || r == '、' - }) - if len(parts) <= 1 { - return raw - } - out := make([]string, 0, len(parts)) - seen := map[string]struct{}{} - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - if _, ok := seen[part]; ok { - continue - } - seen[part] = struct{}{} - out = append(out, part) - } - return strings.Join(out, "、") -} diff --git a/internal/service/notify_telegram_media.go b/internal/service/notify_telegram_media.go deleted file mode 100644 index fccc22b..0000000 --- a/internal/service/notify_telegram_media.go +++ /dev/null @@ -1,138 +0,0 @@ -package service - -import ( - "regexp" - "strings" -) - -const ( - telegramMediaTemplateHeader = "🐈‍⬛🐈‍⬛ MediaStationGo 更新啦 🐈‍⬛🐈‍⬛" - telegramMediaTemplateSeparator = "--------------------------------" -) - -var telegramSeasonEpisodePattern = regexp.MustCompile(`(?i)S\d{1,2}E\d{1,3}(?:[\-.~_ ]?E?\d{1,3})?`) - -func formatTelegramMediaNotification(event NotifyEvent) string { - if strings.TrimSpace(event.Type) == EventDownloadComplete { - return formatTelegramDownloadCompleteNotification(event) - } - - tag := telegramMediaTag(event.Data) - if tag == "" { - return "" - } - - title := telegramFirstValue(event.Data, "chinese_title", "title", "name", "media_title") - if title == "" { - title = telegramMessageFieldValue(event.Message, "任务", "订阅", "媒体", "资源") - } - originalTitle := telegramFirstValue(event.Data, "original_title", "original_name") - originalLanguage := telegramLanguageName(telegramFirstValue(event.Data, "original_language", "language", "languages")) - year := telegramFirstValue(event.Data, "year", "release_year") - category := telegramMediaCategory(event.Data) - seasonEpisode := telegramSeasonEpisodeValue(event) - size := telegramSizeValue(event.Data) - version := telegramVersionValue(event, seasonEpisode) - rating := telegramFirstValue(event.Data, "rating", "score") - genres := telegramGenresValue(telegramFirstValue(event.Data, "genres", "genre", "type")) - overview := telegramFirstValue(event.Data, "overview", "summary", "description") - links := telegramExternalLinks(event.Data) - - topFields := []string{} - if title != "" { - topFields = append(topFields, "📺 中文片名:"+escapeHTML(title)) - } - if originalTitle != "" && !strings.EqualFold(originalTitle, title) { - topFields = append(topFields, "🧿 原始片名:"+escapeHTML(originalTitle)) - } - if originalLanguage != "" { - topFields = append(topFields, "🌐 原始语言:"+escapeHTML(originalLanguage)) - } - if year != "" && year != "0" { - topFields = append(topFields, "📅 发行年份:"+escapeHTML(year)) - } - - mediaFields := []string{} - if category != "" { - mediaFields = append(mediaFields, "🐈‍⬛ 类别:"+escapeHTML(category)) - } - if seasonEpisode != "" { - mediaFields = append(mediaFields, "🫧 季集:"+escapeHTML(seasonEpisode)) - } - if size != "" { - mediaFields = append(mediaFields, "🔎 大小:"+escapeHTML(size)) - } - if version != "" { - mediaFields = append(mediaFields, "📁 版本:"+escapeHTML(version)) - } - - infoFields := []string{} - if rating != "" && rating != "0" { - infoFields = append(infoFields, "⭐️ 评分:"+escapeHTML(rating)) - } - if genres != "" { - infoFields = append(infoFields, "💎 类型:"+escapeHTML(genres)) - } - if overview != "" { - infoFields = append(infoFields, "🪬 简介:\n"+escapeHTML(overview)) - } - - if len(topFields) == 0 && len(mediaFields) == 0 && len(infoFields) == 0 && links == "" { - return "" - } - - lines := []string{telegramMediaTemplateHeader, telegramMediaTemplateSeparator, tag} - lines = append(lines, topFields...) - if len(mediaFields) > 0 { - if len(topFields) > 0 { - lines = append(lines, "") - } - lines = append(lines, mediaFields...) - } - if len(infoFields) > 0 { - if len(topFields) > 0 || len(mediaFields) > 0 { - lines = append(lines, "") - } - lines = append(lines, infoFields...) - } - if links != "" { - lines = append(lines, "", telegramMediaTemplateSeparator, links) - } - return strings.TrimSpace(strings.Join(lines, "\n")) -} - -func formatTelegramDownloadCompleteNotification(event NotifyEvent) string { - title := telegramMessageFieldValue(event.Message, "任务", "媒体", "资源") - if title == "" { - title = telegramFirstValue(event.Data, "title", "name", "media_title", "chinese_title", "resource_title") - } - if title == "" { - title = "下载任务" - } - return "#下载完成\n📺 任务:" + escapeHTML(title) -} - -func telegramMediaTag(data map[string]interface{}) string { - for _, key := range []string{"media_category", "category", "media_type"} { - value := strings.TrimSpace(telegramDataString(data, key)) - if value == "" { - continue - } - lower := strings.ToLower(value) - switch { - case strings.Contains(value, "电影") || lower == "movie": - return "#电影" - case strings.Contains(value, "剧") || lower == "tv" || lower == "series" || lower == "show": - return "#剧集" - case strings.Contains(value, "动漫") || strings.Contains(value, "动画") || lower == "anime": - return "#动漫" - case strings.Contains(value, "综艺") || lower == "variety": - return "#综艺" - case strings.Contains(value, "纪录") || lower == "documentary": - return "#纪录片" - default: - return "#" + escapeHTML(strings.ReplaceAll(value, " ", "")) - } - } - return "" -} diff --git a/internal/service/notify_webhook.go b/internal/service/notify_webhook.go deleted file mode 100644 index f6ff894..0000000 --- a/internal/service/notify_webhook.go +++ /dev/null @@ -1,119 +0,0 @@ -// Package service — Webhook 通知 Provider。 -package service - -import ( - "context" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -// WebhookProvider 通过自定义 HTTP Webhook 发送通知。 -// 支持自定义 HTTP 方法和请求头。 -type WebhookProvider struct{} - -// Send 发送 Webhook 通知。 -func (p *WebhookProvider) Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error { - webhookURL := cfg["url"] - if webhookURL == "" { - return fmt.Errorf("webhook: url is required") - } - - method := cfg["method"] - if method == "" { - method = "POST" - } - method = strings.ToUpper(method) - - // 构建请求体 - bodyTemplate := cfg["body_template"] - var bodyStr string - if bodyTemplate != "" { - bodyStr = renderTemplate(bodyTemplate, event) - } else { - // 默认 JSON 格式 - bodyStr = fmt.Sprintf(`{"type":"%s","title":"%s","message":"%s","data":{}}`, - event.Type, event.Title, event.Message) - if len(event.Data) > 0 { - var dataParts []string - for k, v := range event.Data { - dataParts = append(dataParts, fmt.Sprintf(`"%s":%v`, k, v)) - } - bodyStr = fmt.Sprintf(`{"type":"%s","title":"%s","message":"%s","data":{%s}}`, - event.Type, event.Title, event.Message, strings.Join(dataParts, ",")) - } - } - - req, err := http.NewRequestWithContext(ctx, method, webhookURL, strings.NewReader(bodyStr)) - if err != nil { - return err - } - - req.Header.Set("Content-Type", "application/json") - - // 自定义请求头 - headersJSON := cfg["headers_json"] - if headersJSON != "" { - headers := parseHeadersJSON(headersJSON) - for k, v := range headers { - req.Header.Set(k, v) - } - } - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("webhook error %d: %s", resp.StatusCode, string(respBody)) - } - return nil -} - -// ValidateConfig 验证 Webhook 配置。 -func (p *WebhookProvider) ValidateConfig(cfg map[string]string) error { - if cfg["url"] == "" { - return fmt.Errorf("webhook: url is required") - } - return nil -} - -// renderTemplate 简单模板渲染,支持 {{title}}, {{message}}, {{type}} 占位符。 -func renderTemplate(template string, event NotifyEvent) string { - result := template - result = strings.ReplaceAll(result, "{{title}}", event.Title) - result = strings.ReplaceAll(result, "{{message}}", event.Message) - result = strings.ReplaceAll(result, "{{type}}", event.Type) - return result -} - -// parseHeadersJSON 简单解析 headers JSON(格式: {"key":"value",...})。 -func parseHeadersJSON(jsonStr string) map[string]string { - result := make(map[string]string) - jsonStr = strings.TrimSpace(jsonStr) - if jsonStr == "" || (jsonStr[0] != '{' && jsonStr[len(jsonStr)-1] != '}') { - return result - } - - // 简单 key:value 解析 - inner := jsonStr[1 : len(jsonStr)-1] - parts := strings.Split(inner, ",") - for _, part := range parts { - kv := strings.SplitN(part, ":", 2) - if len(kv) != 2 { - continue - } - key := strings.Trim(strings.TrimSpace(kv[0]), `"`) - value := strings.Trim(strings.TrimSpace(kv[1]), `"`) - if key != "" { - result[key] = value - } - } - return result -} diff --git a/internal/service/notify_wechat.go b/internal/service/notify_wechat.go deleted file mode 100644 index 6794d87..0000000 --- a/internal/service/notify_wechat.go +++ /dev/null @@ -1,77 +0,0 @@ -// Package service — Server酱(WeChat) 通知 Provider。 -package service - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" -) - -// WechatProvider 通过 Server酱 API 推送消息到微信。 -// Server酱 API 文档: https://sct.ftqq.com/ -type WechatProvider struct{} - -// Send 发送 Server酱 推送消息。 -func (p *WechatProvider) Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error { - sendkey := cfg["sendkey"] - if sendkey == "" { - return fmt.Errorf("wechat: sendkey is required") - } - - payload := map[string]string{ - "title": event.Title, - "desp": event.Message, - } - if len(event.Data) > 0 { - payload["desp"] += "\n\n---\n\n" - for k, v := range event.Data { - payload["desp"] += fmt.Sprintf("- **%s**: %v\n", k, v) - } - } - - body, err := json.Marshal(payload) - if err != nil { - return err - } - - apiURL := fmt.Sprintf("https://sctapi.ftqq.com/%s.send", sendkey) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - respBody, _ := io.ReadAll(resp.Body) - if resp.StatusCode >= 400 { - return fmt.Errorf("wechat server酱 api error %d: %s", resp.StatusCode, string(respBody)) - } - - // 检查 Server酱 响应 - var result map[string]interface{} - if err := json.Unmarshal(respBody, &result); err == nil { - if code, ok := result["code"].(float64); ok && code != 0 { - msg, _ := result["message"].(string) - return fmt.Errorf("wechat server酱 error: %s", msg) - } - } - return nil -} - -// ValidateConfig 验证 Server酱 配置。 -func (p *WechatProvider) ValidateConfig(cfg map[string]string) error { - if cfg["sendkey"] == "" { - return fmt.Errorf("wechat: sendkey is required") - } - return nil -} diff --git a/internal/service/organizer.go b/internal/service/organizer.go index 6db7646..b6b1511 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -49,23 +49,6 @@ func (o *OrganizerService) SetProbe(p *FFprobeService) { o.probe = p } // metadata before it decides the final folder and filename. func (o *OrganizerService) SetScraper(s *ScraperService) { o.scraper = s } -// SetActiveDownloadPathProvider wires a live downloader snapshot. Directory -// organize must never move/copy files that still belong to an unfinished -// torrent, regardless of which UI switch triggered the organize operation. -func (o *OrganizerService) SetActiveDownloadPathProvider(provider func(context.Context) []string) { - o.activeDownloadPaths = provider -} - -func (o *OrganizerService) SetActiveDownloadProvider(provider func(context.Context) []QBitTorrent) { - if provider == nil { - o.activeDownloadPaths = nil - return - } - o.activeDownloadPaths = func(ctx context.Context) []string { - return activeDownloadPathCandidates(provider(ctx), nil) - } -} - // OrganizeMedia moves a single media file into the target library directory. // It auto-detects whether the media is a movie or TV episode based on the // parsed season/episode numbers and builds the destination path accordingly. diff --git a/internal/service/organizer_active_downloads.go b/internal/service/organizer_active_downloads.go index cd2392c..961f84a 100644 --- a/internal/service/organizer_active_downloads.go +++ b/internal/service/organizer_active_downloads.go @@ -31,41 +31,6 @@ func (g activeDownloadGuard) contains(path string) bool { return false } -func activeDownloadPathCandidates(torrents []QBitTorrent, mappings map[string]string) []string { - if len(torrents) == 0 { - return nil - } - var out []string - for _, torrent := range torrents { - if qbitTorrentCompleted(torrent) { - continue - } - for _, raw := range []string{ - torrent.ContentPath, - filepath.Join(strings.TrimSpace(torrent.SavePath), strings.TrimSpace(torrent.Name)), - } { - out = appendDownloadPathCandidates(out, raw, mappings) - } - } - return cleanUniqueExistingPaths(out) -} - -func appendDownloadPathCandidates(out []string, raw string, mappings map[string]string) []string { - raw = strings.TrimSpace(raw) - if raw == "" || raw == "." { - return out - } - if translated := translateClientPath(raw, mappings); translated != "" { - out = append(out, translated) - } - for _, candidate := range mappedPathCandidates(raw) { - if _, err := os.Stat(candidate); err == nil { - out = append(out, candidate) - } - } - return out -} - func cleanUniqueExistingPaths(paths []string) []string { if len(paths) == 0 { return nil diff --git a/internal/service/organizer_directory_source_test.go b/internal/service/organizer_directory_source_test.go index 6f1bdd5..1a83ae5 100644 --- a/internal/service/organizer_directory_source_test.go +++ b/internal/service/organizer_directory_source_test.go @@ -1,7 +1,6 @@ package service import ( - "context" "os" "path/filepath" "testing" @@ -34,48 +33,6 @@ func TestOrganizeDirectoryUsesConfiguredSourceWhenRequestSourceEmpty(t *testing. } } -func TestOrganizeDirectorySkipsActiveQBitTorrentDownloads(t *testing.T) { - root := t.TempDir() - src := filepath.Join(root, "downloads") - dest := filepath.Join(root, "media") - active := filepath.Join(src, "Still.Downloading.2026.1080p.mkv") - done := filepath.Join(src, "Dune 2021 2160p WEB-DL.mkv") - writeOrgFile(t, active, "partial") - writeOrgFile(t, done, "done") - - org := NewOrganizerService(&config.Config{}, zap.NewNop(), newOrganizerTestRepo(t)) - org.SetActiveDownloadProvider(func(context.Context) []QBitTorrent { - return []QBitTorrent{{ - Hash: "active", - Name: "Still.Downloading.2026.1080p", - State: "downloading", - Progress: 0.42, - SavePath: src, - ContentPath: active, - }} - }) - res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ - SourcePath: src, - DestPath: dest, - TransferMode: TransferMove, - }) - if err != nil { - t.Fatalf("organize directory: %v", err) - } - if res.Organized != 1 || res.Skipped != 1 { - t.Fatalf("result = %+v, want organized=1 skipped=1", res) - } - if _, err := os.Stat(active); err != nil { - t.Fatalf("active qB file must stay in source: %v", err) - } - for _, item := range res.Items { - if item.Source == active && item.Action == "skip" && item.Reason == organizeSkipActiveDownload { - return - } - } - t.Fatalf("missing active-download skip item: %+v", res.Items) -} - func TestOrganizeDirectoryMapsConfiguredHostPathsToContainerPaths(t *testing.T) { root := t.TempDir() hostDownloads := filepath.Join(root, "nas-host", "downloads") diff --git a/internal/service/organizer_reclassify_cloud.go b/internal/service/organizer_reclassify_cloud.go deleted file mode 100644 index 4a5b480..0000000 --- a/internal/service/organizer_reclassify_cloud.go +++ /dev/null @@ -1,186 +0,0 @@ -package service - -import ( - "context" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (o *OrganizerService) reclassifyCloudScannedMedia(ctx context.Context, media model.Media, lib model.Library, mount CloudMountInfo, mediaTypeHint string, dryRun bool, res *OrganizeResult) (bool, error) { - mediaType := normalizeOrganizeMediaType(lib.Type) - if mediaTypeHint != "" { - mediaType = mediaTypeHint - } - metadataMatch := organizeMatchFromMedia(&media) - if metadataMatch != nil && mediaTypeHint != "" { - metadataMatch.MediaType = mediaTypeHint - } - if !mediaHasReliableCategoryMetadata(media) { - metadataMatch = o.lookupReclassifyMetadata(ctx, media, lib, mediaType) - if metadataMatch == nil { - return false, nil - } - media = mediaWithReclassifyMatch(media, metadataMatch) - } - if matchType := normalizeOrganizeMediaType(metadataMatchMediaType(metadataMatch)); matchType != "" { - mediaType = matchType - } - category := o.classifyMedia(ctx, &media, mediaType) - if category == "" { - return false, nil - } - if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" { - mediaType = reconcileOrganizeCategoryMediaType(mediaType, impliedType) - category = normalizedCategory - } - if mediaType == "" { - mediaType = normalizeOrganizeMediaType(lib.Type) - } - displayDir := o.cloudReclassifyCategoryDisplayDir(mediaType, category) - if displayDir == "" { - return false, nil - } - if normalizeCloudMountDir(mount.Provider, mount.DisplayDir) == normalizeCloudMountDir(mount.Provider, displayDir) { - return false, nil - } - targetLibrary, ok, err := o.ensureCloudReclassifyLibrary(ctx, mount.Provider, displayDir, mediaType, dryRun) - if err != nil || !ok { - return false, err - } - if strings.TrimSpace(targetLibrary.ID) != "" && targetLibrary.ID == lib.ID { - return false, nil - } - title := sanitizeFilename(strings.TrimSpace(media.Title)) - if title == "" { - title = "Unknown" - } - res.Items = append(res.Items, OrganizePreviewItem{ - Source: media.Path, - Target: targetLibrary.Path, - Action: "reclassify", - Reason: "cloud metadata category library changed", - MediaType: mediaType, - Category: category, - Title: title, - }) - if dryRun { - res.Reclassified++ - return true, nil - } - updates := map[string]any{ - "library_id": targetLibrary.ID, - "series_id": "", - } - if normalizeOrganizeMediaType(mediaType) == "movie" { - updates["season_num"] = 0 - updates["episode_num"] = 0 - updates["episode_title"] = "" - } - applyReclassifyMatchUpdates(updates, metadataMatch) - if err := o.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", media.ID).Updates(updates).Error; err != nil { - return false, err - } - if o.log != nil { - o.log.Info("cloud media library reclassified by metadata", - zap.String("media", media.ID), - zap.String("path", media.Path), - zap.String("from_library", lib.ID), - zap.String("to_library", targetLibrary.ID), - zap.String("category", category), - zap.String("media_type", mediaType), - zap.String("display_dir", displayDir)) - } - res.Reclassified++ - return true, nil -} - -func (o *OrganizerService) cloudReclassifyCategoryDisplayDir(mediaType, category string) string { - category = sanitizeFilename(strings.TrimSpace(category)) - if category == "" { - return "" - } - root := o.mediaTypeRootDirForCategory(mediaType, category) - if root == "" { - return "" - } - return strings.Join([]string{root, category}, "/") -} - -func (o *OrganizerService) ensureCloudReclassifyLibrary(ctx context.Context, provider, displayDir, mediaType string, dryRun bool) (model.Library, bool, error) { - if o == nil || o.repo == nil || o.repo.Library == nil { - return model.Library{}, false, nil - } - provider = strings.TrimSpace(provider) - displayDir = normalizeCloudMountDir(provider, displayDir) - if provider == "" || displayDir == "" { - return model.Library{}, false, nil - } - if existing := o.findCloudReclassifyLibrary(ctx, provider, displayDir); existing != nil { - return *existing, true, nil - } - path := BuildCloudAutoCategoryLibraryPath(provider, displayDir) - if path == "" { - return model.Library{}, false, nil - } - name := cloudMountDirBase(displayDir) - if name == "" { - name = displayDir - } - libType := InferCloudMountMediaType(displayDir, name) - if normalizeOrganizeMediaType(libType) == "" { - libType = organizeLibraryModelType(mediaType) - } - lib := model.Library{ - Name: name, - Path: path, - Type: libType, - Enabled: true, - } - if dryRun { - return lib, true, nil - } - if err := o.repo.Library.Create(ctx, &lib); err != nil { - if existing := o.findCloudReclassifyLibrary(ctx, provider, displayDir); existing != nil { - return *existing, true, nil - } - return model.Library{}, false, err - } - if o.log != nil { - o.log.Info("created cloud metadata reclassify library", - zap.String("library_id", lib.ID), - zap.String("provider", provider), - zap.String("display_dir", displayDir), - zap.String("type", lib.Type)) - } - return lib, true, nil -} - -func (o *OrganizerService) findCloudReclassifyLibrary(ctx context.Context, provider, displayDir string) *model.Library { - if o == nil || o.repo == nil || o.repo.Library == nil { - return nil - } - libs, err := o.repo.Library.List(ctx) - if err != nil { - if o.log != nil { - o.log.Warn("list cloud libraries for metadata reclassify failed", zap.Error(err)) - } - return nil - } - displayDir = normalizeCloudMountDir(provider, displayDir) - for _, lib := range libs { - if !lib.Enabled { - continue - } - info, ok := ParseCloudLibraryMount(lib.Path) - if !ok || info.Provider != provider { - continue - } - if normalizeCloudMountDir(provider, info.DisplayDir) == displayDir { - return &lib - } - } - return nil -} diff --git a/internal/service/organizer_reclassify_scanned.go b/internal/service/organizer_reclassify_scanned.go index 745efd0..b3a4730 100644 --- a/internal/service/organizer_reclassify_scanned.go +++ b/internal/service/organizer_reclassify_scanned.go @@ -107,9 +107,6 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod if res == nil || !lib.Enabled || strings.TrimSpace(media.Path) == "" { return false, nil } - if mount, ok := ParseCloudLibraryMount(lib.Path); ok { - return o.reclassifyCloudScannedMedia(ctx, media, lib, mount, mediaTypeHint, dryRun, res) - } if !organizeFileExists(media.Path) { return false, nil } diff --git a/internal/service/permission.go b/internal/service/permission.go index 87e2382..710e82a 100644 --- a/internal/service/permission.go +++ b/internal/service/permission.go @@ -40,9 +40,8 @@ func DefaultPermissions(userID string) *model.UserPermission { CanRescrape: false, CanUseAI: false, CanCaptureFrames: false, - CanManageDownloads: false, - CanViewDiscover: false, - CanManageSubscriptions: false, + CanManageDownloads: false, + CanManageSubscriptions: false, CanManageSites: false, CanUseAIAssistant: false, CanManageUsers: false, @@ -66,9 +65,8 @@ func adminGrant(userID string) *model.UserPermission { CanRescrape: true, CanUseAI: true, CanCaptureFrames: true, - CanManageDownloads: true, - CanViewDiscover: true, - CanManageSubscriptions: true, + CanManageDownloads: true, + CanManageSubscriptions: true, CanManageSites: true, CanUseAIAssistant: true, CanManageUsers: true, diff --git a/internal/service/qbittorrent.go b/internal/service/qbittorrent.go deleted file mode 100644 index 0a1d89e..0000000 --- a/internal/service/qbittorrent.go +++ /dev/null @@ -1,310 +0,0 @@ -// Package service — qBittorrent Web UI client. -// -// QBitClient is a thin wrapper around the qBittorrent /api/v2 REST API -// (https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API). -// -// We only need three operations for the download flow: -// -// POST /auth/login -// POST /torrents/add (multipart, accepts magnet URL or .torrent bytes) -// GET /torrents/info (filtered by hash) -// -// The client stores the SID cookie returned by /auth/login and reuses it -// across calls. Re-auth happens transparently on 403. -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/cookiejar" - "net/url" - "strings" - "sync" - "time" - - "go.uber.org/zap" -) - -// QBitConfig holds the connection settings (typically loaded from the -// system Setting table or an env var). -type QBitConfig struct { - BaseURL string - Username string - Password string -} - -// QBitTorrent is the subset of /torrents/info we surface to the API. -type QBitTorrent struct { - Hash string `json:"hash"` - ClientID string `json:"client_id,omitempty"` - Source string `json:"source,omitempty"` - Name string `json:"name"` - State string `json:"state"` - Progress float32 `json:"progress"` - DLSpeed int64 `json:"dlspeed"` - UpSpeed int64 `json:"upspeed"` - NumSeeds int `json:"num_seeds"` - NumLeech int `json:"num_leechs"` - Size int64 `json:"size"` - SavePath string `json:"save_path"` - Category string `json:"category"` - // ContentPath is qBittorrent's resolved payload path. For single-file - // torrents it points at the file; for multi-file torrents it points at the - // root folder. Prefer it for automatic organize so we do not scan the whole - // download category. - ContentPath string `json:"content_path"` - // CompletionOn 是 qBittorrent 报告的完成时间(Unix 秒,未完成为 0 或负值)。 - // 用于应用重启后的「补整理」判断:只补最近完成的种子,避免每次启动 - // 都重新触发全部历史种子的整理。 - CompletionOn int64 `json:"completion_on"` -} - -// QBitClient is a thread-safe qBittorrent v2 API client. -type QBitClient struct { - log *zap.Logger - mu sync.Mutex - cfg QBitConfig - client *http.Client -} - -var ( - qbitAddVerifyAttempts = 10 - qbitAddVerifyInterval = 800 * time.Millisecond -) - -// NewQBitClient builds a fresh client. A blank URL intentionally stays blank: -// an unconfigured downloader must fail closed instead of silently trying a -// localhost qBittorrent instance. -func NewQBitClient(log *zap.Logger, cfg QBitConfig) *QBitClient { - cfg.BaseURL = normalizeQBitBaseURL(cfg.BaseURL) - jar, _ := cookiejar.New(nil) - client := NewInternalHTTPClient(20 * time.Second) - client.Jar = jar - return &QBitClient{ - log: log, - cfg: cfg, - client: client, - } -} - -// Configure rotates the client to a new endpoint and re-auths next call. -func (q *QBitClient) Configure(cfg QBitConfig) { - q.mu.Lock() - defer q.mu.Unlock() - cfg.BaseURL = normalizeQBitBaseURL(cfg.BaseURL) - q.cfg = cfg - jar, _ := cookiejar.New(nil) - q.client.Jar = jar -} - -func (q *QBitClient) IsConfigured() bool { - q.mu.Lock() - defer q.mu.Unlock() - return strings.TrimSpace(q.cfg.BaseURL) != "" -} - -func normalizeQBitBaseURL(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - normalized, err := normalizeDownloadClientEndpoint("qbittorrent", raw) - if err != nil { - return strings.TrimRight(raw, "/") - } - return normalized -} - -// Login performs POST /api/v2/auth/login. -func (q *QBitClient) Login(ctx context.Context) error { - if q.cfg.BaseURL == "" { - return errors.New("qbittorrent base url not configured") - } - return qbitLogin(ctx, q.client, q.cfg.BaseURL, q.cfg.Username, q.cfg.Password) -} - -// List returns every torrent (optionally filtered by status: all / downloading / completed). -func (q *QBitClient) List(ctx context.Context, filter string) ([]QBitTorrent, error) { - q.mu.Lock() - defer q.mu.Unlock() - if err := q.ensureAuth(ctx); err != nil { - return nil, err - } - return q.listLocked(ctx, filter) -} - -func (q *QBitClient) listLocked(ctx context.Context, filter string) ([]QBitTorrent, error) { - u := strings.TrimRight(q.cfg.BaseURL, "/") + "/api/v2/torrents/info" - req, err := newDownloadClientHTTPRequest(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, err - } - if filter != "" { - query := req.URL.Query() - query.Set("filter", filter) - req.URL.RawQuery = query.Encode() - } - req.Header.Set("Referer", q.cfg.BaseURL) - resp, err := q.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("qbittorrent list: %d", resp.StatusCode) - } - var out []QBitTorrent - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return nil, err - } - return out, nil -} - -// Delete removes a torrent (optionally with its files). -func (q *QBitClient) Delete(ctx context.Context, hash string, deleteFiles bool) error { - q.mu.Lock() - defer q.mu.Unlock() - if err := q.ensureAuth(ctx); err != nil { - return err - } - form := url.Values{} - form.Set("hashes", hash) - if deleteFiles { - form.Set("deleteFiles", "true") - } else { - form.Set("deleteFiles", "false") - } - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, - strings.TrimRight(q.cfg.BaseURL, "/")+"/api/v2/torrents/delete", - strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Referer", q.cfg.BaseURL) - req.Header.Set("Origin", q.cfg.BaseURL) - resp, err := q.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return fmt.Errorf("qbittorrent delete: %d", resp.StatusCode) - } - return nil -} - -func (q *QBitClient) Pause(ctx context.Context, hash string) error { - return q.torrentAction(ctx, hash, "pause", "stop") -} - -func (q *QBitClient) Resume(ctx context.Context, hash string) error { - return q.torrentAction(ctx, hash, "resume", "start") -} - -func (q *QBitClient) torrentAction(ctx context.Context, hash string, actions ...string) error { - q.mu.Lock() - defer q.mu.Unlock() - if err := q.ensureAuth(ctx); err != nil { - return err - } - baseURL := strings.TrimRight(q.cfg.BaseURL, "/") - form := url.Values{"hashes": []string{hash}} - var lastErr error - for _, action := range actions { - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, - baseURL+"/api/v2/torrents/"+action, strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Referer", baseURL) - req.Header.Set("Origin", baseURL) - resp, err := q.client.Do(req) - if err != nil { - return err - } - body, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - if resp.StatusCode < 400 { - return nil - } - lastErr = fmt.Errorf("qbittorrent %s: %d: %s", action, resp.StatusCode, strings.TrimSpace(string(body))) - if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusMethodNotAllowed { - break - } - } - return lastErr -} - -// SetLocation moves a torrent's data to a new save directory via -// POST /api/v2/torrents/setLocation. qBittorrent performs the physical move -// itself and keeps seeding from the new location — this is the seeding-safe -// way to relocate downloaded PT files. location must be an absolute path the -// qBittorrent process can write to. -func (q *QBitClient) SetLocation(ctx context.Context, hash, location string) error { - if strings.TrimSpace(hash) == "" { - return errors.New("qbittorrent setLocation: empty hash") - } - if strings.TrimSpace(location) == "" { - return errors.New("qbittorrent setLocation: empty location") - } - q.mu.Lock() - defer q.mu.Unlock() - if err := q.ensureAuth(ctx); err != nil { - return err - } - form := url.Values{} - form.Set("hashes", hash) - form.Set("location", location) - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, - strings.TrimRight(q.cfg.BaseURL, "/")+"/api/v2/torrents/setLocation", - strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Referer", q.cfg.BaseURL) - req.Header.Set("Origin", q.cfg.BaseURL) - resp, err := q.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode == http.StatusBadRequest { - return errors.New("qbittorrent setLocation: 保存路径无效") - } - if resp.StatusCode == http.StatusConflict { - return errors.New("qbittorrent setLocation: 无法写入目标路径 (权限或磁盘问题)") - } - if resp.StatusCode >= 400 { - return fmt.Errorf("qbittorrent setLocation: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) - } - q.log.Info("qbittorrent: torrent relocated", zap.String("hash", hash), zap.String("location", location)) - return nil -} - -// ensureAuth makes sure we have a valid SID cookie. Cheap on the happy -// path; logs in transparently otherwise. -func (q *QBitClient) ensureAuth(ctx context.Context) error { - if strings.TrimSpace(q.cfg.BaseURL) == "" { - return errors.New("qbittorrent base url not configured") - } - u, err := url.Parse(q.cfg.BaseURL) - if err != nil { - return err - } - if cookies := q.client.Jar.Cookies(u); len(cookies) > 0 { - for _, c := range cookies { - if strings.EqualFold(c.Name, "SID") && c.Value != "" { - return nil - } - } - } - return q.Login(ctx) -} diff --git a/internal/service/qbittorrent_add.go b/internal/service/qbittorrent_add.go deleted file mode 100644 index 2f4705b..0000000 --- a/internal/service/qbittorrent_add.go +++ /dev/null @@ -1,233 +0,0 @@ -package service - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/url" - "path" - "strings" - "time" - - "go.uber.org/zap" -) - -// AddTorrent submits a magnet URL or HTTP(S) URL to qBittorrent. -// -// qBittorrent 的 /api/v2/torrents/add 在很多失败场景下仍然返回 HTTP 200 -// 但 body 里写 "Fails."。我们把这些情况也识别为错误并返回,避免 -// "API 返回 200 → 我们告诉前端成功 → qb 中却没下载" 这种迷惑性失败。 -func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath string) error { - return q.AddTorrentWithCategory(ctx, magnetOrURL, savePath, "") -} - -func (q *QBitClient) AddTorrentWithCategory(ctx context.Context, magnetOrURL, savePath, category string) error { - q.mu.Lock() - defer q.mu.Unlock() - if err := q.ensureAuth(ctx); err != nil { - return err - } - - torrentData, torrentName, fetchErr := q.fetchTorrentFile(ctx, magnetOrURL) - useFileUpload := fetchErr == nil && len(torrentData) > 0 - return q.addTorrentLocked(ctx, magnetOrURL, torrentData, torrentName, useFileUpload, savePath, category) -} - -func (q *QBitClient) AddTorrentFile(ctx context.Context, data []byte, name, savePath string) error { - return q.AddTorrentFileWithCategory(ctx, data, name, savePath, "") -} - -func (q *QBitClient) AddTorrentFileWithCategory(ctx context.Context, data []byte, name, savePath, category string) error { - if len(data) == 0 { - return errors.New("empty torrent data") - } - q.mu.Lock() - defer q.mu.Unlock() - if err := q.ensureAuth(ctx); err != nil { - return err - } - return q.addTorrentLocked(ctx, "", data, name, true, savePath, category) -} - -func (q *QBitClient) addTorrentLocked(ctx context.Context, magnetOrURL string, torrentData []byte, torrentName string, useFileUpload bool, savePath, category string) error { - before, beforeErr := q.listLocked(ctx, "") - beforeHashes := make(map[string]struct{}, len(before)) - if beforeErr == nil { - for _, torrent := range before { - if torrent.Hash != "" { - beforeHashes[strings.ToLower(torrent.Hash)] = struct{}{} - } - } - } - if useFileUpload && beforeErr == nil { - if hash := torrentInfoHash(torrentData); hash != "" { - if _, ok := beforeHashes[hash]; ok { - q.log.Info("qbittorrent: torrent already exists", zap.String("hash", hash), zap.String("name", torrentName)) - return ErrDownloadAlreadyExists - } - } - } - - body := &bytes.Buffer{} - w := multipart.NewWriter(body) - if useFileUpload { - if strings.TrimSpace(torrentName) == "" { - torrentName = "download.torrent" - } - part, err := w.CreateFormFile("torrents", torrentName) - if err != nil { - return err - } - if _, err := part.Write(torrentData); err != nil { - return err - } - } else { - _ = w.WriteField("urls", magnetOrURL) - } - if savePath != "" { - _ = w.WriteField("savepath", savePath) - } - if strings.TrimSpace(category) != "" { - _ = w.WriteField("category", sanitizeQBitCategory(category)) - } - _ = w.Close() - - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, - strings.TrimRight(q.cfg.BaseURL, "/")+"/api/v2/torrents/add", body) - if err != nil { - return err - } - req.Header.Set("Content-Type", w.FormDataContentType()) - req.Header.Set("Referer", q.cfg.BaseURL) - req.Header.Set("Origin", q.cfg.BaseURL) - - resp, err := q.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - raw, _ := io.ReadAll(resp.Body) - bodyText := strings.TrimSpace(string(raw)) - if resp.StatusCode >= 400 { - return fmt.Errorf("qbittorrent add: HTTP %d: %s", resp.StatusCode, bodyText) - } - // qb 的成功响应是 "Ok." 或空体;任何 "Fails." 视为失败。 - if strings.EqualFold(bodyText, "Fails.") { - return fmt.Errorf("qbittorrent add: 拒绝任务 (检查 URL 是否需要认证或 savePath 是否可写)") - } - if beforeErr == nil { - accepted := false - var lastListErr error - for attempt := 0; attempt < qbitAddVerifyAttempts; attempt++ { - if attempt > 0 { - time.Sleep(qbitAddVerifyInterval) - } - after, err := q.listLocked(ctx, "") - if err != nil { - lastListErr = err - continue - } - for _, torrent := range after { - if torrent.Hash == "" { - continue - } - if _, ok := beforeHashes[torrent.Hash]; !ok { - accepted = true - break - } - } - if accepted { - break - } - } - if !accepted { - if lastListErr != nil { - return fmt.Errorf("qbittorrent add: 无法确认任务已加入下载器: %w", lastListErr) - } - return fmt.Errorf("qbittorrent add: 下载器未出现新任务,可能种子已存在或 URL 未被下载器接受") - } - } - q.log.Info("qbittorrent: torrent added", - zap.String("url", redactTorrentURL(magnetOrURL)), - zap.String("save_path", savePath), - zap.String("category", sanitizeQBitCategory(category)), - zap.Bool("file_upload", useFileUpload), - zap.String("body", bodyText)) - return nil -} - -func sanitizeQBitCategory(category string) string { - return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(category, "\r", " "), "\n", " ")) -} - -func redactTorrentURL(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if strings.HasPrefix(strings.ToLower(raw), "magnet:") { - return "magnet:?xt=***" - } - u, err := url.Parse(raw) - if err != nil || u.Host == "" { - return "[redacted-download-url]" - } - u.RawQuery = "" - u.Fragment = "" - return u.String() -} - -func (q *QBitClient) fetchTorrentFile(ctx context.Context, raw string) ([]byte, string, error) { - u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || u.Host == "" { - return nil, "", errors.New("not a remote URL") - } - if u.Scheme != "http" && u.Scheme != "https" { - return nil, "", errors.New("not an HTTP torrent URL") - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil) - if err != nil { - return nil, "", err - } - req.Header.Set("User-Agent", "MediaStationGo/0.1") - req.Header.Set("Accept", "application/x-bittorrent,application/octet-stream,*/*") - - client := NewExternalHTTPClient(30 * time.Second) - resp, err := client.Do(req) - if err != nil { - return nil, "", err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, "", fmt.Errorf("torrent fetch: HTTP %d", resp.StatusCode) - } - - const maxTorrentSize = 32 << 20 - data, err := io.ReadAll(io.LimitReader(resp.Body, maxTorrentSize+1)) - if err != nil { - return nil, "", err - } - if len(data) == 0 { - return nil, "", errors.New("torrent fetch: empty body") - } - if len(data) > maxTorrentSize { - return nil, "", errors.New("torrent fetch: body too large") - } - if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") { - return nil, "", errors.New("torrent fetch: upstream returned HTML") - } - - name := strings.TrimSpace(path.Base(u.Path)) - if name == "" || name == "." || name == "/" { - name = "download.torrent" - } - if !strings.HasSuffix(strings.ToLower(name), ".torrent") { - name += ".torrent" - } - return data, name, nil -} diff --git a/internal/service/qbittorrent_adp.go b/internal/service/qbittorrent_adp.go deleted file mode 100644 index bd541cd..0000000 --- a/internal/service/qbittorrent_adp.go +++ /dev/null @@ -1,285 +0,0 @@ -// Package service — qBittorrent 下载适配器。 -// -// QBitAdapter 实现了 DownloadAdapter 接口,通过 qBittorrent WebUI API -// 管理下载任务。底层使用与 QBitClient 相同的 HTTP API 调用逻辑。 -package service - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/http/cookiejar" - "net/url" - "strings" - "sync" - "time" -) - -// QBitAdapter 是 qBittorrent 的 DownloadAdapter 实现。 -type QBitAdapter struct { - mu sync.Mutex - cfg DownloadClientConfig - client *http.Client - LoggedIn bool -} - -// NewQBitAdapter 创建新的 qBittorrent 适配器。 -func NewQBitAdapter() *QBitAdapter { - jar, _ := cookiejar.New(nil) - client := NewInternalHTTPClient(20 * time.Second) - client.Jar = jar - return &QBitAdapter{ - client: client, - } -} - -// Initialize 配置并初始化 qBittorrent 连接。 -func (a *QBitAdapter) Initialize(ctx context.Context, cfg DownloadClientConfig) error { - a.mu.Lock() - defer a.mu.Unlock() - endpoint, err := normalizeDownloadClientEndpoint("qbittorrent", cfg.Host) - if err != nil { - return err - } - cfg.Host = endpoint - a.cfg = cfg - a.LoggedIn = false - jar, _ := cookiejar.New(nil) - a.client.Jar = jar - return a.loginLocked(ctx) -} - -// Ping 测试连接。 -func (a *QBitAdapter) Ping(ctx context.Context) error { - a.mu.Lock() - defer a.mu.Unlock() - return a.loginLocked(ctx) -} - -// AddTorrent 通过 URL 添加种子。 -func (a *QBitAdapter) AddTorrent(ctx context.Context, torrentURL, savePath string) (string, error) { - return a.AddTorrentWithCategory(ctx, torrentURL, savePath, "") -} - -// AddTorrentWithCategory submits a URL or magnet while preserving the -// qBittorrent category selected by MediaStationGo's classification rules. -func (a *QBitAdapter) AddTorrentWithCategory(ctx context.Context, torrentURL, savePath, category string) (string, error) { - a.mu.Lock() - defer a.mu.Unlock() - if err := a.ensureAuthLocked(ctx); err != nil { - return "", err - } - - body := &bytes.Buffer{} - w := multipart.NewWriter(body) - _ = w.WriteField("urls", torrentURL) - return a.addTorrentMultipartLocked(ctx, body, w, savePath, category, torrentURLInfoHash(torrentURL)) -} - -// AddMagnet 通过磁力链接添加种子。 -func (a *QBitAdapter) AddMagnet(ctx context.Context, magnet, savePath string) (string, error) { - return a.AddTorrent(ctx, magnet, savePath) -} - -// AddTorrentFile uploads .torrent bytes as multipart/form-data. -func (a *QBitAdapter) AddTorrentFile(ctx context.Context, data []byte, name, savePath string) (string, error) { - return a.AddTorrentFileWithCategory(ctx, data, name, savePath, "") -} - -// AddTorrentFileWithCategory uploads .torrent bytes and preserves the -// qBittorrent category selected by the caller. -func (a *QBitAdapter) AddTorrentFileWithCategory(ctx context.Context, data []byte, name, savePath, category string) (string, error) { - a.mu.Lock() - defer a.mu.Unlock() - if err := a.ensureAuthLocked(ctx); err != nil { - return "", err - } - body := &bytes.Buffer{} - w := multipart.NewWriter(body) - part, err := w.CreateFormFile("torrents", name) - if err != nil { - return "", err - } - if _, err := part.Write(data); err != nil { - return "", err - } - return a.addTorrentMultipartLocked(ctx, body, w, savePath, category, torrentInfoHash(data)) -} - -func (a *QBitAdapter) addTorrentMultipartLocked(ctx context.Context, body *bytes.Buffer, w *multipart.Writer, savePath, category, externalID string) (string, error) { - if savePath != "" { - _ = w.WriteField("savepath", savePath) - } - if category != "" { - _ = w.WriteField("category", category) - } - if err := w.Close(); err != nil { - return "", err - } - - baseURL := strings.TrimRight(a.cfg.Host, "/") - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, - baseURL+"/api/v2/torrents/add", body) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", w.FormDataContentType()) - req.Header.Set("Referer", baseURL) - req.Header.Set("Origin", baseURL) - - resp, err := a.client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - raw, _ := io.ReadAll(resp.Body) - if resp.StatusCode >= 400 { - return "", fmt.Errorf("qbittorrent add torrent: %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) - } - if strings.EqualFold(strings.TrimSpace(string(raw)), "Fails.") { - return externalID, errors.New("qbittorrent add torrent: rejected by downloader") - } - return externalID, nil -} - -// Pause 暂停种子。 -func (a *QBitAdapter) Pause(ctx context.Context, hash string) error { - a.mu.Lock() - defer a.mu.Unlock() - if err := a.ensureAuthLocked(ctx); err != nil { - return err - } - return a.postTorrentActionLocked(ctx, hash, "pause", "stop") -} - -// Resume 恢复种子。 -func (a *QBitAdapter) Resume(ctx context.Context, hash string) error { - a.mu.Lock() - defer a.mu.Unlock() - if err := a.ensureAuthLocked(ctx); err != nil { - return err - } - return a.postTorrentActionLocked(ctx, hash, "resume", "start") -} - -func (a *QBitAdapter) postTorrentActionLocked(ctx context.Context, hash string, primary, fallback string) error { - baseURL := strings.TrimRight(a.cfg.Host, "/") - form := url.Values{} - form.Set("hashes", hash) - var lastErr error - for _, action := range []string{primary, fallback} { - if action == "" { - continue - } - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, - baseURL+"/api/v2/torrents/"+action, strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Referer", baseURL) - req.Header.Set("Origin", baseURL) - resp, err := a.client.Do(req) - if err != nil { - return err - } - body, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - if resp.StatusCode < 400 { - return nil - } - lastErr = fmt.Errorf("qbittorrent %s: %d: %s", action, resp.StatusCode, strings.TrimSpace(string(body))) - if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusMethodNotAllowed { - break - } - } - return lastErr -} - -// Remove 删除种子。 -func (a *QBitAdapter) Remove(ctx context.Context, hash string, deleteFiles bool) error { - a.mu.Lock() - defer a.mu.Unlock() - if err := a.ensureAuthLocked(ctx); err != nil { - return err - } - baseURL := strings.TrimRight(a.cfg.Host, "/") - form := url.Values{} - form.Set("hashes", hash) - if deleteFiles { - form.Set("deleteFiles", "true") - } else { - form.Set("deleteFiles", "false") - } - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, - baseURL+"/api/v2/torrents/delete", strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Referer", baseURL) - req.Header.Set("Origin", baseURL) - resp, err := a.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return fmt.Errorf("qbittorrent delete: %d", resp.StatusCode) - } - return nil -} - -func (a *QBitAdapter) Relocate(ctx context.Context, hash, location string) error { - a.mu.Lock() - defer a.mu.Unlock() - if err := a.ensureAuthLocked(ctx); err != nil { - return err - } - baseURL := strings.TrimRight(a.cfg.Host, "/") - form := url.Values{} - form.Set("hashes", hash) - form.Set("location", location) - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, - baseURL+"/api/v2/torrents/setLocation", strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Referer", baseURL) - req.Header.Set("Origin", baseURL) - resp, err := a.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode >= 400 { - return fmt.Errorf("qbittorrent setLocation: %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) - } - return nil -} - -// loginLocked 执行登录(调用者必须持有锁)。 -func (a *QBitAdapter) loginLocked(ctx context.Context) error { - if a.cfg.Host == "" { - return fmt.Errorf("qbittorrent host not configured") - } - if err := qbitLogin(ctx, a.client, a.cfg.Host, a.cfg.Username, a.cfg.Password); err != nil { - return err - } - a.LoggedIn = true - return nil -} - -// ensureAuthLocked 确保已认证(调用者必须持有锁)。 -func (a *QBitAdapter) ensureAuthLocked(ctx context.Context) error { - if a.LoggedIn { - return nil - } - return a.loginLocked(ctx) -} diff --git a/internal/service/qbittorrent_adp_list.go b/internal/service/qbittorrent_adp_list.go deleted file mode 100644 index d75251a..0000000 --- a/internal/service/qbittorrent_adp_list.go +++ /dev/null @@ -1,101 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strings" - "time" -) - -// List 列出种子。 -func (a *QBitAdapter) List(ctx context.Context, filter string) ([]TorrentInfo, error) { - a.mu.Lock() - defer a.mu.Unlock() - if err := a.ensureAuthLocked(ctx); err != nil { - return nil, err - } - baseURL := strings.TrimRight(a.cfg.Host, "/") - u := baseURL + "/api/v2/torrents/info" - req, err := newDownloadClientHTTPRequest(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, err - } - if filter != "" { - query := req.URL.Query() - query.Set("filter", filter) - req.URL.RawQuery = query.Encode() - } - req.Header.Set("Referer", baseURL) - resp, err := a.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("qbittorrent list: %d", resp.StatusCode) - } - - var qbList []qbitTorrentListItem - if err := json.NewDecoder(resp.Body).Decode(&qbList); err != nil { - return nil, err - } - return qbitTorrentListToInfo(qbList), nil -} - -// GetInfo 获取单个种子信息。 -func (a *QBitAdapter) GetInfo(ctx context.Context, hash string) (*TorrentInfo, error) { - list, err := a.List(ctx, "") - if err != nil { - return nil, err - } - for _, t := range list { - if t.Hash == hash { - return &t, nil - } - } - return nil, fmt.Errorf("torrent %s not found", hash) -} - -type qbitTorrentListItem struct { - Hash string `json:"hash"` - Name string `json:"name"` - State string `json:"state"` - Progress float32 `json:"progress"` - DLSpeed int64 `json:"dlspeed"` - UPSpeed int64 `json:"upspeed"` - NumSeeds int `json:"num_seeds"` - NumLeechs int `json:"num_leechs"` - Size int64 `json:"size"` - SavePath string `json:"save_path"` - AddedOn int64 `json:"added_on"` - Category string `json:"category"` - Tags string `json:"tags"` - ContentPath string `json:"content_path"` - CompletionOn int64 `json:"completion_on"` -} - -func qbitTorrentListToInfo(items []qbitTorrentListItem) []TorrentInfo { - result := make([]TorrentInfo, 0, len(items)) - for _, item := range items { - result = append(result, TorrentInfo{ - Hash: item.Hash, - Name: item.Name, - Size: item.Size, - Progress: normalizedTorrentProgress(float64(item.Progress)), - DLSpeed: item.DLSpeed, - UPSpeed: item.UPSpeed, - State: canonicalTorrentState(item.State, float64(item.Progress)), - SavePath: item.SavePath, - NumSeeds: item.NumSeeds, - NumLeechs: item.NumLeechs, - AddedOn: time.Unix(item.AddedOn, 0), - Category: item.Category, - Tags: item.Tags, - ContentPath: item.ContentPath, - CompletionOn: item.CompletionOn, - }) - } - return result -} diff --git a/internal/service/qbittorrent_bencode.go b/internal/service/qbittorrent_bencode.go deleted file mode 100644 index bc754fa..0000000 --- a/internal/service/qbittorrent_bencode.go +++ /dev/null @@ -1,91 +0,0 @@ -package service - -import ( - "crypto/sha1" // #nosec G505 -- BitTorrent v1 info-hash is SHA-1 by protocol. - "encoding/hex" -) - -func torrentInfoHash(data []byte) string { - start, end, ok := torrentInfoBounds(data) - if !ok { - return "" - } - sum := sha1.Sum(data[start:end]) // #nosec G401 -- BitTorrent v1 info-hash is SHA-1 by protocol, not a security hash. - return hex.EncodeToString(sum[:]) -} - -func torrentInfoBounds(data []byte) (int, int, bool) { - if len(data) == 0 || data[0] != 'd' { - return 0, 0, false - } - pos := 1 - for pos < len(data) && data[pos] != 'e' { - keyStart, keyEnd, next, ok := parseBencodeString(data, pos) - if !ok { - return 0, 0, false - } - valueStart := next - valueEnd, ok := bencodeValueEnd(data, valueStart) - if !ok { - return 0, 0, false - } - if string(data[keyStart:keyEnd]) == "info" { - return valueStart, valueEnd, true - } - pos = valueEnd - } - return 0, 0, false -} - -func parseBencodeString(data []byte, pos int) (int, int, int, bool) { - if pos >= len(data) || data[pos] < '0' || data[pos] > '9' { - return 0, 0, 0, false - } - length := 0 - for pos < len(data) && data[pos] >= '0' && data[pos] <= '9' { - length = length*10 + int(data[pos]-'0') - pos++ - } - if pos >= len(data) || data[pos] != ':' { - return 0, 0, 0, false - } - start := pos + 1 - end := start + length - if end > len(data) { - return 0, 0, 0, false - } - return start, end, end, true -} - -func bencodeValueEnd(data []byte, pos int) (int, bool) { - if pos >= len(data) { - return 0, false - } - switch data[pos] { - case 'i': - end := pos + 1 - for end < len(data) && data[end] != 'e' { - end++ - } - if end >= len(data) { - return 0, false - } - return end + 1, true - case 'l', 'd': - end := pos + 1 - for end < len(data) && data[end] != 'e' { - next, ok := bencodeValueEnd(data, end) - if !ok { - return 0, false - } - end = next - } - if end >= len(data) { - return 0, false - } - return end + 1, true - default: - _, _, next, ok := parseBencodeString(data, pos) - return next, ok - } -} diff --git a/internal/service/qbittorrent_convert.go b/internal/service/qbittorrent_convert.go deleted file mode 100644 index e65e827..0000000 --- a/internal/service/qbittorrent_convert.go +++ /dev/null @@ -1,40 +0,0 @@ -package service - -// QBitTorrentToInfo 将旧的 QBitTorrent 转换为新的 TorrentInfo。 -func QBitTorrentToInfo(q QBitTorrent) TorrentInfo { - return TorrentInfo{ - Hash: q.Hash, - Name: q.Name, - Size: q.Size, - Progress: float64(q.Progress), - DLSpeed: q.DLSpeed, - UPSpeed: q.UpSpeed, - State: q.State, - SavePath: q.SavePath, - NumSeeds: q.NumSeeds, - NumLeechs: q.NumLeech, - ContentPath: q.ContentPath, - CompletionOn: q.CompletionOn, - } -} - -// TorrentInfoToQBit 将 TorrentInfo 转换回旧的 QBitTorrent 格式(兼容性)。 -func TorrentInfoToQBit(t TorrentInfo) QBitTorrent { - return QBitTorrent{ - Hash: t.Hash, - ClientID: "", - Source: "", - Name: t.Name, - State: canonicalTorrentState(t.State, t.Progress), - Progress: float32(normalizedTorrentProgress(t.Progress)), - DLSpeed: t.DLSpeed, - UpSpeed: t.UPSpeed, - NumSeeds: t.NumSeeds, - NumLeech: t.NumLeechs, - Size: t.Size, - SavePath: t.SavePath, - Category: t.Category, - ContentPath: t.ContentPath, - CompletionOn: t.CompletionOn, - } -} diff --git a/internal/service/qbittorrent_login.go b/internal/service/qbittorrent_login.go deleted file mode 100644 index a55d378..0000000 --- a/internal/service/qbittorrent_login.go +++ /dev/null @@ -1,101 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/url" - "strings" -) - -type qbitLoginVariant struct { - name string - referer bool - origin bool -} - -func qbitLogin(ctx context.Context, client *http.Client, baseURL, username, password string) error { - baseURL = strings.TrimSpace(baseURL) - if baseURL == "" { - return errors.New("qbittorrent host not configured") - } - var err error - baseURL, err = normalizeDownloadClientEndpoint("qbittorrent", baseURL) - if err != nil { - return err - } - var lastErr error - for _, variant := range []qbitLoginVariant{ - {name: "minimal"}, - {name: "referer", referer: true}, - {name: "referer-origin", referer: true, origin: true}, - } { - err := qbitLoginOnce(ctx, client, baseURL, username, password, variant) - if err == nil { - return nil - } - lastErr = err - if errors.Is(err, errQbitBadCredentials) { - return err - } - } - return lastErr -} - -var errQbitBadCredentials = errors.New("qbittorrent: 用户名/密码错误") - -func qbitLoginOnce(ctx context.Context, client *http.Client, baseURL, username, password string, variant qbitLoginVariant) error { - form := url.Values{} - form.Set("username", username) - form.Set("password", password) - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, - baseURL+"/api/v2/auth/login", strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - if variant.referer { - req.Header.Set("Referer", baseURL) - } - if variant.origin { - req.Header.Set("Origin", baseURL) - } - - resp, err := client.Do(req) - if err != nil { - return qbitNetworkError(baseURL, err) - } - defer resp.Body.Close() - raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - text := strings.TrimSpace(string(raw)) - switch { - case resp.StatusCode == http.StatusOK && text == "Ok.": - return nil - case resp.StatusCode == http.StatusOK && text == "Ok": - return nil - case resp.StatusCode == http.StatusNoContent: - return nil - case resp.StatusCode == http.StatusOK && text == "Fails.": - return errQbitBadCredentials - case resp.StatusCode == http.StatusForbidden: - return fmt.Errorf("qbittorrent: 403 forbidden during %s login, body=%q; check qBittorrent WebUI bypass/auth settings for the container IP and host %s", variant.name, text, baseURL) - case resp.StatusCode >= 400: - return fmt.Errorf("qbittorrent login failed during %s login: status=%d body=%q", variant.name, resp.StatusCode, text) - default: - return fmt.Errorf("qbittorrent login unexpected response during %s login: status=%d body=%q", variant.name, resp.StatusCode, text) - } -} - -func qbitNetworkError(baseURL string, err error) error { - if err == nil { - return nil - } - var netErr net.Error - if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) || strings.Contains(err.Error(), "Client.Timeout exceeded") { - return fmt.Errorf("qbittorrent: 连接 %s 超时;容器内无法访问该地址。若 qBittorrent 运行在 NAS 宿主机上,请把下载器地址改为 http://host.docker.internal:端口 或 http://172.17.0.1:端口,并确认 docker-compose.yml 包含 extra_hosts: host.docker.internal:host-gateway: %w", baseURL, err) - } - return fmt.Errorf("qbittorrent: 连接 %s 失败:%w", baseURL, err) -} diff --git a/internal/service/qbittorrent_test.go b/internal/service/qbittorrent_test.go deleted file mode 100644 index d9081cb..0000000 --- a/internal/service/qbittorrent_test.go +++ /dev/null @@ -1,411 +0,0 @@ -package service - -import ( - "context" - "errors" - "io" - "mime/multipart" - "net/http" - "net/http/cookiejar" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - "time" - - "go.uber.org/zap" -) - -type roundTripFunc func(*http.Request) (*http.Response, error) - -func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) -} - -func TestQBitLoginUsesMinimalRequestFirst(t *testing.T) { - var loginAttempts atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - loginAttempts.Add(1) - if r.Header.Get("Origin") != "" || r.Header.Get("Referer") != "" { - http.Error(w, "unexpected csrf headers", http.StatusForbidden) - return - } - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - client := NewQBitClient(zap.NewNop(), QBitConfig{ - BaseURL: server.URL, - Username: "admin", - Password: "adminadmin", - }) - - if err := client.Login(context.Background()); err != nil { - t.Fatalf("expected minimal login to succeed: %v", err) - } - if loginAttempts.Load() != 1 { - t.Fatalf("login attempts = %d, want 1", loginAttempts.Load()) - } -} - -func TestQBitLoginTimeoutSuggestsDockerHostAddress(t *testing.T) { - client := &http.Client{ - Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - return nil, context.DeadlineExceeded - }), - } - - err := qbitLogin(context.Background(), client, "http://192.168.1.125:8085", "admin", "adminadmin") - if err == nil { - t.Fatal("expected timeout error") - } - msg := err.Error() - for _, want := range []string{"连接 http://192.168.1.125:8085 超时", "host.docker.internal", "172.17.0.1"} { - if !strings.Contains(msg, want) { - t.Fatalf("timeout hint %q missing %q", msg, want) - } - } -} - -func TestQBitLoginRetriesWithRefererWhenRequired(t *testing.T) { - var loginAttempts atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - loginAttempts.Add(1) - if r.Header.Get("Referer") == "" { - http.Error(w, "missing referer", http.StatusForbidden) - return - } - if r.Header.Get("Origin") != "" { - http.Error(w, "origin blocked", http.StatusForbidden) - return - } - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - jar, _ := cookiejar.New(nil) - httpClient := &http.Client{Jar: jar} - if err := qbitLogin(context.Background(), httpClient, server.URL, "admin", "adminadmin"); err != nil { - t.Fatalf("expected referer retry to succeed: %v", err) - } - if loginAttempts.Load() != 2 { - t.Fatalf("login attempts = %d, want 2", loginAttempts.Load()) - } -} - -func TestQBitLoginAcceptsNoContentFromNewerWebUI(t *testing.T) { - var loginAttempts atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - loginAttempts.Add(1) - if r.Header.Get("Referer") == "" || r.Header.Get("Origin") == "" { - http.Error(w, "csrf headers required", http.StatusForbidden) - return - } - w.WriteHeader(http.StatusNoContent) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - jar, _ := cookiejar.New(nil) - httpClient := &http.Client{Jar: jar} - if err := qbitLogin(context.Background(), httpClient, server.URL, "admin", "adminadmin"); err != nil { - t.Fatalf("expected 204 login response to succeed: %v", err) - } - if loginAttempts.Load() != 3 { - t.Fatalf("login attempts = %d, want 3", loginAttempts.Load()) - } -} - -func TestQBitAddTorrentRequiresVisibleNewTask(t *testing.T) { - oldAttempts := qbitAddVerifyAttempts - oldInterval := qbitAddVerifyInterval - qbitAddVerifyAttempts = 2 - qbitAddVerifyInterval = time.Millisecond - defer func() { - qbitAddVerifyAttempts = oldAttempts - qbitAddVerifyInterval = oldInterval - }() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/add": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte("[]")) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - client := NewQBitClient(zap.NewNop(), QBitConfig{ - BaseURL: server.URL, - Username: "admin", - Password: "adminadmin", - }) - - err := client.AddTorrent(context.Background(), server.URL+"/missing.torrent", "") - if err == nil { - t.Fatal("expected add to fail when no new torrent appears") - } - if !strings.Contains(err.Error(), "下载器未出现新任务") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestQBitAddTorrentUploadsFetchedTorrentFile(t *testing.T) { - var added atomic.Bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/fixture.torrent": - w.Header().Set("Content-Type", "application/x-bittorrent") - _, _ = w.Write([]byte("d4:infod4:name7:fixtureee")) - case "/api/v2/torrents/add": - reader, err := r.MultipartReader() - if err != nil { - t.Errorf("expected multipart add request: %v", err) - http.Error(w, "bad multipart", http.StatusBadRequest) - return - } - if !multipartHasTorrentFile(reader) { - t.Error("expected qbit add request to upload torrent file") - http.Error(w, "missing file", http.StatusBadRequest) - return - } - added.Store(true) - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if added.Load() { - _, _ = w.Write([]byte(`[{"hash":"abc123","name":"fixture"}]`)) - return - } - _, _ = w.Write([]byte("[]")) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - client := NewQBitClient(zap.NewNop(), QBitConfig{ - BaseURL: server.URL, - Username: "admin", - Password: "adminadmin", - }) - - if err := client.AddTorrent(context.Background(), server.URL+"/fixture.torrent", ""); err != nil { - t.Fatalf("expected fetched torrent upload to succeed: %v", err) - } -} - -func TestQBitAddTorrentFileReturnsDedupForExistingInfoHash(t *testing.T) { - torrentData := []byte("d4:infod4:name7:fixtureee") - hash := torrentInfoHash(torrentData) - if hash == "" { - t.Fatal("expected fixture info hash") - } - var addCalled atomic.Bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/add": - addCalled.Store(true) - _, _ = w.Write([]byte("Fails.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[{"hash":"` + hash + `","name":"fixture"}]`)) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - client := NewQBitClient(zap.NewNop(), QBitConfig{ - BaseURL: server.URL, - Username: "admin", - Password: "adminadmin", - }) - - if err := client.AddTorrentFile(context.Background(), torrentData, "fixture.torrent", ""); !errors.Is(err, ErrDownloadAlreadyExists) { - t.Fatalf("err = %v, want ErrDownloadAlreadyExists", err) - } - if addCalled.Load() { - t.Fatal("expected qbit add to be skipped for existing infohash") - } -} - -func multipartHasTorrentFile(reader *multipart.Reader) bool { - for { - part, err := reader.NextPart() - if err == io.EOF { - return false - } - if err != nil { - return false - } - if part.FormName() == "torrents" && part.FileName() != "" { - return true - } - } -} - -func TestQBitSetLocationPostsHashAndLocation(t *testing.T) { - var gotHash, gotLocation string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/setLocation": - if err := r.ParseForm(); err != nil { - http.Error(w, "bad form", http.StatusBadRequest) - return - } - gotHash = r.PostFormValue("hashes") - gotLocation = r.PostFormValue("location") - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - client := NewQBitClient(zap.NewNop(), QBitConfig{ - BaseURL: server.URL, - Username: "admin", - Password: "adminadmin", - }) - if err := client.SetLocation(context.Background(), "abc123", "/data/media/Movie"); err != nil { - t.Fatalf("setLocation: %v", err) - } - if gotHash != "abc123" { - t.Fatalf("hashes = %q, want abc123", gotHash) - } - if gotLocation != "/data/media/Movie" { - t.Fatalf("location = %q, want /data/media/Movie", gotLocation) - } -} - -func TestQBitSetLocationSurfacesConflict(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/setLocation": - http.Error(w, "cannot write", http.StatusConflict) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - client := NewQBitClient(zap.NewNop(), QBitConfig{ - BaseURL: server.URL, - Username: "admin", - Password: "adminadmin", - }) - err := client.SetLocation(context.Background(), "abc123", "/data/media/Movie") - if err == nil { - t.Fatal("expected error on 409 conflict") - } - if !strings.Contains(err.Error(), "无法写入目标路径") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestQBitAdapterPauseResumeFallsBackToQBit52Actions(t *testing.T) { - var pauseCalled, stopCalled, resumeCalled, startCalled atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/pause": - pauseCalled.Add(1) - http.NotFound(w, r) - case "/api/v2/torrents/stop": - stopCalled.Add(1) - if r.Header.Get("Origin") != serverOrigin(r) { - t.Errorf("stop Origin = %q, want %q", r.Header.Get("Origin"), serverOrigin(r)) - } - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/resume": - resumeCalled.Add(1) - http.NotFound(w, r) - case "/api/v2/torrents/start": - startCalled.Add(1) - if r.Header.Get("Origin") != serverOrigin(r) { - t.Errorf("start Origin = %q, want %q", r.Header.Get("Origin"), serverOrigin(r)) - } - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - adapter := NewQBitAdapter() - if err := adapter.Initialize(context.Background(), DownloadClientConfig{Host: server.URL, Username: "admin", Password: "adminadmin"}); err != nil { - t.Fatalf("initialize: %v", err) - } - if err := adapter.Pause(context.Background(), "abc123"); err != nil { - t.Fatalf("pause: %v", err) - } - if err := adapter.Resume(context.Background(), "abc123"); err != nil { - t.Fatalf("resume: %v", err) - } - if pauseCalled.Load() != 1 || stopCalled.Load() != 1 || resumeCalled.Load() != 1 || startCalled.Load() != 1 { - t.Fatalf("calls pause=%d stop=%d resume=%d start=%d, want all 1", - pauseCalled.Load(), stopCalled.Load(), resumeCalled.Load(), startCalled.Load()) - } -} - -func TestQBitAdapterAddTorrentSendsOriginAndRejectsFailsBody(t *testing.T) { - var addCalled atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/add": - addCalled.Add(1) - if r.Header.Get("Origin") != serverOrigin(r) { - t.Errorf("Origin = %q, want %q", r.Header.Get("Origin"), serverOrigin(r)) - } - _, _ = w.Write([]byte("Fails.")) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - adapter := NewQBitAdapter() - if err := adapter.Initialize(context.Background(), DownloadClientConfig{Host: server.URL, Username: "admin", Password: "adminadmin"}); err != nil { - t.Fatalf("initialize: %v", err) - } - _, err := adapter.AddTorrent(context.Background(), "magnet:?xt=urn:btih:abc", "/downloads") - if err == nil || !strings.Contains(err.Error(), "rejected") { - t.Fatalf("expected rejected add error, got %v", err) - } - if addCalled.Load() != 1 { - t.Fatalf("add calls = %d, want 1", addCalled.Load()) - } -} - -func serverOrigin(r *http.Request) string { - return "http://" + r.Host -} diff --git a/internal/service/runtime_settings.go b/internal/service/runtime_settings.go index 91cdffe..d10625c 100644 --- a/internal/service/runtime_settings.go +++ b/internal/service/runtime_settings.go @@ -100,12 +100,6 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) { } case "transcode.video_bitrate", "transcoder.video_bitrate": cfg.Transcoder.VideoBitrate = value - case "license.server_url": - cfg.License.ServerURL = value - case "license.hmac_secret": - cfg.License.HMACSecret = value - case "license.public_key": - cfg.License.PublicKey = value } } diff --git a/internal/service/scanner.go b/internal/service/scanner.go index cca087b..a6e7128 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -11,11 +11,9 @@ package service import ( - "context" "errors" "strings" "sync" - "time" "go.uber.org/zap" @@ -60,26 +58,10 @@ type ScannerService struct { probe *FFprobeService scraper *ScraperService organizer *OrganizerService - storage *StorageConfigService cache *RuntimeCacheService - notify *NotifyChannelService imageProxy *ImageProxy - cloudScanMu sync.Mutex - cloudScans map[string]*cloudScanEntry - cloudSlots chan struct{} - cloudImagePrefetchOnce sync.Once - cloudImagePrefetchQueue chan cloudImagePrefetchTask - cloudImagePrefetchMu sync.Mutex - cloudImagePrefetching map[string]struct{} - cloudMediaProbeOnce sync.Once - cloudMediaProbeQueue chan cloudMediaProbeTask - cloudMediaProbeMu sync.Mutex - cloudMediaProbing map[string]struct{} - cloudMediaProbeBackoff map[string]time.Time - cloudMediaProbeWarnMu sync.Mutex - cloudMediaProbeLastWarn time.Time localMediaProbeOnce sync.Once localMediaProbeQueue chan localMediaProbeTask localMediaProbeMu sync.Mutex @@ -98,34 +80,12 @@ func NewScannerService( scraper *ScraperService, ) *ScannerService { return &ScannerService{ - cfg: cfg, log: log, repo: repo, hub: hub, - probe: probe, - scraper: scraper, - cloudScans: make(map[string]*cloudScanEntry), - cloudSlots: make(chan struct{}, 1), - cloudImagePrefetchQueue: make(chan cloudImagePrefetchTask, 256), - cloudImagePrefetching: make(map[string]struct{}), - cloudMediaProbeQueue: make(chan cloudMediaProbeTask, 1024), - cloudMediaProbing: make(map[string]struct{}), - cloudMediaProbeBackoff: make(map[string]time.Time), - localMediaProbeQueue: make(chan localMediaProbeTask, 1024), - localMediaProbing: make(map[string]struct{}), - localScans: make(map[string]struct{}), - } -} - -// SetStorageConfig wires cloud-disk storage access into the scanner. It is set -// after service construction because StorageConfigService depends on Crypto, -// while the scanner is needed earlier by watcher/download services. -func (s *ScannerService) SetStorageConfig(storage *StorageConfigService) { - s.storage = storage - if storage != nil && s.probe != nil { - s.cloudMediaProbeOnce.Do(func() { - workers := s.ffprobeWorkerCount() - for i := 0; i < workers; i++ { - go s.cloudMediaProbeWorker() - } - }) + cfg: cfg, log: log, repo: repo, hub: hub, + probe: probe, + scraper: scraper, + localMediaProbeQueue: make(chan localMediaProbeTask, 1024), + localMediaProbing: make(map[string]struct{}), + localScans: make(map[string]struct{}), } } @@ -141,22 +101,8 @@ func (s *ScannerService) SetRuntimeCache(cache *RuntimeCacheService) { } } -func (s *ScannerService) SetNotifyChannels(notify *NotifyChannelService) { - if s != nil { - s.notify = notify - } -} - -// SetImageProxy lets cloud scans warm sidecar poster/backdrop files into the -// local image cache. This keeps library opening fast without forcing the UI or -// Emby clients to resolve/download every cloud poster on demand. func (s *ScannerService) SetImageProxy(imageProxy *ImageProxy) { s.imageProxy = imageProxy - if imageProxy != nil { - s.cloudImagePrefetchOnce.Do(func() { - go s.cloudImagePrefetchWorker() - }) - } } // ScanResult summarises a scan run. @@ -173,7 +119,6 @@ type ScanResult struct { Errors []string `json:"errors,omitempty"` } -var ErrCloudScanAlreadyRunning = errors.New("cloud scan already running") var ErrLocalScanAlreadyRunning = errors.New("local scan already running") const maxScanErrorDetails = 20 @@ -194,85 +139,10 @@ func addScanError(res *ScanResult, path string, err error) { res.Errors = append(res.Errors, msg) } -const maxCloudMediaProbeQueuePerScan = 256 - -const cloudMediaProbeFailureBackoff = 6 * time.Hour - -// cloudMediaProbeQueueFullBackoff 是探测队列饱和时给单个文件挂的短退避, -// 防止后续扫描轮次对同一批文件反复尝试入队。 -const cloudMediaProbeQueueFullBackoff = 30 * time.Minute - -// CloudScanStatus is the operator-facing state for long-running cloud scans. -type CloudScanStatus struct { - LibraryID string `json:"library_id"` - Provider string `json:"provider"` - Stage string `json:"stage"` - State string `json:"state"` - StartedAt time.Time `json:"started_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` - FinishedAt time.Time `json:"finished_at,omitempty"` - Dirs int `json:"dirs"` - Discovered int `json:"discovered"` - Visited int `json:"visited"` - Added int `json:"added"` - Updated int `json:"updated"` - Skipped int `json:"skipped"` - Removed int64 `json:"removed"` - ErrorCount int `json:"error_count,omitempty"` - Errors []string `json:"errors,omitempty"` - Error string `json:"error,omitempty"` - ResumeHint string `json:"resume_hint,omitempty"` - Estimate string `json:"estimate_message,omitempty"` - FilesPerSecond float64 `json:"files_per_second,omitempty"` -} - -type cloudScanEntry struct { - status CloudScanStatus - cancel context.CancelFunc -} - -type cloudMediaProbeTask struct { - typ string - ref string - path string -} - type localMediaProbeTask struct { path string } -type existingCloudMedia struct { - LibraryID string - Title string - OriginalName string - EpisodeTitle string - SizeBytes int64 - DurationSec int - Width int - Height int - VideoCodec string - AudioCodec string - Container string - PosterURL string - BackdropURL string - STRMURL string - Overview string - Year int - ReleaseDate string - Rating float32 - TMDbID int - BangumiID int - DoubanID string - TheTVDBID string - SeasonNum int - EpisodeNum int - Genres string - Countries string - Languages string - NSFW bool - ScrapeStatus string -} - type existingLocalMedia struct { LibraryRootID string RelativePath string diff --git a/internal/service/scanner_cloud_115_test.go b/internal/service/scanner_cloud_115_test.go deleted file mode 100644 index a62d7eb..0000000 --- a/internal/service/scanner_cloud_115_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestScan115CloudLibraryKeepsDisplayHierarchyAndSeasonCounts(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/files" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - switch r.URL.Query().Get("cid") { - case "100": - _, _ = w.Write([]byte(`{"state":true,"data":[ - {"cid":"s1","n":"Season 1","s":0}, - {"cid":"s2","n":"Season 2","s":0} - ]}`)) - case "s1": - _, _ = w.Write([]byte(`{"state":true,"data":[ - {"fid":"f101","n":"剑来 - S01E01.mkv","s":1001,"pc":"pick101"}, - {"fid":"f125","n":"剑来 - S01E25.mkv","s":1025,"pc":"pick125"} - ]}`)) - case "s2": - _, _ = w.Write([]byte(`{"state":true,"data":[ - {"fid":"f201","n":"剑来 - S02E01.mkv","s":2001,"pc":"pick201"}, - {"fid":"f204","n":"剑来 - S02E04.mkv","s":2004,"pc":"pick204"} - ]}`)) - default: - t.Fatalf("unexpected cid %q", r.URL.Query().Get("cid")) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "cloud115", - Config: map[string]any{ - "cookie": "UID=test", - "base": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "115 · 国漫 · 剑来", Path: BuildCloudLibraryPath("cloud115", "100", "动漫/国漫/剑来"), Type: "anime", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud115: %v", err) - } - if res.Added != 4 { - t.Fatalf("scan result = %#v, want added=4", res) - } - var rows []model.Media - if err := repos.DB.Order("path").Find(&rows).Error; err != nil { - t.Fatal(err) - } - if len(rows) != 4 { - t.Fatalf("media rows = %d, want 4", len(rows)) - } - want := map[string][2]int{ - "cloud://cloud115/动漫/国漫/剑来/Season 1/剑来 - S01E01.mkv": {1, 1}, - "cloud://cloud115/动漫/国漫/剑来/Season 1/剑来 - S01E25.mkv": {1, 25}, - "cloud://cloud115/动漫/国漫/剑来/Season 2/剑来 - S02E01.mkv": {2, 1}, - "cloud://cloud115/动漫/国漫/剑来/Season 2/剑来 - S02E04.mkv": {2, 4}, - } - for _, row := range rows { - seasonEpisode, ok := want[row.Path] - if !ok { - t.Fatalf("unexpected path %q", row.Path) - } - if row.Title != "剑来" { - t.Fatalf("title = %q, want 剑来", row.Title) - } - if row.SeasonNum != seasonEpisode[0] || row.EpisodeNum != seasonEpisode[1] { - t.Fatalf("%s season/episode = %d/%d, want %d/%d", row.Path, row.SeasonNum, row.EpisodeNum, seasonEpisode[0], seasonEpisode[1]) - } - if !strings.Contains(row.STRMURL, "ref=pick") { - t.Fatalf("115 playback should keep pickcode ref, got %q", row.STRMURL) - } - } -} diff --git a/internal/service/scanner_cloud_artwork.go b/internal/service/scanner_cloud_artwork.go deleted file mode 100644 index 92360f7..0000000 --- a/internal/service/scanner_cloud_artwork.go +++ /dev/null @@ -1,158 +0,0 @@ -package service - -import ( - "context" - "net/url" - "strings" - "time" - - "go.uber.org/zap" -) - -type cloudImagePrefetchTask struct { - typ string - ref string - stableKey string -} - -func (s *ScannerService) cloudImagePrefetchWorker() { - for task := range s.cloudImagePrefetchQueue { - s.prefetchCloudImage(task) - } -} - -func (s *ScannerService) queueCloudArtworkPrefetch(raw string) { - if s == nil || s.storage == nil || s.imageProxy == nil { - return - } - typ, ref, ok := ParseCloudArtworkURL(raw) - if !ok { - return - } - stableKey := typ + ":" + ref - if s.imageProxy.CloudImageCached(stableKey) { - return - } - s.cloudImagePrefetchMu.Lock() - if _, ok := s.cloudImagePrefetching[stableKey]; ok { - s.cloudImagePrefetchMu.Unlock() - return - } - s.cloudImagePrefetching[stableKey] = struct{}{} - s.cloudImagePrefetchMu.Unlock() - - task := cloudImagePrefetchTask{typ: typ, ref: ref, stableKey: stableKey} - select { - case s.cloudImagePrefetchQueue <- task: - default: - s.cloudImagePrefetchMu.Lock() - delete(s.cloudImagePrefetching, stableKey) - s.cloudImagePrefetchMu.Unlock() - if s.log != nil { - s.log.Debug("cloud artwork prefetch queue full", zap.String("provider", typ), zap.String("ref", ref)) - } - } -} - -func (s *ScannerService) prefetchCloudImage(task cloudImagePrefetchTask) { - defer func() { - s.cloudImagePrefetchMu.Lock() - delete(s.cloudImagePrefetching, task.stableKey) - s.cloudImagePrefetchMu.Unlock() - }() - if s == nil || s.storage == nil || s.imageProxy == nil || s.imageProxy.CloudImageCached(task.stableKey) { - return - } - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) - defer cancel() - link, err := s.storage.CloudResolve(ctx, task.typ, task.ref, "") - if err != nil { - if s.log != nil { - s.log.Debug("resolve cloud artwork for prefetch failed", zap.String("provider", task.typ), zap.String("ref", task.ref), zap.Error(err)) - } - return - } - if err := s.imageProxy.PrefetchCloudResolved(ctx, task.stableKey, link); err != nil && s.log != nil { - s.log.Debug("prefetch cloud artwork failed", zap.String("provider", task.typ), zap.String("ref", task.ref), zap.Error(err)) - } -} - -func (s *ScannerService) cacheCloudArtworkNow(ctx context.Context, raw string) { - if s == nil || s.storage == nil || s.imageProxy == nil { - return - } - typ, ref, ok := ParseCloudArtworkURL(raw) - if !ok { - return - } - stableKey := typ + ":" + ref - if s.imageProxy.CloudImageCached(stableKey) { - return - } - cacheCtx, cancel := context.WithTimeout(ctx, 20*time.Second) - defer cancel() - link, err := s.storage.CloudResolve(cacheCtx, typ, ref, "") - if err != nil { - if s.log != nil { - s.log.Debug("resolve cloud artwork for priority cache failed", zap.String("provider", typ), zap.String("ref", ref), zap.Error(err)) - } - s.queueCloudArtworkPrefetch(raw) - return - } - if err := s.imageProxy.PrefetchCloudResolved(cacheCtx, stableKey, link); err != nil { - if s.log != nil { - s.log.Debug("priority cache cloud artwork failed", zap.String("provider", typ), zap.String("ref", ref), zap.Error(err)) - } - s.queueCloudArtworkPrefetch(raw) - } -} - -func (s *ScannerService) cacheCloudMetadataArtworkNow(ctx context.Context, meta *LocalMetadata) { - if meta == nil { - return - } - s.cacheCloudArtworkNow(ctx, meta.PosterURL) - s.cacheCloudArtworkNow(ctx, meta.BackdropURL) -} - -func ParseCloudArtworkURL(raw string) (string, string, bool) { - u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil { - return "", "", false - } - path := strings.Trim(u.Path, "/") - typ := "" - for _, prefix := range []string{"api/img/cloud/", "api/cloud/play/"} { - if strings.HasPrefix(strings.ToLower(path), prefix) { - typ = strings.TrimSpace(path[len(prefix):]) - break - } - } - if typ == "" { - return "", "", false - } - ref := strings.TrimSpace(u.Query().Get("ref")) - if typ == "" || ref == "" || !isCloudArtworkRef(ref) { - return "", "", false - } - return typ, ref, true -} - -func CloudArtworkURL(typ, ref string) string { - typ = strings.Trim(strings.ReplaceAll(strings.TrimSpace(typ), "\\", "/"), "/") - ref = strings.TrimSpace(ref) - if typ == "" || ref == "" { - return "" - } - return "/api/img/cloud/" + url.PathEscape(typ) + "?ref=" + url.QueryEscape(ref) -} - -func isCloudArtworkRef(ref string) bool { - ref = strings.ToLower(strings.TrimSpace(ref)) - for _, suffix := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tbn"} { - if strings.HasSuffix(ref, suffix) { - return true - } - } - return false -} diff --git a/internal/service/scanner_cloud_artwork_test.go b/internal/service/scanner_cloud_artwork_test.go deleted file mode 100644 index 5d8ee13..0000000 --- a/internal/service/scanner_cloud_artwork_test.go +++ /dev/null @@ -1,386 +0,0 @@ -package service - -import ( - "bytes" - "net/http" - "net/http/httptest" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestScanCloudLibraryCachesFileLevelRemoteArtwork(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - if r.URL.Path != "/dav/Movies" { - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - _, _ = w.Write([]byte(` - - /dav/Movies/ - /dav/Movies/Movie.mkvMovie.mkv4096 - /dav/Movies/Movie.nfoMovie.nfo128 - /dav/Movies/Movie.jpgMovie.jpg1024 -`)) - case http.MethodGet: - switch r.URL.Path { - case "/dav/Movies/Movie.nfo": - _, _ = w.Write([]byte(`Sidecar Movie2026`)) - case "/dav/Movies/Movie.jpg": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(testJPEG) - default: - t.Fatalf("unexpected get path %s", r.URL.Path) - } - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) - scanner.SetImageProxy(imageProxy) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - if media.Title != "Sidecar Movie" || media.Year != 2026 { - t.Fatalf("metadata not applied: %#v", media) - } - if media.PosterURL != "/api/img/cloud/openlist?ref=%2FMovies%2FMovie.jpg" { - t.Fatalf("poster url = %q", media.PosterURL) - } - rec := httptest.NewRecorder() - if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Movie.jpg") { - t.Fatal("file-level cloud poster should be cached locally during scan before media is exposed") - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached poster body = %x", got) - } -} - -func TestScanCloudLibraryUsesArtworkReferencedByRemoteNFO(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - if r.URL.Path != "/dav/Movies" { - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - _, _ = w.Write([]byte(` - - /dav/Movies/ - /dav/Movies/Movie.mkvMovie.mkv4096 - /dav/Movies/Movie.nfoMovie.nfo256 - /dav/Movies/Artwork.Custom.tbnArtwork.Custom.tbn1024 - /dav/Movies/Scene.Still.pngScene.Still.png1024 -`)) - case http.MethodGet: - switch r.URL.Path { - case "/dav/Movies/Movie.nfo": - _, _ = w.Write([]byte(`NFO Custom ArtworkArtwork.Custom.tbnScene.Still.png?version=1`)) - case "/dav/Movies/Artwork.Custom.tbn": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(testJPEG) - case "/dav/Movies/Scene.Still.png": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(testJPEG) - default: - t.Fatalf("unexpected get path %s", r.URL.Path) - } - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) - scanner.SetImageProxy(imageProxy) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - if media.Title != "NFO Custom Artwork" { - t.Fatalf("metadata title = %q", media.Title) - } - if media.PosterURL != "/api/img/cloud/openlist?ref=%2FMovies%2FArtwork.Custom.tbn" { - t.Fatalf("poster url = %q", media.PosterURL) - } - if media.BackdropURL != "/api/img/cloud/openlist?ref=%2FMovies%2FScene.Still.png" { - t.Fatalf("backdrop url = %q", media.BackdropURL) - } - rec := httptest.NewRecorder() - if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Artwork.Custom.tbn") { - t.Fatal("NFO-referenced cloud poster should be cached locally during scan") - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached poster body = %x", got) - } -} - -func TestScanCloudLibraryReadsRemoteNFOAndArtwork(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - switch r.URL.Path { - case "/dav/Anime/JianLai": - _, _ = w.Write([]byte(` - - /dav/Anime/JianLai/ - /dav/Anime/JianLai/tvshow.nfotvshow.nfo64 - /dav/Anime/JianLai/poster.jpgposter.jpg1024 - /dav/Anime/JianLai/Season1/Season1 -`)) - case "/dav/Anime/JianLai/Season1": - _, _ = w.Write([]byte(` - - /dav/Anime/JianLai/Season1/ - /dav/Anime/JianLai/Season1/JianLai.S01E01.mkvJianLai.S01E01.mkv2048 - /dav/Anime/JianLai/Season1/JianLai.S01E01.nfoJianLai.S01E01.nfo128 -`)) - default: - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - case http.MethodGet: - switch r.URL.Path { - case "/dav/Anime/JianLai/tvshow.nfo": - _, _ = w.Write([]byte(`剑来2024天地有剑气`)) - case "/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo": - _, _ = w.Write([]byte(`剑来第一集11`)) - case "/dav/Anime/JianLai/poster.jpg": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(testJPEG) - default: - t.Fatalf("unexpected get path %s", r.URL.Path) - } - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · 国漫 · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) - scanner.SetImageProxy(imageProxy) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - // 单集名(episode 「第一集」)不得写入 OriginalName(整剧原名/分组键)。 - // tvshow.nfo 未提供 originaltitle, 故 OriginalName 应为空。 - if media.Title != "剑来" || media.OriginalName != "" || media.Year != 2024 { - t.Fatalf("metadata not applied: %#v", media) - } - if media.SeasonNum != 1 || media.EpisodeNum != 1 { - t.Fatalf("episode numbers = %d/%d", media.SeasonNum, media.EpisodeNum) - } - if media.PosterURL != "/api/img/cloud/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg" { - t.Fatalf("poster url = %q", media.PosterURL) - } - rec := httptest.NewRecorder() - if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Anime/JianLai/poster.jpg") { - t.Fatal("cloud poster should be cached locally during scan before media is exposed") - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached poster body = %x", got) - } - if media.ScrapeStatus != "matched" { - t.Fatalf("scrape status = %q", media.ScrapeStatus) - } -} - -func TestScanCloudLibraryRefreshesExistingRemoteNFOAndArtwork(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - switch r.URL.Path { - case "/dav/Anime/JianLai": - _, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?> -<d:multistatus xmlns:d="DAV:"> - <d:response><d:href>/dav/Anime/JianLai/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response> - <d:response><d:href>/dav/Anime/JianLai/tvshow.nfo</d:href><d:propstat><d:prop><d:displayname>tvshow.nfo</d:displayname><d:getcontentlength>64</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response> - <d:response><d:href>/dav/Anime/JianLai/poster.jpg</d:href><d:propstat><d:prop><d:displayname>poster.jpg</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response> - <d:response><d:href>/dav/Anime/JianLai/Season1/</d:href><d:propstat><d:prop><d:displayname>Season1</d:displayname><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response> -</d:multistatus>`)) - case "/dav/Anime/JianLai/Season1": - _, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?> -<d:multistatus xmlns:d="DAV:"> - <d:response><d:href>/dav/Anime/JianLai/Season1/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response> - <d:response><d:href>/dav/Anime/JianLai/Season1/JianLai.S01E01.mkv</d:href><d:propstat><d:prop><d:displayname>JianLai.S01E01.mkv</d:displayname><d:getcontentlength>2048</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response> - <d:response><d:href>/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo</d:href><d:propstat><d:prop><d:displayname>JianLai.S01E01.nfo</d:displayname><d:getcontentlength>128</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response> -</d:multistatus>`)) - default: - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - case http.MethodGet: - switch r.URL.Path { - case "/dav/Anime/JianLai/tvshow.nfo": - _, _ = w.Write([]byte(`<tvshow><title>剑来2024天地有剑气296753`)) - case "/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo": - _, _ = w.Write([]byte(`剑来第一集11`)) - case "/dav/Anime/JianLai/poster.jpg": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(testJPEG) - default: - t.Fatalf("unexpected get path %s", r.URL.Path) - } - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · 国漫 · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - - mediaPath := "cloud://openlist/Anime/JianLai/Season1/JianLai.S01E01.mkv" - old := model.Media{ - LibraryID: lib.ID, - Title: "JianLai.S01E01", - Path: mediaPath, - SizeBytes: 2048, - Container: "mkv", - PosterURL: "https://image.tmdb.org/t/p/w500/old.jpg", - STRMURL: BuildRelativeCloudPlayURL("openlist", "/Anime/JianLai/Season1/JianLai.S01E01.mkv"), - ScrapeStatus: "no_match", - } - if err := repos.Media.Upsert(t.Context(), &old); err != nil { - t.Fatal(err) - } - - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) - scanner.SetImageProxy(imageProxy) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Updated != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want updated=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media, "path = ?", mediaPath).Error; err != nil { - t.Fatal(err) - } - if media.Title != "剑来" || media.Year != 2024 || media.TMDbID != 296753 { - t.Fatalf("metadata not refreshed: %#v", media) - } - if media.PosterURL != "/api/img/cloud/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg" { - t.Fatalf("poster url = %q", media.PosterURL) - } - if media.ScrapeStatus != "matched" { - t.Fatalf("scrape status = %q", media.ScrapeStatus) - } - rec := httptest.NewRecorder() - if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Anime/JianLai/poster.jpg") { - t.Fatal("refreshed cloud poster should be cached locally during scan") - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached poster body = %x", got) - } -} diff --git a/internal/service/scanner_cloud_autocategory_test.go b/internal/service/scanner_cloud_autocategory_test.go deleted file mode 100644 index 67d4fac..0000000 --- a/internal/service/scanner_cloud_autocategory_test.go +++ /dev/null @@ -1,457 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/glebarez/sqlite" - "go.uber.org/zap" - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestScanRootCloudLibraryCreatesAutoCategoryLibraries(t *testing.T) { - empty := false - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - if empty { - return nil, 0 - } - switch path { - case "/": - return []openListTestEntry{ - {Name: "电视剧", IsDir: true}, - {Name: "电影", IsDir: true}, - {Name: "国漫", IsDir: true}, - }, 3 - case "/电视剧": - return []openListTestEntry{{Name: "欧美剧", IsDir: true}}, 1 - case "/电视剧/欧美剧": - return []openListTestEntry{{Name: "The Show", IsDir: true}}, 1 - case "/电视剧/欧美剧/The Show": - return []openListTestEntry{{Name: "The.Show.S01E01.mkv", Size: 101}}, 1 - case "/电影": - return []openListTestEntry{{Name: "华语电影", IsDir: true}}, 1 - case "/电影/华语电影": - return []openListTestEntry{{Name: "Movie.2024.mkv", Size: 202}}, 1 - case "/国漫": - return []openListTestEntry{{Name: "剑来", IsDir: true}}, 1 - case "/国漫/剑来": - return []openListTestEntry{{Name: "剑来.S01E01.mkv", Size: 303}}, 1 - default: - t.Fatalf("unexpected openlist path %q", path) - return nil, 0 - } - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &root); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - - res, err := scanner.ScanLibrary(t.Context(), root.ID) - if err != nil { - t.Fatalf("scan root cloud: %v", err) - } - if res.Visited != 3 || res.Added != 3 { - t.Fatalf("scan result = %#v, want visited=3 added=3", res) - } - - libs, err := repos.Library.List(t.Context()) - if err != nil { - t.Fatal(err) - } - byDisplayDir := map[string]model.Library{} - for _, lib := range libs { - if !CloudLibraryAutoCategory(lib) { - continue - } - info, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - t.Fatalf("auto category path did not parse: %q", lib.Path) - } - byDisplayDir[info.DisplayDir] = lib - } - wantTypes := map[string]string{ - "电视剧/欧美剧": "tv", - "电影/华语电影": "movie", - "动漫/国漫": "anime", - } - for dir, wantType := range wantTypes { - lib, ok := byDisplayDir[dir] - if !ok { - t.Fatalf("missing auto category library %q; got %#v", dir, byDisplayDir) - } - if lib.Type != wantType { - t.Fatalf("auto category %s type = %s, want %s", dir, lib.Type, wantType) - } - } - - var rows []model.Media - if err := repos.DB.Order("path").Find(&rows).Error; err != nil { - t.Fatal(err) - } - if len(rows) != 3 { - t.Fatalf("media rows = %d, want 3", len(rows)) - } - wantLibraries := map[string]string{ - "cloud://openlist/电视剧/欧美剧/The Show/The.Show.S01E01.mkv": byDisplayDir["电视剧/欧美剧"].ID, - "cloud://openlist/电影/华语电影/Movie.2024.mkv": byDisplayDir["电影/华语电影"].ID, - "cloud://openlist/动漫/国漫/剑来/剑来.S01E01.mkv": byDisplayDir["动漫/国漫"].ID, - } - for _, row := range rows { - if row.LibraryID != wantLibraries[row.Path] { - t.Fatalf("%s library_id = %s, want %s", row.Path, row.LibraryID, wantLibraries[row.Path]) - } - } - - res, err = scanner.ScanLibrary(t.Context(), root.ID) - if err != nil { - t.Fatalf("rescan root cloud: %v", err) - } - if res.Added != 0 || res.Updated != 0 || res.Skipped != 3 { - t.Fatalf("rescan should skip unchanged auto-category rows, got %#v", res) - } - libs, err = repos.Library.List(t.Context()) - if err != nil { - t.Fatal(err) - } - autoCount := 0 - for _, lib := range libs { - if CloudLibraryAutoCategory(lib) { - autoCount++ - } - } - if autoCount != 3 { - t.Fatalf("auto category library count after rescan = %d, want 3", autoCount) - } - - empty = true - res, err = scanner.ScanLibrary(t.Context(), root.ID) - if err != nil { - t.Fatalf("empty rescan root cloud: %v", err) - } - if res.Removed != 3 { - t.Fatalf("removed = %d, want 3", res.Removed) - } - if got := countMedia(t, repos); got != 0 { - t.Fatalf("media count after auto-category prune = %d, want 0", got) - } -} - -func TestScanRootCloudAutoCategoryAppendsExistingLibraryRoot(t *testing.T) { - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - switch path { - case "/": - return []openListTestEntry{{Name: "电影", IsDir: true}}, 1 - case "/电影": - return []openListTestEntry{{Name: "华语电影", IsDir: true}}, 1 - case "/电影/华语电影": - return []openListTestEntry{{Name: "Movie.2024.mkv", Size: 202}}, 1 - default: - t.Fatalf("unexpected openlist path %q", path) - return nil, 0 - } - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - storage := newOpenListStorageForTest(t, repos, upstream.URL) - local := model.Library{Name: "华语电影", Path: "/media/电影/华语电影", Type: "movie", Enabled: true} - if err := repos.Library.CreateWithRoots(t.Context(), &local, []model.LibraryRoot{{ - Name: "华语电影", - Path: local.Path, - Enabled: true, - }}); err != nil { - t.Fatal(err) - } - root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &root); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - scanner.SetStorageConfig(storage) - - res, err := scanner.ScanLibrary(t.Context(), root.ID) - if err != nil { - t.Fatalf("scan root cloud: %v", err) - } - if res.Added != 1 { - t.Fatalf("added = %d, want 1", res.Added) - } - libs, err := repos.Library.List(t.Context()) - if err != nil { - t.Fatal(err) - } - for _, lib := range libs { - if CloudLibraryAutoCategory(lib) { - t.Fatalf("auto category should append to existing library, got extra library %#v", lib) - } - } - roots, err := repos.Library.ListRoots(t.Context(), local.ID) - if err != nil { - t.Fatal(err) - } - if len(roots) != 2 { - t.Fatalf("roots = %#v, want local root plus cloud root", roots) - } - cloudRoot := roots[1] - if cloudRoot.Name != "华语电影" || !CloudLibraryAutoCategory(model.Library{Path: cloudRoot.Path}) { - t.Fatalf("cloud root = %#v, want auto-category 华语电影 root", cloudRoot) - } - info, ok := ParseCloudLibraryMount(cloudRoot.Path) - if !ok || info.DisplayDir != "电影/华语电影" || info.ScanDir != "电影/华语电影" { - t.Fatalf("cloud root mount = %#v, want display/scan 电影/华语电影", info) - } - var media model.Media - if err := repos.DB.First(&media, "path = ?", "cloud://openlist/电影/华语电影/Movie.2024.mkv").Error; err != nil { - t.Fatal(err) - } - if media.LibraryID != local.ID || media.LibraryRootID != cloudRoot.ID { - t.Fatalf("media placement = library %s root %s, want %s/%s", media.LibraryID, media.LibraryRootID, local.ID, cloudRoot.ID) - } -} - -func TestScanRootCloudAutoCategoryPreservesFlatScanDir(t *testing.T) { - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - switch path { - case "/": - return []openListTestEntry{{Name: "国漫", IsDir: true}}, 1 - case "/国漫": - return []openListTestEntry{{Name: "剑来", IsDir: true}}, 1 - case "/国漫/剑来": - return []openListTestEntry{{Name: "剑来.S01E01.mkv", Size: 303}}, 1 - default: - t.Fatalf("unexpected openlist path %q", path) - return nil, 0 - } - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - storage := newOpenListStorageForTest(t, repos, upstream.URL) - local := model.Library{Name: "国漫", Path: "/media/动漫/国漫", Type: "anime", Enabled: true} - if err := repos.Library.CreateWithRoots(t.Context(), &local, []model.LibraryRoot{{ - Name: "国漫", - Path: local.Path, - Enabled: true, - }}); err != nil { - t.Fatal(err) - } - root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &root); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - scanner.SetStorageConfig(storage) - - if _, err := scanner.ScanLibrary(t.Context(), root.ID); err != nil { - t.Fatalf("scan root cloud: %v", err) - } - roots, err := repos.Library.ListRoots(t.Context(), local.ID) - if err != nil { - t.Fatal(err) - } - if len(roots) != 2 { - t.Fatalf("roots = %#v, want local root plus flat cloud root", roots) - } - cloudRoot := roots[1] - info, ok := ParseCloudLibraryMount(cloudRoot.Path) - if !ok || info.DisplayDir != "动漫/国漫" || info.ScanDir != "国漫" { - t.Fatalf("flat cloud root mount = %#v, want display 动漫/国漫 and scan 国漫", info) - } - res, err := scanner.ScanLibraryRoot(t.Context(), local.ID, cloudRoot.ID) - if err != nil { - t.Fatalf("scan flat cloud root: %v", err) - } - if res.Skipped != 1 && res.Updated != 1 { - t.Fatalf("flat cloud root rescan = %#v, want existing media refreshed/skipped", res) - } -} - -func TestScanRootCloudAutoCategoryMigratesExistingAutoLibrary(t *testing.T) { - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - switch path { - case "/": - return []openListTestEntry{{Name: "电视剧", IsDir: true}}, 1 - case "/电视剧": - return []openListTestEntry{{Name: "欧美剧", IsDir: true}}, 1 - case "/电视剧/欧美剧": - return []openListTestEntry{{Name: "The Show", IsDir: true}}, 1 - case "/电视剧/欧美剧/The Show": - return []openListTestEntry{{Name: "The.Show.S01E01.mkv", Size: 101}}, 1 - default: - t.Fatalf("unexpected openlist path %q", path) - return nil, 0 - } - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - storage := newOpenListStorageForTest(t, repos, upstream.URL) - local := model.Library{Name: "欧美剧", Path: "/media/电视剧/欧美剧", Type: "tv", Enabled: true} - if err := repos.Library.CreateWithRoots(t.Context(), &local, []model.LibraryRoot{{ - Name: "欧美剧", - Path: local.Path, - Enabled: true, - }}); err != nil { - t.Fatal(err) - } - root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - oldAuto := model.Library{Name: "欧美剧", Path: BuildCloudAutoCategoryLibraryPath("openlist", "电视剧/欧美剧"), Type: "tv", Enabled: true} - for _, lib := range []*model.Library{&root, &oldAuto} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - mediaPath := "cloud://openlist/电视剧/欧美剧/The Show/The.Show.S01E01.mkv" - if err := repos.DB.Create(&model.Media{LibraryID: oldAuto.ID, Title: "The Show", Path: mediaPath}).Error; err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - scanner.SetStorageConfig(storage) - - if _, err := scanner.ScanLibrary(t.Context(), root.ID); err != nil { - t.Fatalf("scan root cloud: %v", err) - } - if old, err := repos.Library.FindByID(t.Context(), oldAuto.ID); err != nil || old != nil { - t.Fatalf("old auto library = %#v, err=%v; want removed", old, err) - } - roots, err := repos.Library.ListRoots(t.Context(), local.ID) - if err != nil { - t.Fatal(err) - } - if len(roots) != 2 { - t.Fatalf("roots = %#v, want local root plus migrated cloud root", roots) - } - var media model.Media - if err := repos.DB.First(&media, "path = ?", mediaPath).Error; err != nil { - t.Fatal(err) - } - if media.LibraryID != local.ID || media.LibraryRootID != roots[1].ID { - t.Fatalf("migrated media placement = %s/%s, want %s/%s", media.LibraryID, media.LibraryRootID, local.ID, roots[1].ID) - } -} - -func TestScanCloudLibraryListsChildDirectoriesConcurrently(t *testing.T) { - var active int32 - var maxActive int32 - var releaseOnce sync.Once - release := make(chan struct{}) - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - switch path { - case "/": - return []openListTestEntry{ - {Name: "A", IsDir: true}, - {Name: "B", IsDir: true}, - }, 2 - case "/A", "/B": - cur := atomic.AddInt32(&active, 1) - defer atomic.AddInt32(&active, -1) - for { - prev := atomic.LoadInt32(&maxActive) - if cur <= prev || atomic.CompareAndSwapInt32(&maxActive, prev, cur) { - break - } - } - if cur >= 2 { - releaseOnce.Do(func() { close(release) }) - } - select { - case <-release: - case <-time.After(1500 * time.Millisecond): - t.Errorf("child directory requests were not concurrent") - return nil, 0 - } - id := strings.TrimPrefix(path, "/") - return []openListTestEntry{{Name: fmt.Sprintf("Movie.%s.mkv", id), Size: 123}}, 1 - default: - t.Errorf("unexpected openlist path %q", path) - return nil, 0 - } - }) - defer upstream.Close() - - db, err := gorm.Open(sqlite.Open("file:cloud_scan_concurrent?mode=memory&cache=shared"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - cfg := &config.Config{} - cfg.App.CloudScanMaxConcurrent = 2 - scanner := NewScannerService(cfg, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - - res, err := scanner.ScanLibrary(ctx, lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if got := atomic.LoadInt32(&maxActive); got < 2 { - t.Fatalf("max concurrent child lists = %d, want >= 2", got) - } - if res.Visited != 2 || res.Added != 2 { - t.Fatalf("scan result = %#v, want visited=2 added=2", res) - } -} - -func newOpenListStorageForTest(t *testing.T, repos *repository.Container, serverURL string) *StorageConfigService { - t.Helper() - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": serverURL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - return storage -} diff --git a/internal/service/scanner_cloud_candidates.go b/internal/service/scanner_cloud_candidates.go deleted file mode 100644 index 21b32e1..0000000 --- a/internal/service/scanner_cloud_candidates.go +++ /dev/null @@ -1,247 +0,0 @@ -package service - -import ( - "context" - "path/filepath" - "strings" - "sync" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -type cloudScanCandidateRequest struct { - provider string - rootDir string - rootDisplayDir string - autoCategoryRoot bool - progress *cloudScanProgressState - result *ScanResult -} - -func (s *ScannerService) collectCloudScanCandidates(ctx context.Context, lib *model.Library, req cloudScanCandidateRequest) ([]cloudCandidate, error) { - collector := newCloudScanCandidateCollector(s, ctx, lib, req) - return collector.collect() -} - -type cloudScanCandidateCollector struct { - scanner *ScannerService - ctx context.Context - lib *model.Library - req cloudScanCandidateRequest - - mu sync.Mutex - seenRefs map[string]struct{} - visitedDirs map[string]struct{} - candidates []cloudCandidate - candidateByKey map[string]int - - walkWG sync.WaitGroup - walkErr error - walkErrOnce sync.Once - listSlots chan struct{} -} - -func newCloudScanCandidateCollector(s *ScannerService, ctx context.Context, lib *model.Library, req cloudScanCandidateRequest) *cloudScanCandidateCollector { - return &cloudScanCandidateCollector{ - scanner: s, - ctx: ctx, - lib: lib, - req: req, - seenRefs: make(map[string]struct{}), - visitedDirs: map[string]struct{}{}, - candidates: make([]cloudCandidate, 0, 256), - candidateByKey: make(map[string]int), - listSlots: make(chan struct{}, s.cloudScanWorkerCount()), - } -} - -func (c *cloudScanCandidateCollector) collect() ([]cloudCandidate, error) { - c.walkWG.Add(1) - go func() { - _ = c.walk(c.req.rootDir, c.req.rootDisplayDir, nil) - }() - c.walkWG.Wait() - if c.walkErr != nil { - return nil, c.walkErr - } - if err := c.ctx.Err(); err != nil { - return nil, err - } - return c.candidates, nil -} - -func (c *cloudScanCandidateCollector) walk(dirID, displayDir string, inheritedMeta *LocalMetadata) error { - defer c.walkWG.Done() - if err := c.ctx.Err(); err != nil { - c.setWalkErr(err) - return err - } - if !c.markDirectoryVisited(dirID) { - return nil - } - release, err := c.acquireListSlot() - if err != nil { - c.setWalkErr(err) - return err - } - defer release() - - entries, err := c.scanner.storage.CloudList(c.ctx, c.req.provider, dirID) - if err != nil { - return c.handleListError(dirID, err) - } - c.req.progress.publish(c.scanner, c.lib.ID, c.req.result, "listing", c.req.progress.markDirVisited()) - sidecars := newCloudSidecarSet(c.req.provider, entries) - dirMeta := c.scanner.cloudDirectoryMetadata(c.ctx, c.req.provider, displayDir, sidecars, inheritedMeta) - c.scanner.cacheCloudMetadataArtworkNow(c.ctx, dirMeta) - for _, entry := range entries { - if err := c.ctx.Err(); err != nil { - c.setWalkErr(err) - return err - } - if entry.IsDir { - c.queueChildDirectory(displayDir, entry.Name, entry.ID, dirMeta) - continue - } - c.addFileCandidate(displayDir, entry, sidecars, dirMeta) - } - return nil -} - -func (c *cloudScanCandidateCollector) markDirectoryVisited(dirID string) bool { - c.mu.Lock() - defer c.mu.Unlock() - if _, ok := c.visitedDirs[dirID]; ok { - return false - } - c.visitedDirs[dirID] = struct{}{} - return true -} - -func (c *cloudScanCandidateCollector) acquireListSlot() (func(), error) { - select { - case c.listSlots <- struct{}{}: - return func() { <-c.listSlots }, nil - case <-c.ctx.Done(): - return nil, c.ctx.Err() - } -} - -func (c *cloudScanCandidateCollector) handleListError(dirID string, err error) error { - if dirID != c.req.rootDir { - c.req.progress.addSkipped(c.req.result) - c.scanner.log.Warn("skip inaccessible cloud directory", - zap.String("library_id", c.lib.ID), - zap.String("provider", c.req.provider), - zap.String("dir", dirID), - zap.Error(err)) - return nil - } - c.setWalkErr(err) - return err -} - -func (c *cloudScanCandidateCollector) queueChildDirectory(displayDir, entryName, entryID string, dirMeta *LocalMetadata) { - if strings.TrimSpace(entryID) == "" { - return - } - c.walkWG.Add(1) - go func(childID, childDisplay string, childMeta *LocalMetadata) { - _ = c.walk(childID, childDisplay, childMeta) - }(entryID, joinCloudDisplayPath(displayDir, entryName), dirMeta) -} - -func (c *cloudScanCandidateCollector) addFileCandidate(displayDir string, entry cloud.FileEntry, sidecars cloudSidecarSet, dirMeta *LocalMetadata) { - ext := strings.ToLower(filepath.Ext(entry.Name)) - if _, ok := videoExtensions[ext]; !ok { - return - } - ref := cloudEntryRef(c.req.provider, entry.ID, entry.PickCode) - if ref == "" { - c.req.progress.addSkipped(c.req.result) - return - } - if !c.markRefSeen(ref) { - c.req.progress.addSkipped(c.req.result) - return - } - c.req.progress.publish(c.scanner, c.lib.ID, c.req.result, "listing", c.req.progress.markFileDiscovered()) - displayPath := joinCloudDisplayPath(displayDir, entry.Name) - path := cloudMediaPath(c.req.provider, displayPath) - candidate := cloudCandidate{ - ref: ref, - name: entry.Name, - size: entry.Size, - path: path, - } - if c.req.autoCategoryRoot { - candidate.categoryDisplayDir, candidate.categoryScanDir = cloudAutoCategoryDirsForMediaPath(path) - if candidate.categoryDisplayDir != "" { - displayPath = canonicalCloudAutoCategoryMediaDisplayPath(displayPath, candidate.categoryDisplayDir, candidate.categoryScanDir) - candidate.path = cloudMediaPath(c.req.provider, displayPath) - } - } - localMeta := c.scanner.cloudFileMetadata(c.ctx, c.req.provider, displayPath, entry.Name, sidecars, dirMeta, librarySupportsSeasons(c.lib)) - localMeta = c.scanner.enrichCloudMetadataFromExternalIDs(c.ctx, c.lib, candidate.path, localMeta) - if localMeta != nil { - c.scanner.cacheCloudMetadataArtworkNow(c.ctx, localMeta) - } - candidate.localMeta = localMeta - c.addCandidate(displayDir, entry, candidate) -} - -func canonicalCloudAutoCategoryMediaDisplayPath(displayPath, categoryDisplayDir, categoryScanDir string) string { - displayPath = strings.Trim(strings.TrimSpace(strings.ReplaceAll(displayPath, "\\", "/")), "/") - categoryDisplayDir = strings.Trim(strings.TrimSpace(strings.ReplaceAll(categoryDisplayDir, "\\", "/")), "/") - categoryScanDir = strings.Trim(strings.TrimSpace(strings.ReplaceAll(categoryScanDir, "\\", "/")), "/") - if displayPath == "" || categoryDisplayDir == "" || categoryScanDir == "" || displayPath == categoryDisplayDir || categoryDisplayDir == categoryScanDir { - return displayPath - } - if displayPath == categoryScanDir { - return categoryDisplayDir - } - prefix := strings.TrimRight(categoryScanDir, "/") + "/" - if strings.HasPrefix(displayPath, prefix) { - return strings.TrimRight(categoryDisplayDir, "/") + "/" + strings.TrimPrefix(displayPath, prefix) - } - return displayPath -} - -func (c *cloudScanCandidateCollector) markRefSeen(ref string) bool { - c.mu.Lock() - defer c.mu.Unlock() - if _, ok := c.seenRefs[ref]; ok { - return false - } - c.seenRefs[ref] = struct{}{} - return true -} - -func (c *cloudScanCandidateCollector) addCandidate(displayDir string, entry cloud.FileEntry, candidate cloudCandidate) { - key := cloudMediaDedupeKey(c.lib, displayDir, entry.Name, entry.Size) - c.mu.Lock() - defer c.mu.Unlock() - if key != "" { - if prevIndex, ok := c.candidateByKey[key]; ok { - if candidate.size > c.candidates[prevIndex].size { - c.candidates[prevIndex] = candidate - } - c.req.progress.addSkipped(c.req.result) - return - } - c.candidateByKey[key] = len(c.candidates) - } - c.candidates = append(c.candidates, candidate) -} - -func (c *cloudScanCandidateCollector) setWalkErr(err error) { - if err == nil { - return - } - c.walkErrOnce.Do(func() { - c.walkErr = err - }) -} diff --git a/internal/service/scanner_cloud_conflict.go b/internal/service/scanner_cloud_conflict.go deleted file mode 100644 index f712b5d..0000000 --- a/internal/service/scanner_cloud_conflict.go +++ /dev/null @@ -1,42 +0,0 @@ -package service - -import ( - "context" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *ScannerService) shadowedCloudLibrary(ctx context.Context, lib *model.Library) *CloudMountConflict { - libs, err := s.repo.Library.List(ctx) - if err != nil { - s.log.Warn("list libraries for cloud shadow check failed", zap.String("library_id", lib.ID), zap.Error(err)) - return nil - } - visible := FilterScannableCloudLibraries(ctx, s.repo, libs) - for _, kept := range visible { - if kept.ID == lib.ID { - return nil - } - } - current, ok := ParseCloudLibraryMount(lib.Path) - if ok { - currentKey, _ := cloudLibraryDisplayKey(*lib) - for _, kept := range visible { - info, ok := ParseCloudLibraryMount(kept.Path) - if !ok || info.Provider != current.Provider { - continue - } - keptKey, _ := cloudLibraryDisplayKey(kept) - exact := currentKey != "" && currentKey == keptKey - return &CloudMountConflict{ - Library: kept, - Exact: exact, - Nested: !exact, - ExistingIsAncestor: cloudMountAncestor(info.DisplayDir, current.DisplayDir), - } - } - } - return CloudLibraryShadowed(libs, *lib) -} diff --git a/internal/service/scanner_cloud_enrich.go b/internal/service/scanner_cloud_enrich.go deleted file mode 100644 index 77a5d1e..0000000 --- a/internal/service/scanner_cloud_enrich.go +++ /dev/null @@ -1,155 +0,0 @@ -package service - -import ( - "context" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *ScannerService) enrichCloudMetadataFromExternalIDs(ctx context.Context, lib *model.Library, path string, meta *LocalMetadata) *LocalMetadata { - if s == nil || s.scraper == nil || meta == nil || !cloudMetadataNeedsExternalEnrich(meta) { - return meta - } - localPoster, localBackdrop := cloudLocalArtworkURLs(meta) - media := &model.Media{ - LibraryID: "", - Title: firstNonEmpty(meta.Title, pathBaseSlash(path)), - Path: path, - Year: meta.Year, - TMDbID: meta.TMDbID, - BangumiID: meta.BangumiID, - DoubanID: meta.DoubanID, - TheTVDBID: meta.TheTVDBID, - SeasonNum: meta.SeasonNum, - EpisodeNum: meta.EpisodeNum, - PosterURL: meta.PosterURL, - BackdropURL: meta.BackdropURL, - } - if lib != nil { - media.LibraryID = lib.ID - } - enrichCtx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - match := s.scraper.matchFromMediaExternalIDs(enrichCtx, media, lib) - if match == nil { - return meta - } - s.scraper.applyFanartArtwork(enrichCtx, match) - mergeLocalMetadataIntoMatch(match, meta) - - enriched := cloneLocalMetadata(meta) - if enriched == nil { - enriched = &LocalMetadata{} - } - mergeMatchIntoLocalMetadata(enriched, match) - if localPoster != "" { - enriched.PosterURL = localPoster - enriched.HasArtwork = true - } - if localBackdrop != "" { - enriched.BackdropURL = localBackdrop - enriched.HasArtwork = true - } - enriched.PathHint = false - enriched.HasNFO = true - if enriched.PosterURL != "" || enriched.BackdropURL != "" { - enriched.HasArtwork = true - } - s.prefetchRemoteArtworkFromScan(ctx, enriched.PosterURL) - s.prefetchRemoteArtworkFromScan(ctx, enriched.BackdropURL) - return enriched -} - -func cloudMetadataNeedsExternalEnrich(meta *LocalMetadata) bool { - if meta == nil { - return false - } - hasExternalID := meta.TMDbID > 0 || meta.BangumiID > 0 || strings.TrimSpace(meta.DoubanID) != "" || strings.TrimSpace(meta.TheTVDBID) != "" - if !hasExternalID { - return false - } - return meta.PosterURL == "" || meta.BackdropURL == "" || meta.Overview == "" || meta.Title == "" -} - -func cloudLocalArtworkURLs(meta *LocalMetadata) (poster, backdrop string) { - if meta == nil || !meta.HasArtwork { - return "", "" - } - if _, _, ok := ParseCloudArtworkURL(meta.PosterURL); ok { - poster = meta.PosterURL - } - if _, _, ok := ParseCloudArtworkURL(meta.BackdropURL); ok { - backdrop = meta.BackdropURL - } - return poster, backdrop -} - -func mergeMatchIntoLocalMetadata(meta *LocalMetadata, match *Match) { - if meta == nil || match == nil { - return - } - if match.Title != "" { - meta.Title = match.Title - } - if match.OriginalName != "" { - meta.OriginalName = match.OriginalName - } - if match.Year > 0 { - meta.Year = match.Year - } - if match.ReleaseDate != "" { - meta.ReleaseDate = match.ReleaseDate - } - if match.Overview != "" { - meta.Overview = match.Overview - } - if match.Rating > 0 { - meta.Rating = match.Rating - } - if match.PosterURL != "" { - meta.PosterURL = match.PosterURL - } - if match.BackdropURL != "" { - meta.BackdropURL = match.BackdropURL - } - if match.TMDbID > 0 { - meta.TMDbID = match.TMDbID - } - if match.BangumiID > 0 { - meta.BangumiID = match.BangumiID - } - if match.DoubanID != "" { - meta.DoubanID = match.DoubanID - } - if match.TheTVDBID != "" { - meta.TheTVDBID = match.TheTVDBID - } - if len(match.Genres) > 0 { - meta.Genres = strings.Join(match.Genres, ",") - } - if len(match.Countries) > 0 { - meta.Countries = strings.Join(match.Countries, ",") - } - if len(match.Languages) > 0 { - meta.Languages = strings.Join(match.Languages, ",") - } - if match.NSFW { - meta.NSFW = true - } -} - -func (s *ScannerService) prefetchRemoteArtworkFromScan(ctx context.Context, raw string) { - if s == nil || s.imageProxy == nil || !isHTTPish(raw) { - return - } - fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second) - err := s.imageProxy.PrefetchRemote(fetchCtx, raw) - cancel() - if err != nil && s.log != nil { - s.log.Debug("scan remote artwork prefetch failed", zap.String("url", raw), zap.Error(err)) - } -} diff --git a/internal/service/scanner_cloud_entry.go b/internal/service/scanner_cloud_entry.go deleted file mode 100644 index 6507624..0000000 --- a/internal/service/scanner_cloud_entry.go +++ /dev/null @@ -1,66 +0,0 @@ -package service - -import ( - "context" - "errors" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *ScannerService) scanMountedCloudLibrary(ctx context.Context, lib *model.Library, mount CloudMountInfo, autoScrape bool) (*ScanResult, error) { - if IsDeprecatedNativeCloudProvider(mount.Provider) { - return &ScanResult{LibraryID: lib.ID, Skipped: 1}, nil - } - if CloudLibraryAutoCategory(*lib) { - res := &ScanResult{LibraryID: lib.ID, Skipped: 1} - s.log.Info("skip auto category cloud library scan", - zap.String("library_id", lib.ID), - zap.String("provider", mount.Provider)) - s.hub.Publish("scan", map[string]any{ - "library_id": lib.ID, - "finished": true, - "skipped": res.Skipped, - "cloud": true, - "auto_category": true, - }) - return res, nil - } - if shadow := s.shadowedCloudLibrary(ctx, lib); shadow != nil { - res := &ScanResult{LibraryID: lib.ID, Skipped: 1} - s.log.Warn("skip shadowed cloud library scan", - zap.String("library_id", lib.ID), - zap.String("shadowed_by", shadow.Library.ID), - zap.String("provider", mount.Provider)) - s.hub.Publish("scan", map[string]any{ - "library_id": lib.ID, - "finished": true, - "skipped": res.Skipped, - "cloud": true, - "shadowed": true, - }) - return res, nil - } - scanCtx, finish, err := s.beginCloudScan(ctx, lib, mount) - if err != nil { - if errors.Is(err, ErrCloudScanAlreadyRunning) { - return &ScanResult{LibraryID: lib.ID, Skipped: 1}, nil - } - return nil, err - } - release, err := s.acquireCloudScanSlot(scanCtx, lib.ID) - if err != nil { - res := &ScanResult{LibraryID: lib.ID} - if finish != nil { - finish(res, err) - } - return res, err - } - defer release() - res, err := s.scanCloudLibrary(scanCtx, lib, mount, autoScrape) - if finish != nil { - finish(res, err) - } - return res, err -} diff --git a/internal/service/scanner_cloud_ingest.go b/internal/service/scanner_cloud_ingest.go deleted file mode 100644 index 34bb7b2..0000000 --- a/internal/service/scanner_cloud_ingest.go +++ /dev/null @@ -1,125 +0,0 @@ -package service - -import ( - "context" - "path/filepath" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library, rootID, typ, ref, path, name string, size int64, localMeta *LocalMetadata, existingMedia map[string]existingCloudMedia, writeBatch *localMediaWriteBatch, probeBudget *int, res *ScanResult) { - res.Visited++ - ext := strings.ToLower(filepath.Ext(name)) - title, year := CleanQueryWithRecognition(ctx, s.repo, name) - if title == "" { - title = strings.TrimSuffix(filepath.Base(name), ext) - } - if title == "" { - title = ref - } - title, year = preferISOParentScrapeIdentity(path, lib.Path, title, year) - parsedSeason, parsedEpisode := ParseEpisode(path) - if librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0 { - if seriesTitle, seriesYear := cloudSeriesTitleFromMediaPath(path); seriesTitle != "" { - title = seriesTitle - if seriesYear > 0 { - year = seriesYear - } - } - } - expectedSTRMURL := BuildRelativeCloudPlayURL(typ, ref) - m := &model.Media{ - LibraryID: lib.ID, - LibraryRootID: strings.TrimSpace(rootID), - Title: title, - Year: year, - Path: path, - SizeBytes: size, - Container: strings.TrimPrefix(ext, "."), - STRMURL: expectedSTRMURL, - ScrapeStatus: "pending", - SeasonNum: parsedSeason, - EpisodeNum: parsedEpisode, - } - if ext == ".strm" { - if targetURL, err := s.resolveCloudSTRMTarget(ctx, typ, ref); err == nil && targetURL != "" { - m.STRMURL = targetURL - } else if err != nil { - s.log.Debug("read cloud strm failed", zap.String("ref", ref), zap.Error(err)) - } - } - if localMeta != nil { - applyLocalMetadata(m, localMeta) - s.queueCloudArtworkPrefetch(localMeta.PosterURL) - s.queueCloudArtworkPrefetch(localMeta.BackdropURL) - } - if _, hints := pathHintMetadata(path, librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0); hints.useful() { - if hints.TMDbID > 0 && m.TMDbID <= 0 { - m.TMDbID = hints.TMDbID - } - if hints.BangumiID > 0 && m.BangumiID <= 0 { - m.BangumiID = hints.BangumiID - } - if strings.TrimSpace(hints.DoubanID) != "" && strings.TrimSpace(m.DoubanID) == "" { - m.DoubanID = strings.TrimSpace(hints.DoubanID) - } - if strings.TrimSpace(hints.TheTVDBID) != "" && strings.TrimSpace(m.TheTVDBID) == "" { - m.TheTVDBID = strings.TrimSpace(hints.TheTVDBID) - } - } - isNewMedia := false - needsTrackProbe := true - if existingMedia != nil { - existing, exists := existingMedia[path] - isNewMedia = !exists - needsTrackProbe = !exists || cloudTrackMetadataMissing(existing) - if exists && existing.LibraryID == lib.ID && existing.SizeBytes == size && existing.STRMURL == expectedSTRMURL && !cloudMetadataNeedsRefresh(existing, localMeta) && !cloudDerivedMetadataNeedsRefresh(existing, m) { - if needsTrackProbe && mediaExtensionSupportsProbe(ext) { - s.queueCloudMediaProbeWithBudget(typ, ref, path, probeBudget) - } - res.Skipped++ - return - } - } else { - isNewMedia = !s.mediaPathExists(ctx, path) - } - if localMeta != nil { - res.LocalMetadata++ - } - if isNewMedia && writeBatch != nil { - var after func() - if needsTrackProbe && mediaExtensionSupportsProbe(ext) { - after = func() { - s.queueCloudMediaProbeWithBudget(typ, ref, path, probeBudget) - } - } - writeBatch.AddWithAfter(path, m, after) - return - } - if err := s.repo.Media.Upsert(ctx, m); err != nil { - addScanError(res, path, err) - s.log.Warn("upsert cloud media failed", zap.String("path", path), zap.Error(err)) - return - } - if needsTrackProbe && mediaExtensionSupportsProbe(ext) { - s.queueCloudMediaProbeWithBudget(typ, ref, path, probeBudget) - } - if isNewMedia { - res.Added++ - } else { - res.Updated++ - } - if s.hub != nil && (res.Visited == 1 || res.Visited%100 == 0) { - s.hub.Publish("scan", map[string]any{ - "library_id": lib.ID, - "path": path, - "visited": res.Visited, - "added": res.Added, - "updated": res.Updated, - "cloud": true, - }) - } -} diff --git a/internal/service/scanner_cloud_jobs.go b/internal/service/scanner_cloud_jobs.go deleted file mode 100644 index 8e99ce4..0000000 --- a/internal/service/scanner_cloud_jobs.go +++ /dev/null @@ -1,151 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func (s *ScannerService) StartCloudLibraryScan(libraryID string, autoScrape bool) (CloudScanStatus, bool, error) { - if s == nil { - return CloudScanStatus{}, false, errors.New("scanner unavailable") - } - lib, err := s.repo.Library.FindByID(context.Background(), libraryID) - if err != nil { - return CloudScanStatus{}, false, err - } - if lib == nil { - return CloudScanStatus{}, false, errors.New("library not found") - } - mount, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - return CloudScanStatus{}, false, errors.New("library is not a cloud mount") - } - if IsDeprecatedNativeCloudProvider(mount.Provider) { - return CloudScanStatus{}, false, fmt.Errorf("cloud provider %q is deprecated; use OpenList or CloudDrive2 bridge", mount.Provider) - } - s.cloudScanMu.Lock() - if entry := s.cloudScans[libraryID]; cloudScanBlocksBegin(entry) { - status := entry.status - s.cloudScanMu.Unlock() - return status, false, nil - } - s.cloudScanMu.Unlock() - - go func() { - ctx, cancel := cloudScanContext(context.Background(), cloudScanTimeout(context.Background(), s.repo, 24*time.Hour)) - defer cancel() - if autoScrape { - _, err = s.ScanLibrary(ctx, libraryID) - } else { - _, err = s.ScanLibraryWithoutAutoScrape(ctx, libraryID) - } - if err != nil && !errors.Is(err, ErrCloudScanAlreadyRunning) && s.log != nil { - s.log.Warn("cloud library background scan failed", zap.String("library_id", libraryID), zap.Error(err)) - } - }() - return newCloudScanEntry(libraryID, mount.Provider, nil).status.withQueuedState(), true, nil -} - -func (status CloudScanStatus) withQueuedState() CloudScanStatus { - status.Stage = "queued" - status.State = "queued" - return status -} - -func cloudScanContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { - if timeout <= 0 { - return context.WithCancel(parent) - } - return context.WithTimeout(parent, timeout) -} - -func cloudScanTimeout(ctx context.Context, repo *repository.Container, fallback time.Duration) time.Duration { - if repo == nil || repo.Setting == nil { - return fallback - } - value, err := repo.Setting.Get(ctx, "cloud.scan_timeout_hours") - if err != nil || strings.TrimSpace(value) == "" { - return fallback - } - hours := parseIntSettingDefault(strings.TrimSpace(value), int(fallback/time.Hour)) - if hours <= 0 { - return 0 - } - return time.Duration(hours) * time.Hour -} - -func (s *ScannerService) StartAllCloudLibraryScans() ([]CloudScanStatus, error) { - if s == nil { - return nil, errors.New("scanner unavailable") - } - libs, err := s.repo.Library.List(context.Background()) - if err != nil { - return nil, err - } - libs = FilterScannableCloudLibraries(context.Background(), s.repo, libs) - statuses := make([]CloudScanStatus, 0, len(libs)) - queue := make([]string, 0, len(libs)) - for _, lib := range libs { - if !lib.Enabled { - continue - } - mount, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - continue - } - status, queued := s.queueCloudLibraryScan(lib, mount) - if queued { - queue = append(queue, lib.ID) - } - statuses = append(statuses, status) - } - if len(queue) > 0 { - go s.runQueuedCloudLibraryScans(queue) - } - return statuses, nil -} - -func (s *ScannerService) queueCloudLibraryScan(lib model.Library, mount CloudMountInfo) (CloudScanStatus, bool) { - status := newCloudScanEntry(lib.ID, mount.Provider, nil).status.withQueuedState() - s.cloudScanMu.Lock() - defer s.cloudScanMu.Unlock() - if s.cloudScans == nil { - s.cloudScans = make(map[string]*cloudScanEntry) - } - if entry := s.cloudScans[lib.ID]; cloudScanActive(entry) { - return entry.status, false - } - s.cloudScans[lib.ID] = &cloudScanEntry{status: status} - return status, true -} - -func (s *ScannerService) runQueuedCloudLibraryScans(libraryIDs []string) { - ctx, cancel := cloudScanContext(context.Background(), cloudScanTimeout(context.Background(), s.repo, 24*time.Hour)) - defer cancel() - for _, libraryID := range libraryIDs { - if ctx.Err() != nil { - return - } - if s.cloudScanWasCanceled(libraryID) { - continue - } - if _, err := s.ScanLibrary(ctx, libraryID); err != nil && !errors.Is(err, ErrCloudScanAlreadyRunning) && !errors.Is(err, context.Canceled) && s.log != nil { - s.log.Warn("cloud library queued scan failed", zap.String("library_id", libraryID), zap.Error(err)) - } - } -} - -func (s *ScannerService) cloudScanWasCanceled(libraryID string) bool { - s.cloudScanMu.Lock() - defer s.cloudScanMu.Unlock() - entry := s.cloudScans[libraryID] - return entry != nil && entry.status.State == "canceled" -} diff --git a/internal/service/scanner_cloud_metadata_test.go b/internal/service/scanner_cloud_metadata_test.go deleted file mode 100644 index f1d7bdb..0000000 --- a/internal/service/scanner_cloud_metadata_test.go +++ /dev/null @@ -1,486 +0,0 @@ -package service - -import ( - "bytes" - "net/http" - "net/http/httptest" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestScanCloudLibraryReadsRemoteSTRMTarget(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - if r.URL.Path != "/dav/Links" { - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - _, _ = w.Write([]byte(` - - - /dav/Links/ - - - - /dav/Links/Movie.strm - Movie.strm32 - -`)) - case http.MethodGet: - if r.URL.Path != "/dav/Links/Movie.strm" { - t.Fatalf("unexpected get path %s", r.URL.Path) - } - _, _ = w.Write([]byte("https://cdn.example.com/Movie.mkv\n")) - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · Links", Path: "cloud://openlist/Links", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 { - t.Fatalf("scan result = %#v, want added=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - if media.Path != "cloud://openlist/Links/Movie.strm" { - t.Fatalf("path = %q", media.Path) - } - if media.STRMURL != "https://cdn.example.com/Movie.mkv" { - t.Fatalf("strm target = %q", media.STRMURL) - } -} - -func TestScanCloudLibraryReadsMovieDirectoryNFOAndCleanTitleArtwork(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - switch r.URL.Path { - case "/dav/Movies": - _, _ = w.Write([]byte(` - - /dav/Movies/ - /dav/Movies/Action Movie (2025) {tmdb-1197306}/Action Movie (2025) {tmdb-1197306} -`)) - case "/dav/Movies/Action Movie (2025) {tmdb-1197306}": - _, _ = w.Write([]byte(` - - /dav/Movies/Action%20Movie%20(2025)%20%7Btmdb-1197306%7D/ - /dav/Movies/Action%20Movie%20(2025)%20%7Btmdb-1197306%7D/Action%20Movie%20(2025)%20-%202160p.WEB-DL.mkvAction Movie (2025) - 2160p.WEB-DL.mkv4096 - /dav/Movies/Action%20Movie%20(2025)%20%7Btmdb-1197306%7D/movie.nfomovie.nfo128 - /dav/Movies/Action%20Movie%20(2025)%20%7Btmdb-1197306%7D/action%20movie%20(2025)-poster.jpgaction movie (2025)-poster.jpg1024 -`)) - default: - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - case http.MethodGet: - switch r.URL.Path { - case "/dav/Movies/Action Movie (2025) {tmdb-1197306}/movie.nfo": - _, _ = w.Write([]byte(`Action Movie20251197306`)) - case "/dav/Movies/Action Movie (2025) {tmdb-1197306}/action movie (2025)-poster.jpg": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(testJPEG) - default: - t.Fatalf("unexpected get path %s", r.URL.Path) - } - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) - scanner.SetImageProxy(imageProxy) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - if media.Title != "Action Movie" || media.Year != 2025 || media.TMDbID != 1197306 { - t.Fatalf("movie.nfo metadata not applied: %#v", media) - } - wantPoster := "/api/img/cloud/openlist?ref=%2FMovies%2FAction+Movie+%282025%29+%7Btmdb-1197306%7D%2Faction+movie+%282025%29-poster.jpg" - if media.PosterURL != wantPoster { - t.Fatalf("poster url = %q, want %q", media.PosterURL, wantPoster) - } - rec := httptest.NewRecorder() - if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Action Movie (2025) {tmdb-1197306}/action movie (2025)-poster.jpg") { - t.Fatal("clean-title cloud poster should be cached locally during scan") - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached poster body = %x", got) - } -} - -func TestScanCloudLibraryReadsRemoteJSONMetadataAndArtwork(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - switch r.URL.Path { - case "/dav/Movies": - _, _ = w.Write([]byte(` - - /dav/Movies/ - /dav/Movies/Sidecar%20Movie%20(2026)%20%7Btmdb-12345%7D/Sidecar Movie (2026) {tmdb-12345} -`)) - case "/dav/Movies/Sidecar Movie (2026) {tmdb-12345}": - _, _ = w.Write([]byte(` - - /dav/Movies/Sidecar%20Movie%20(2026)%20%7Btmdb-12345%7D/ - /dav/Movies/Sidecar%20Movie%20(2026)%20%7Btmdb-12345%7D/Sidecar%20Movie%20(2026).mkvSidecar Movie (2026).mkv4096 - /dav/Movies/Sidecar%20Movie%20(2026)%20%7Btmdb-12345%7D/Sidecar%20Movie%20(2026)-mediainfo.jsonSidecar Movie (2026)-mediainfo.json256 - /dav/Movies/Sidecar%20Movie%20(2026)%20%7Btmdb-12345%7D/poster.jpgposter.jpg1024 - /dav/Movies/Sidecar%20Movie%20(2026)%20%7Btmdb-12345%7D/backdrop.jpgbackdrop.jpg1024 -`)) - default: - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - case http.MethodGet: - switch r.URL.Path { - case "/dav/Movies/Sidecar Movie (2026) {tmdb-12345}/Sidecar Movie (2026)-mediainfo.json": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"title":"JSON Sidecar Movie","year":2026,"tmdb_id":12345,"overview":"metadata from cloud json","poster":"poster.jpg","backdrop":"backdrop.jpg","genres":["Action","Drama"]}`)) - case "/dav/Movies/Sidecar Movie (2026) {tmdb-12345}/poster.jpg": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(testJPEG) - case "/dav/Movies/Sidecar Movie (2026) {tmdb-12345}/backdrop.jpg": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(testJPEG) - default: - t.Fatalf("unexpected get path %s", r.URL.Path) - } - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) - scanner.SetImageProxy(imageProxy) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - if media.Title != "JSON Sidecar Movie" || media.Year != 2026 || media.TMDbID != 12345 || media.ScrapeStatus != "matched" { - t.Fatalf("json metadata not applied: %#v", media) - } - wantPoster := "/api/img/cloud/openlist?ref=%2FMovies%2FSidecar+Movie+%282026%29+%7Btmdb-12345%7D%2Fposter.jpg" - if media.PosterURL != wantPoster { - t.Fatalf("poster url = %q, want %q", media.PosterURL, wantPoster) - } - rec := httptest.NewRecorder() - if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Sidecar Movie (2026) {tmdb-12345}/poster.jpg") { - t.Fatal("JSON cloud poster should be cached locally during scan") - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached poster body = %x", got) - } -} - -func TestCloudEpisodeJSONDoesNotPolluteSeriesIdentity(t *testing.T) { - local, _ := metadataFromCloudJSON([]byte(`{ - "title": "九龙拉棺", - "episode": 1, - "tmdb_id": 4375419, - "original_title": "Burial", - "overview": "本集简介", - "poster": "episode-poster.jpg", - "genres": ["动画"] - }`)) - if local == nil { - t.Fatal("episode json metadata was not parsed") - } - dst := &LocalMetadata{Title: "遮天", Year: 2023, SeasonNum: 1} - got := mergeCloudEpisodeMetadata(dst, local) - if got.Title != "遮天" || got.OriginalName != "" || got.TMDbID != 0 { - t.Fatalf("episode json polluted series identity: %+v", got) - } - if got.EpisodeTitle != "九龙拉棺" || got.Overview != "本集简介" || got.SeasonNum != 1 || got.EpisodeNum != 1 { - t.Fatalf("episode json fields not preserved: %+v", got) - } - if got.Genres != "动画" { - t.Fatalf("episode json taxonomy should fill empty series taxonomy, got %q", got.Genres) - } -} - -func TestScanCloudLibraryEnrichesPathHintTMDbArtwork(t *testing.T) { - tmdb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/movie/755679" { - t.Fatalf("unexpected tmdb path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "id": 755679, - "title": "速度与激情11", - "original_title": "Fast X: Part 2", - "overview": "Exact metadata by TMDb ID", - "poster_path": "/poster-fast11.jpg", - "backdrop_path": "/backdrop-fast11.jpg", - "release_date": "2028-04-07", - "vote_average": 7.2, - "genres": [{"name":"Action"}], - "production_countries": [{"iso_3166_1":"US"}], - "spoken_languages": [{"iso_639_1":"en"}] - }`)) - })) - defer tmdb.Close() - - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - switch r.URL.Path { - case "/dav/Movies": - _, _ = w.Write([]byte(` - - /dav/Movies/ - /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/速度与激情11 (2028) {tmdb-755679} -`)) - case "/dav/Movies/速度与激情11 (2028) {tmdb-755679}": - _, _ = w.Write([]byte(` - - /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/ - /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028).mkv速度与激情11 (2028).mkv4096 -`)) - default: - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}, &model.APIConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - cfg := &config.Config{} - cfg.Secrets.TMDbAPIKey = "test-key" - cfg.Secrets.TMDbAPIProxy = tmdb.URL - cfg.Secrets.TMDbImageProxy = "https://image.tmdb.org/t/p" - scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log)) - scanner := NewScannerService(cfg, log, repos, NewHub(log), nil, scraper) - scanner.SetStorageConfig(storage) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - if media.ScrapeStatus != "matched" || media.TMDbID != 755679 || media.PosterURL == "" || media.BackdropURL == "" || media.Overview == "" { - t.Fatalf("path-hint tmdb metadata not enriched: %#v", media) - } - if media.PosterURL != "https://image.tmdb.org/t/p/w500/poster-fast11.jpg" { - t.Fatalf("poster url = %q", media.PosterURL) - } -} - -func TestScanCloudLibraryKeepsCloudArtworkWhenEnrichingPathHint(t *testing.T) { - tmdb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/movie/755679" { - t.Fatalf("unexpected tmdb path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "id": 755679, - "title": "速度与激情11", - "overview": "Exact metadata by TMDb ID", - "poster_path": "/remote-poster.jpg", - "backdrop_path": "/remote-backdrop.jpg", - "release_date": "2028-04-07" - }`)) - })) - defer tmdb.Close() - - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - switch r.URL.Path { - case "/dav/Movies": - _, _ = w.Write([]byte(` - - /dav/Movies/ - /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/速度与激情11 (2028) {tmdb-755679} -`)) - case "/dav/Movies/速度与激情11 (2028) {tmdb-755679}": - _, _ = w.Write([]byte(` - - /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/ - /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028).mkv速度与激情11 (2028).mkv4096 - /dav/Movies/%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511%20(2028)%20%7Btmdb-755679%7D/poster.jpgposter.jpg1024 -`)) - default: - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - case http.MethodGet: - if r.URL.Path != "/dav/Movies/速度与激情11 (2028) {tmdb-755679}/poster.jpg" { - t.Fatalf("unexpected get path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write(testJPEG) - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}, &model.APIConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - cfg := &config.Config{} - cfg.Secrets.TMDbAPIKey = "test-key" - cfg.Secrets.TMDbAPIProxy = tmdb.URL - cfg.Secrets.TMDbImageProxy = "https://image.tmdb.org/t/p" - scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log)) - scanner := NewScannerService(cfg, log, repos, NewHub(log), nil, scraper) - scanner.SetStorageConfig(storage) - imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) - scanner.SetImageProxy(imageProxy) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - wantPoster := "/api/img/cloud/openlist?ref=%2FMovies%2F%E9%80%9F%E5%BA%A6%E4%B8%8E%E6%BF%80%E6%83%8511+%282028%29+%7Btmdb-755679%7D%2Fposter.jpg" - if media.PosterURL != wantPoster { - t.Fatalf("poster url = %q, want local cloud poster %q", media.PosterURL, wantPoster) - } - if media.BackdropURL != "https://image.tmdb.org/t/p/w1280/remote-backdrop.jpg" || media.Overview == "" { - t.Fatalf("external enrichment should still fill missing fields: %#v", media) - } - rec := httptest.NewRecorder() - if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/速度与激情11 (2028) {tmdb-755679}/poster.jpg") { - t.Fatal("local cloud poster should be cached during enriched scan") - } - if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) { - t.Fatalf("cached poster body = %x", got) - } -} diff --git a/internal/service/scanner_cloud_openlist_test.go b/internal/service/scanner_cloud_openlist_test.go deleted file mode 100644 index 71cc5b8..0000000 --- a/internal/service/scanner_cloud_openlist_test.go +++ /dev/null @@ -1,338 +0,0 @@ -package service - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestScanCloudLibraryImportsRecursivePlayableMedia(t *testing.T) { - empty := false - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - if empty { - return nil, 0 - } - switch path { - case "/": - return []openListTestEntry{ - {Name: "Movies", IsDir: true}, - {Name: "Root.Movie.2024.mkv", Size: 123}, - }, 2 - case "/Movies": - return []openListTestEntry{ - {Name: "Nested.Show.S01E02.mp4", Size: 456}, - }, 1 - default: - t.Fatalf("unexpected openlist path %q", path) - return nil, 0 - } - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Visited != 2 || res.Added != 2 { - t.Fatalf("scan result = %#v, want visited=2 added=2", res) - } - var rows []model.Media - if err := repos.DB.Order("path").Find(&rows).Error; err != nil { - t.Fatal(err) - } - if len(rows) != 2 { - t.Fatalf("media rows = %d, want 2: %#v", len(rows), rows) - } - if rows[0].Path != "cloud://openlist/Movies/Nested.Show.S01E02.mp4" || !strings.Contains(rows[0].STRMURL, "ref=%2FMovies%2FNested.Show.S01E02.mp4") { - t.Fatalf("nested media path/strm wrong: path=%q strm=%q", rows[0].Path, rows[0].STRMURL) - } - if rows[0].SeasonNum != 1 || rows[0].EpisodeNum != 2 { - t.Fatalf("nested episode metadata wrong: %#v", rows[0]) - } - if rows[1].Path != "cloud://openlist/Root.Movie.2024.mkv" || rows[1].STRMURL != "/api/cloud/play/openlist?ref=%2FRoot.Movie.2024.mkv" { - t.Fatalf("root media path/strm wrong: path=%q strm=%q", rows[0].Path, rows[0].STRMURL) - } - - res, err = scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("rescan same cloud: %v", err) - } - if res.Added != 0 || res.Updated != 0 || res.Skipped != 2 { - t.Fatalf("same cloud rescan should skip unchanged rows, got %#v", res) - } - - empty = true - res, err = scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("rescan cloud: %v", err) - } - if res.Removed != 2 { - t.Fatalf("removed = %d, want 2", res.Removed) - } - if got := countMedia(t, repos); got != 0 { - t.Fatalf("media count after prune = %d, want 0", got) - } - var allRows int64 - if err := repos.DB.Unscoped().Model(&model.Media{}).Count(&allRows).Error; err != nil { - t.Fatal(err) - } - if allRows != 0 { - t.Fatalf("unscoped media count after cloud prune = %d, want 0", allRows) - } -} - -func TestScanOpenListCloudLibraryUsesAPIPaginationBeyondFirstPage(t *testing.T) { - const totalFiles = 125 - requestedPages := map[int]bool{} - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/fs/list" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - if r.Method != http.MethodPost { - t.Fatalf("unexpected method %s", r.Method) - } - if r.Header.Get("Authorization") != "openlist-token" { - t.Fatalf("missing openlist token: %q", r.Header.Get("Authorization")) - } - var in struct { - Path string `json:"path"` - Page int `json:"page"` - PerPage int `json:"per_page"` - } - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - t.Fatalf("decode request: %v", err) - } - if in.Path != "/Movies" { - t.Fatalf("path = %q, want /Movies", in.Path) - } - if in.PerPage <= 100 { - t.Fatalf("per_page = %d, want API pagination larger than legacy 100", in.PerPage) - } - requestedPages[in.Page] = true - effectivePageSize := in.PerPage - if effectivePageSize > 100 { - effectivePageSize = 100 - } - start := (in.Page - 1) * effectivePageSize - content := []map[string]any{} - for idx := start; idx < totalFiles && idx < start+effectivePageSize; idx++ { - content = append(content, map[string]any{ - "name": fmt.Sprintf("Movie.%03d.mkv", idx+1), - "size": int64(1024 + idx), - "is_dir": false, - }) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "code": 200, - "message": "success", - "data": map[string]any{ - "content": content, - "total": totalFiles, - }, - }) - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · Movies", Path: BuildCloudLibraryPath("openlist", "/Movies", "/Movies"), Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan openlist: %v", err) - } - if res.Added != totalFiles { - t.Fatalf("scan result = %#v, want added=%d", res, totalFiles) - } - if got := countMedia(t, repos); got != totalFiles { - t.Fatalf("media count = %d, want %d", got, totalFiles) - } - if !requestedPages[1] || !requestedPages[2] { - t.Fatalf("expected pagination beyond the first 100 entries, got pages %#v", requestedPages) - } -} - -func TestScanCloudLibraryQueuesMissingExistingTrackMetadataBeforeNewFiles(t *testing.T) { - const newFiles = maxCloudMediaProbeQueuePerScan + 5 - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - if path != "/" { - t.Fatalf("unexpected openlist path %q", path) - } - entries := make([]openListTestEntry, 0, newFiles+1) - for i := 0; i < newFiles; i++ { - entries = append(entries, openListTestEntry{Name: fmt.Sprintf("New.Movie.%02d.mkv", i), Size: int64(1000 + i)}) - } - entries = append(entries, openListTestEntry{Name: "Existing.Show.S01E01.mkv", Size: 2048}) - return entries, len(entries) - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - existingPath := "cloud://openlist/Existing.Show.S01E01.mkv" - if err := repos.DB.Create(&model.Media{ - LibraryID: lib.ID, - Title: "Existing Show", - Path: existingPath, - SizeBytes: 2048, - Container: "mkv", - STRMURL: "/api/cloud/play/openlist?ref=%2FExisting.Show.S01E01.mkv", - SeasonNum: 1, - EpisodeNum: 1, - }).Error; err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), NewFFprobeService(&config.Config{}, log), nil) - scanner.storage = storage - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != newFiles || res.Skipped != 1 { - t.Fatalf("scan result = %#v, want new files added and existing skipped", res) - } - foundExistingProbe := false - for { - select { - case task := <-scanner.cloudMediaProbeQueue: - if task.path == existingPath { - foundExistingProbe = true - } - default: - if !foundExistingProbe { - t.Fatal("existing media missing track metadata did not receive probe budget before new files") - } - return - } - } -} - -func TestScanCloudLibraryRefreshesStaleNoMatchDerivedMetadata(t *testing.T) { - const showDir = "Hntv Spring Festival Gala S01e (2026)" - const seasonDir = "Season 1" - const name = "Hntv Spring Festival Gala S01e - S01E202-DD5.QHstudIo.6.4K - 第 202 集.ts" - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - switch path { - case "/": - return []openListTestEntry{{Name: showDir, IsDir: true}}, 1 - case "/" + showDir: - return []openListTestEntry{{Name: seasonDir, IsDir: true}}, 1 - case "/" + showDir + "/" + seasonDir: - return []openListTestEntry{{Name: name, Size: 1}}, 1 - default: - t.Fatalf("unexpected openlist path %q", path) - return nil, 0 - } - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · 综艺", Path: "cloud://openlist", Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - ref := "/" + showDir + "/" + seasonDir + "/" + name - path := "cloud://openlist/" + showDir + "/" + seasonDir + "/" + name - if err := repos.DB.Create(&model.Media{ - LibraryID: lib.ID, - Title: "hntv spring festival gala s01e", - Path: path, - SizeBytes: 1, - Container: "ts", - STRMURL: BuildRelativeCloudPlayURL("openlist", ref), - ScrapeStatus: "no_match", - }).Error; err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Updated != 1 || res.Skipped != 0 { - t.Fatalf("scan result updated=%d skipped=%d, want 1/0: %#v", res.Updated, res.Skipped, res) - } - var media model.Media - if err := repos.DB.First(&media, "path = ?", path).Error; err != nil { - t.Fatal(err) - } - if media.Title != "hntv spring festival gala" || media.SeasonNum != 1 || media.EpisodeNum != 202 || media.ScrapeStatus != "pending" { - t.Fatalf("stale cloud row was not refreshed: title=%q s=%d e=%d status=%q", media.Title, media.SeasonNum, media.EpisodeNum, media.ScrapeStatus) - } -} diff --git a/internal/service/scanner_cloud_paths.go b/internal/service/scanner_cloud_paths.go deleted file mode 100644 index 556671f..0000000 --- a/internal/service/scanner_cloud_paths.go +++ /dev/null @@ -1,61 +0,0 @@ -package service - -import ( - "fmt" - "path/filepath" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func parseCloudLibraryPath(raw string) (typ, dirID string, ok bool) { - info, ok := ParseCloudLibraryMount(raw) - if !ok { - return "", "", false - } - return info.Provider, info.ScanDir, true -} - -func cloudEntryRef(typ, id, pickCode string) string { - if typ == "cloud115" && strings.TrimSpace(pickCode) != "" { - return strings.TrimSpace(pickCode) - } - return strings.TrimSpace(id) -} - -func cloudMediaPath(typ, ref string) string { - return "cloud://" + strings.TrimSpace(typ) + "/" + strings.TrimLeft(strings.TrimSpace(ref), "/") -} - -func cloudMediaDedupeKey(lib *model.Library, dirID, name string, size int64) string { - base := strings.TrimSpace(strings.TrimSuffix(filepath.Base(name), filepath.Ext(name))) - if base == "" { - return "" - } - season, episode := ParseEpisode(name) - title, year := CleanQuery(name) - title = normalizeCloudDedupeText(title) - if (season > 0 || episode > 0) && title != "" { - return fmt.Sprintf("episode:%s:%s:%d:%d:%d", strings.ToLower(strings.TrimSpace(lib.Type)), title, year, season, episode) - } - if (season > 0 || episode > 0) && title == "" { - return fmt.Sprintf("episode-dir:%s:%s:%d:%d:%d", strings.ToLower(strings.TrimSpace(lib.Type)), normalizeCloudDedupeText(dirID), season, episode, size) - } - return fmt.Sprintf("file:%s:%d", normalizeCloudDedupeText(base), size) -} - -func normalizeCloudDedupeText(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - if value == "" { - return "" - } - fields := strings.FieldsFunc(value, func(r rune) bool { - switch r { - case '.', '_', '-', ' ', '\t', '/', '\\', '[', ']', '(', ')': - return true - default: - return false - } - }) - return strings.Join(fields, " ") -} diff --git a/internal/service/scanner_cloud_probe.go b/internal/service/scanner_cloud_probe.go deleted file mode 100644 index df0f1c9..0000000 --- a/internal/service/scanner_cloud_probe.go +++ /dev/null @@ -1,122 +0,0 @@ -package service - -import ( - "context" - "errors" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *ScannerService) probeCloudMediaAsync(task cloudMediaProbeTask) { - defer func() { - s.cloudMediaProbeMu.Lock() - delete(s.cloudMediaProbing, task.path) - s.cloudMediaProbeMu.Unlock() - }() - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - probe, err := s.probeCloudFileMetadata(ctx, task.typ, task.ref) - if err != nil { - if s.log != nil { - s.log.Debug("cloud media async probe failed", zap.String("provider", task.typ), zap.String("path", task.path), zap.Error(err)) - } - s.cloudMediaProbeMu.Lock() - if s.cloudMediaProbeBackoff == nil { - s.cloudMediaProbeBackoff = make(map[string]time.Time) - } - s.cloudMediaProbeBackoff[task.path] = time.Now().Add(cloudMediaProbeFailureBackoff) - s.cloudMediaProbeMu.Unlock() - return - } - updates := probeResultUpdates(probe) - if len(updates) == 0 { - return - } - if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("path = ?", task.path).Updates(updates).Error; err != nil { - if s.log != nil { - s.log.Debug("update cloud media track metadata failed", zap.String("path", task.path), zap.Error(err)) - } - return - } - s.cloudMediaProbeMu.Lock() - delete(s.cloudMediaProbeBackoff, task.path) - s.cloudMediaProbeMu.Unlock() - if s.hub != nil { - s.hub.Publish("scan", map[string]any{ - "path": task.path, - "cloud": true, - "track_probed": true, - "duration_sec": probe.DurationSec, - "video_codec": probe.VideoCodec, - "audio_codec": probe.AudioCodec, - "width": probe.Width, - "height": probe.Height, - "probe_message": "云盘媒体轨道元数据已后台补齐", - }) - } -} - -func (s *ScannerService) ffprobeWorkerCount() int { - if s == nil || s.cfg == nil { - return 1 - } - return normalizeFFprobeMaxConcurrent(s.cfg.App.FFprobeMaxConcurrent) -} - -func (s *ScannerService) cloudScanWorkerCount() int { - if s == nil || s.cfg == nil { - return 4 - } - return normalizeCloudScanMaxConcurrent(s.cfg.App.CloudScanMaxConcurrent) -} - -func normalizeCloudScanMaxConcurrent(n int) int { - if n <= 0 { - return 1 - } - if n > 16 { - return 16 - } - return n -} - -func (s *ScannerService) probeCloudFileMetadata(ctx context.Context, typ, ref string) (*ProbeResult, error) { - if s == nil || s.probe == nil || s.storage == nil { - return nil, errors.New("cloud probe unavailable") - } - link, err := s.storage.CloudResolve(ctx, typ, ref, "") - if err != nil { - return nil, err - } - return s.probe.ProbeHTTP(ctx, link.URL, link.Headers) -} - -func probeResultUpdates(probe *ProbeResult) map[string]any { - updates := map[string]any{} - if probe == nil { - return updates - } - if probe.DurationSec > 0 { - updates["duration_sec"] = probe.DurationSec - } - if probe.Width > 0 { - updates["width"] = probe.Width - } - if probe.Height > 0 { - updates["height"] = probe.Height - } - if strings.TrimSpace(probe.VideoCodec) != "" { - updates["video_codec"] = probe.VideoCodec - } - if strings.TrimSpace(probe.AudioCodec) != "" { - updates["audio_codec"] = probe.AudioCodec - } - if probe.Container != "" { - updates["container"] = probe.Container - } - return updates -} diff --git a/internal/service/scanner_cloud_scan.go b/internal/service/scanner_cloud_scan.go deleted file mode 100644 index 45d2105..0000000 --- a/internal/service/scanner_cloud_scan.go +++ /dev/null @@ -1,184 +0,0 @@ -package service - -import ( - "context" - "fmt" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -type cloudScanImportRequest struct { - provider string - candidates []cloudCandidate - existingMedia map[string]existingCloudMedia - writeBatch *localMediaWriteBatch - probeBudget *int - defaultRootID string - progress *cloudScanProgressState - result *ScanResult -} - -type cloudScanImportResult struct { - seen map[string]struct{} - touchedLibraryIDs []string - scopeLibraryIDs []string -} - -type cloudLibraryScanCompletion struct { - libraryID string - touchedLibraryIDs []string - result *ScanResult - progress *cloudScanProgressState - autoScrape bool -} - -func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Library, mount CloudMountInfo, autoScrape bool) (*ScanResult, error) { - return s.scanCloudLibraryWithRoot(ctx, lib, mount, "", autoScrape) -} - -func (s *ScannerService) scanCloudLibraryRoot(ctx context.Context, lib *model.Library, root *model.LibraryRoot, mount CloudMountInfo, autoScrape bool) (*ScanResult, error) { - return s.scanCloudLibraryWithRoot(ctx, lib, mount, libraryRootID(root), autoScrape) -} - -func (s *ScannerService) scanCloudLibraryWithRoot(ctx context.Context, lib *model.Library, mount CloudMountInfo, defaultRootID string, autoScrape bool) (*ScanResult, error) { - res := &ScanResult{LibraryID: lib.ID} - if s.storage == nil { - return res, fmt.Errorf("cloud storage service unavailable") - } - - cfg, err := s.repo.StorageConfig.Get(ctx, mount.Provider) - if err != nil || cfg == nil { - return res, fmt.Errorf("storage config not found: %s", mount.Provider) - } - if !cfg.Enabled { - return res, fmt.Errorf("storage %s is disabled", mount.Provider) - } - typ := mount.Provider - rootDir := mount.ScanDir - rootDisplayDir := mount.DisplayDir - autoCategoryRoot := cloudRootMountNeedsAutoCategory(mount) - scopeIDs := s.cloudScanLibraryScopeIDs(ctx, lib, mount) - progress := newCloudScanProgressState() - progress.publish(s, lib.ID, res, "listing", true) - candidates, err := s.collectCloudScanCandidates(ctx, lib, cloudScanCandidateRequest{ - provider: typ, - rootDir: rootDir, - rootDisplayDir: rootDisplayDir, - autoCategoryRoot: autoCategoryRoot, - progress: progress, - result: res, - }) - if err != nil { - return res, err - } - existingMedia, err := s.existingCloudMediaSnapshotForLibraries(ctx, scopeIDs) - if err != nil { - s.log.Warn("load existing cloud media snapshot failed", zap.String("library_id", lib.ID), zap.Error(err)) - existingMedia = nil - } - sortCloudCandidatesByRefreshPriority(candidates, existingMedia) - writeBatch := newLocalMediaWriteBatch(s, ctx, res, 100) - probeBudget := maxCloudMediaProbeQueuePerScan - imported, err := s.importCloudScanCandidates(ctx, lib, cloudScanImportRequest{ - provider: typ, - candidates: candidates, - existingMedia: existingMedia, - writeBatch: writeBatch, - probeBudget: &probeBudget, - defaultRootID: defaultRootID, - progress: progress, - result: res, - }) - if err != nil { - return res, err - } - scopeIDs = appendUniqueLibraryIDs(scopeIDs, imported.scopeLibraryIDs...) - writeBatch.Flush() - var removed int64 - if defaultRootID != "" { - removed, err = s.pruneMissingCloudMediaForRoot(ctx, lib.ID, defaultRootID, imported.seen) - } else { - removed, err = s.pruneMissingCloudMediaForLibraries(ctx, scopeIDs, imported.seen) - } - if err != nil { - s.log.Warn("prune missing cloud media failed", zap.String("library_id", lib.ID), zap.Error(err)) - } else { - res.Removed = removed - } - s.completeCloudLibraryScan(ctx, cloudLibraryScanCompletion{ - libraryID: lib.ID, - touchedLibraryIDs: imported.touchedLibraryIDs, - result: res, - progress: progress, - autoScrape: autoScrape, - }) - return res, nil -} - -type cloudScanTarget struct { - lib *model.Library - rootID string -} - -func (s *ScannerService) importCloudScanCandidates(ctx context.Context, rootLib *model.Library, req cloudScanImportRequest) (cloudScanImportResult, error) { - imported := cloudScanImportResult{ - seen: make(map[string]struct{}), - touchedLibraryIDs: []string{}, - scopeLibraryIDs: []string{}, - } - targetLibs := map[string]cloudScanTarget{"": {lib: rootLib, rootID: req.defaultRootID}} - for _, candidate := range req.candidates { - select { - case <-ctx.Done(): - return imported, ctx.Err() - default: - } - target := targetLibs[""] - if candidate.categoryDisplayDir != "" { - categoryKey := candidate.categoryDisplayDir + "\x00" + candidate.categoryScanDir - if cached, ok := targetLibs[categoryKey]; ok { - target = cached - } else if categoryTarget, err := s.ensureCloudAutoCategoryTarget(ctx, rootLib, req.provider, candidate.categoryDisplayDir, candidate.categoryScanDir); err == nil && categoryTarget.Library != nil { - target = cloudScanTarget{lib: categoryTarget.Library, rootID: categoryTarget.RootID} - targetLibs[categoryKey] = target - imported.scopeLibraryIDs = appendUniqueLibraryIDs(imported.scopeLibraryIDs, categoryTarget.Library.ID) - } else if err != nil { - s.log.Warn("ensure cloud auto category library failed", - zap.String("library_id", rootLib.ID), - zap.String("provider", req.provider), - zap.String("category", candidate.categoryDisplayDir), - zap.String("scan_dir", candidate.categoryScanDir), - zap.Error(err)) - } - } - targetLib := target.lib - if targetLib == nil { - targetLib = rootLib - } - imported.touchedLibraryIDs = appendUniqueLibraryIDs(imported.touchedLibraryIDs, targetLib.ID) - imported.seen[candidate.path] = struct{}{} - s.ingestCloudFile(ctx, targetLib, target.rootID, req.provider, candidate.ref, candidate.path, candidate.name, candidate.size, candidate.localMeta, req.existingMedia, req.writeBatch, req.probeBudget, req.result) - req.progress.publish(s, rootLib.ID, req.result, "importing", req.result.Visited == 1 || req.result.Visited%100 == 0) - } - return imported, nil -} - -func (s *ScannerService) completeCloudLibraryScan(ctx context.Context, req cloudLibraryScanCompletion) { - publishCloudScanFinished(s, req.libraryID, req.result, req.progress) - s.invalidateMediaCache(ctx) - targetIDs := appendUniqueLibraryIDs(req.touchedLibraryIDs, req.libraryID) - for _, targetID := range targetIDs { - s.maybeGenerateSTRMAfterScan(targetID) - } - if scanHasImportChanges(req.result) && req.autoScrape && s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) { - for _, targetID := range targetIDs { - s.startAutoScrape(ctx, targetID) - } - } -} - -func scanHasImportChanges(res *ScanResult) bool { - return res != nil && (res.Added > 0 || res.Updated > 0 || res.Removed > 0) -} diff --git a/internal/service/scanner_cloud_scan_progress.go b/internal/service/scanner_cloud_scan_progress.go deleted file mode 100644 index 284671a..0000000 --- a/internal/service/scanner_cloud_scan_progress.go +++ /dev/null @@ -1,170 +0,0 @@ -package service - -import ( - "sort" - "sync" - "time" -) - -type cloudCandidate struct { - ref string - name string - size int64 - path string - categoryDisplayDir string - categoryScanDir string - localMeta *LocalMetadata -} - -type cloudScanProgressState struct { - mu sync.Mutex - startedAt time.Time - lastProgress time.Time - dirsVisited int - filesDiscovered int -} - -type cloudScanProgressSnapshot struct { - dirsVisited int - filesDiscovered int - visited int - added int - updated int - skipped int - removed int64 - elapsed time.Duration -} - -func newCloudScanProgressState() *cloudScanProgressState { - return &cloudScanProgressState{startedAt: time.Now()} -} - -func (p *cloudScanProgressState) markDirVisited() bool { - p.mu.Lock() - defer p.mu.Unlock() - p.dirsVisited++ - return p.dirsVisited == 1 || p.dirsVisited%20 == 0 -} - -func (p *cloudScanProgressState) markFileDiscovered() bool { - p.mu.Lock() - defer p.mu.Unlock() - p.filesDiscovered++ - return p.filesDiscovered%100 == 0 -} - -func (p *cloudScanProgressState) addSkipped(res *ScanResult) { - p.mu.Lock() - defer p.mu.Unlock() - res.Skipped++ -} - -func (p *cloudScanProgressState) publish(s *ScannerService, libraryID string, res *ScanResult, stage string, force bool) { - if s == nil || s.hub == nil { - return - } - snap, ok := p.snapshotForProgress(res, force) - if !ok { - return - } - filesPerSecond := snap.filesPerSecond() - s.updateCloudScanProgress(libraryID, stage, snap.dirsVisited, snap.filesDiscovered, snap.visited, snap.added, snap.updated, snap.skipped, snap.removed, filesPerSecond) - s.hub.Publish("scan", map[string]any{ - "library_id": libraryID, - "cloud": true, - "stage": stage, - "dirs": snap.dirsVisited, - "discovered": snap.filesDiscovered, - "visited": snap.visited, - "added": snap.added, - "updated": snap.updated, - "skipped": snap.skipped, - "elapsed_seconds": int(snap.elapsed.Seconds()), - "files_per_second": filesPerSecond, - "estimate_message": "云盘接口不提供总文件数,剩余时间会随目录大小和网盘响应速度变化", - }) -} - -func (p *cloudScanProgressState) snapshotForProgress(res *ScanResult, force bool) (cloudScanProgressSnapshot, bool) { - p.mu.Lock() - defer p.mu.Unlock() - if !force && time.Since(p.lastProgress) < 2*time.Second { - return cloudScanProgressSnapshot{}, false - } - p.lastProgress = time.Now() - return p.snapshotLocked(res), true -} - -func (p *cloudScanProgressState) finalSnapshot(res *ScanResult) cloudScanProgressSnapshot { - p.mu.Lock() - defer p.mu.Unlock() - return p.snapshotLocked(res) -} - -func (p *cloudScanProgressState) snapshotLocked(res *ScanResult) cloudScanProgressSnapshot { - snap := cloudScanProgressSnapshot{ - dirsVisited: p.dirsVisited, - filesDiscovered: p.filesDiscovered, - elapsed: time.Since(p.startedAt), - } - if res != nil { - snap.visited = res.Visited - snap.added = res.Added - snap.updated = res.Updated - snap.skipped = res.Skipped - snap.removed = res.Removed - } - return snap -} - -func (s cloudScanProgressSnapshot) filesPerSecond() float64 { - processed := s.filesDiscovered - if s.visited > processed { - processed = s.visited - } - if s.elapsed.Seconds() <= 0 { - return 0 - } - return float64(processed) / s.elapsed.Seconds() -} - -func publishCloudScanFinished(s *ScannerService, libraryID string, res *ScanResult, progress *cloudScanProgressState) { - if s == nil || s.hub == nil || progress == nil { - return - } - snap := progress.finalSnapshot(res) - s.hub.Publish("scan", map[string]any{ - "library_id": libraryID, - "finished": true, - "visited": res.Visited, - "added": res.Added, - "updated": res.Updated, - "skipped": res.Skipped, - "removed": res.Removed, - "error_count": res.ErrorCount, - "errors": res.Errors, - "discovered": snap.filesDiscovered, - "dirs": snap.dirsVisited, - "elapsed_seconds": int(snap.elapsed.Seconds()), - "cloud": true, - }) -} - -func sortCloudCandidatesByRefreshPriority(candidates []cloudCandidate, existingMedia map[string]existingCloudMedia) { - if existingMedia == nil { - return - } - priority := func(candidate cloudCandidate) int { - existing, ok := existingMedia[candidate.path] - if !ok { - return 2 - } - if cloudTrackMetadataMissing(existing) || cloudMetadataNeedsRefresh(existing, candidate.localMeta) { - return 0 - } - return 1 - } - sort.SliceStable(candidates, func(i, j int) bool { - return priority(candidates[i]) < priority(candidates[j]) - }) -} diff --git a/internal/service/scanner_cloud_status.go b/internal/service/scanner_cloud_status.go deleted file mode 100644 index 32ff11e..0000000 --- a/internal/service/scanner_cloud_status.go +++ /dev/null @@ -1,265 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *ScannerService) beginCloudScan(ctx context.Context, lib *model.Library, mount CloudMountInfo) (context.Context, func(*ScanResult, error), error) { - if s == nil || lib == nil { - return ctx, func(*ScanResult, error) {}, nil - } - s.cloudScanMu.Lock() - if s.cloudScans == nil { - s.cloudScans = make(map[string]*cloudScanEntry) - } - if entry := s.cloudScans[lib.ID]; cloudScanBlocksBegin(entry) { - s.cloudScanMu.Unlock() - return ctx, nil, ErrCloudScanAlreadyRunning - } - runCtx, cancel := context.WithCancel(ctx) - s.cloudScans[lib.ID] = newCloudScanEntry(lib.ID, mount.Provider, cancel) - s.cloudScanMu.Unlock() - - finish := func(res *ScanResult, err error) { - s.finishCloudScan(lib, mount, res, err) - } - return runCtx, finish, nil -} - -func newCloudScanEntry(libraryID, provider string, cancel context.CancelFunc) *cloudScanEntry { - now := time.Now() - return &cloudScanEntry{ - status: CloudScanStatus{ - LibraryID: libraryID, - Provider: provider, - Stage: "listing", - State: "running", - StartedAt: now, - UpdatedAt: now, - ResumeHint: "中断后再次点击扫描会从头遍历,但已入库媒体会去重更新,只补齐缺失项。", - Estimate: "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度。", - }, - cancel: cancel, - } -} - -func (s *ScannerService) finishCloudScan(lib *model.Library, mount CloudMountInfo, res *ScanResult, err error) { - s.cloudScanMu.Lock() - defer s.cloudScanMu.Unlock() - current := s.cloudScans[lib.ID] - if current == nil { - return - } - applyCloudScanResult(¤t.status, res) - current.status.UpdatedAt = time.Now() - current.status.FinishedAt = current.status.UpdatedAt - current.cancel = nil - applyCloudScanCompletion(¤t.status, err) - s.publishCloudScanFinished(lib.ID, mount.Provider, current.status) - s.notifyScanFinished(lib, res, err, true) -} - -func applyCloudScanResult(status *CloudScanStatus, res *ScanResult) { - if status == nil || res == nil { - return - } - status.Visited = res.Visited - status.Added = res.Added - status.Updated = res.Updated - status.Skipped = res.Skipped - status.Removed = res.Removed - status.ErrorCount = res.ErrorCount - status.Errors = append([]string(nil), res.Errors...) -} - -func applyCloudScanCompletion(status *CloudScanStatus, err error) { - if status == nil { - return - } - switch { - case errors.Is(err, context.Canceled): - status.State = "canceled" - status.Stage = "canceled" - status.Error = "" - case errors.Is(err, context.DeadlineExceeded): - status.State = "error" - status.Stage = "error" - status.Error = "扫描超时:" + err.Error() - case err != nil: - status.State = "error" - status.Stage = "error" - status.Error = err.Error() - default: - status.State = "finished" - status.Stage = "finished" - if status.ErrorCount > 0 { - status.Error = fmt.Sprintf("部分文件入库失败:%d 个,详情见 errors", status.ErrorCount) - } else { - status.Error = "" - } - } -} - -func (s *ScannerService) publishCloudScanFinished(libraryID, provider string, status CloudScanStatus) { - if s == nil || s.hub == nil { - return - } - s.hub.Publish("scan", map[string]any{ - "library_id": libraryID, - "provider": provider, - "cloud": true, - "finished": true, - "state": status.State, - "stage": status.Stage, - "error": status.Error, - "visited": status.Visited, - "added": status.Added, - "updated": status.Updated, - "skipped": status.Skipped, - "removed": status.Removed, - "error_count": status.ErrorCount, - "errors": status.Errors, - }) -} - -func (s *ScannerService) updateCloudScanProgress(libraryID, stage string, dirs, discovered, visited, added, updated, skipped int, removed int64, filesPerSecond float64) { - if s == nil { - return - } - s.cloudScanMu.Lock() - defer s.cloudScanMu.Unlock() - entry := s.cloudScans[libraryID] - if entry == nil { - return - } - entry.status.Stage = stage - entry.status.UpdatedAt = time.Now() - entry.status.Dirs = dirs - entry.status.Discovered = discovered - entry.status.Visited = visited - entry.status.Added = added - entry.status.Updated = updated - entry.status.Skipped = skipped - entry.status.Removed = removed - entry.status.FilesPerSecond = filesPerSecond -} - -func (s *ScannerService) acquireCloudScanSlot(ctx context.Context, libraryID string) (func(), error) { - if s == nil { - return func() {}, nil - } - s.cloudScanMu.Lock() - if s.cloudSlots == nil { - s.cloudSlots = make(chan struct{}, 1) - } - slots := s.cloudSlots - if entry := s.cloudScans[libraryID]; entry != nil { - entry.status.Stage = "queued" - entry.status.UpdatedAt = time.Now() - } - s.cloudScanMu.Unlock() - - select { - case slots <- struct{}{}: - s.cloudScanMu.Lock() - if entry := s.cloudScans[libraryID]; entry != nil && entry.status.State == "running" { - entry.status.Stage = "listing" - entry.status.UpdatedAt = time.Now() - } - s.cloudScanMu.Unlock() - return func() { <-slots }, nil - case <-ctx.Done(): - return nil, ctx.Err() - } -} - -// CloudScanStatuses returns the current or most recent status per cloud library. -func (s *ScannerService) CloudScanStatuses() []CloudScanStatus { - if s == nil { - return nil - } - s.cloudScanMu.Lock() - defer s.cloudScanMu.Unlock() - out := make([]CloudScanStatus, 0, len(s.cloudScans)) - for _, entry := range s.cloudScans { - out = append(out, entry.status) - } - return out -} - -func (s *ScannerService) CancelCloudScan(libraryID string) bool { - if s == nil || strings.TrimSpace(libraryID) == "" { - return false - } - s.cloudScanMu.Lock() - defer s.cloudScanMu.Unlock() - return cancelCloudScanEntry(s.cloudScans[libraryID]) -} - -func (s *ScannerService) CancelAllCloudScans() int { - if s == nil { - return 0 - } - s.cloudScanMu.Lock() - defer s.cloudScanMu.Unlock() - cancelled := 0 - for _, entry := range s.cloudScans { - if cancelCloudScanEntry(entry) { - cancelled++ - } - } - return cancelled -} - -func (s *ScannerService) CancelCloudScansForProvider(provider string) int { - if s == nil { - return 0 - } - provider = strings.TrimSpace(provider) - if provider == "" { - return 0 - } - s.cloudScanMu.Lock() - defer s.cloudScanMu.Unlock() - cancelled := 0 - for _, entry := range s.cloudScans { - if entry == nil || entry.status.Provider != provider { - continue - } - if cancelCloudScanEntry(entry) { - cancelled++ - } - } - return cancelled -} - -func cancelCloudScanEntry(entry *cloudScanEntry) bool { - if !cloudScanActive(entry) { - return false - } - entry.status.State = "canceling" - entry.status.Stage = "canceling" - entry.status.UpdatedAt = time.Now() - if entry.cancel != nil { - entry.cancel() - return true - } - entry.status.State = "canceled" - entry.status.Stage = "canceled" - entry.status.FinishedAt = time.Now() - return true -} - -func cloudScanActive(entry *cloudScanEntry) bool { - return entry != nil && (entry.status.State == "running" || entry.status.State == "queued" || entry.status.State == "canceling") -} - -func cloudScanBlocksBegin(entry *cloudScanEntry) bool { - return entry != nil && (entry.status.State == "running" || entry.status.State == "canceling") -} diff --git a/internal/service/scanner_cloud_status_test.go b/internal/service/scanner_cloud_status_test.go deleted file mode 100644 index 5468e71..0000000 --- a/internal/service/scanner_cloud_status_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package service - -import ( - "context" - "errors" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestBeginCloudScanAllowsQueuedEntryToStart(t *testing.T) { - scanner := &ScannerService{ - cloudScans: map[string]*cloudScanEntry{ - "lib-1": {status: CloudScanStatus{LibraryID: "lib-1", Provider: "openlist", State: "queued", Stage: "queued"}}, - }, - } - lib := &model.Library{Base: model.Base{ID: "lib-1"}, Name: "Movies"} - mount := CloudMountInfo{Provider: "openlist"} - - _, finish, err := scanner.beginCloudScan(context.Background(), lib, mount) - if err != nil { - t.Fatalf("queued scan should be allowed to start, got %v", err) - } - if finish == nil { - t.Fatal("finish callback should not be nil") - } - statuses := scanner.CloudScanStatuses() - if len(statuses) != 1 || statuses[0].State != "running" || statuses[0].Stage != "listing" { - t.Fatalf("status after begin = %#v, want running/listing", statuses) - } - - finish(&ScanResult{Visited: 5, Added: 2, Updated: 1, ErrorCount: 1, Errors: []string{"bad file"}}, nil) - statuses = scanner.CloudScanStatuses() - if len(statuses) != 1 || statuses[0].State != "finished" || statuses[0].Visited != 5 || statuses[0].Added != 2 || statuses[0].ErrorCount != 1 { - t.Fatalf("status after finish = %#v", statuses) - } - if statuses[0].Error == "" { - t.Fatal("finished scan with error_count should keep summary error text") - } -} - -func TestBeginCloudScanRejectsRunningEntry(t *testing.T) { - scanner := &ScannerService{ - cloudScans: map[string]*cloudScanEntry{ - "lib-1": {status: CloudScanStatus{LibraryID: "lib-1", Provider: "openlist", State: "running", Stage: "listing"}}, - }, - } - lib := &model.Library{Base: model.Base{ID: "lib-1"}, Name: "Movies"} - - _, finish, err := scanner.beginCloudScan(context.Background(), lib, CloudMountInfo{Provider: "openlist"}) - if !errors.Is(err, ErrCloudScanAlreadyRunning) { - t.Fatalf("err = %v, want ErrCloudScanAlreadyRunning", err) - } - if finish != nil { - t.Fatal("finish callback should be nil when begin is rejected") - } -} diff --git a/internal/service/scanner_cloud_test.go b/internal/service/scanner_cloud_test.go deleted file mode 100644 index 38fb4dd..0000000 --- a/internal/service/scanner_cloud_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package service - -import ( - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestCloudLibraryPathParsing(t *testing.T) { - typ, dir, ok := parseCloudLibraryPath("cloud://cloud115/abc%20123?ignored=1") - if !ok || typ != "cloud115" || dir != "abc 123" { - t.Fatalf("parse path got typ=%q dir=%q ok=%v", typ, dir, ok) - } - typ, dir, ok = parseCloudLibraryPath("cloud://openlist/Movies?dir=%2FMovies") - if !ok || typ != "openlist" || dir != "Movies" { - t.Fatalf("parse query got typ=%q dir=%q ok=%v", typ, dir, ok) - } - if ref := cloudEntryRef("cloud115", "fid", "pick"); ref != "pick" { - t.Fatalf("115 ref = %q, want pick", ref) - } -} - -func TestCloudMountConflictDetectsNestedMounts(t *testing.T) { - root := model.Library{Base: model.Base{ID: "root"}, Name: "115", Path: "cloud://cloud115", Enabled: true} - childPath := BuildCloudLibraryPath("cloud115", "child-id", "parent-id/child-id") - info, ok := ParseCloudLibraryMount(childPath) - if !ok || info.ScanDir != "child-id" || info.DisplayDir != "parent-id/child-id" { - t.Fatalf("parse child mount = %#v ok=%v", info, ok) - } - - conflict := FindCloudMountConflict([]model.Library{root}, "cloud115", "child-id", "parent-id/child-id") - if conflict != nil { - t.Fatalf("child mount under existing root should be allowed, got conflict %#v", conflict) - } - - sibling := model.Library{Base: model.Base{ID: "sibling"}, Name: "Sibling", Path: BuildCloudLibraryPath("cloud115", "sibling-id", "parent-id/sibling-id"), Enabled: true} - conflict = FindCloudMountConflict([]model.Library{sibling}, "cloud115", "child-id", "parent-id/child-id") - if conflict != nil { - t.Fatalf("sibling conflict = %#v, want nil", conflict) - } - - conflict = FindCloudMountConflict([]model.Library{sibling}, "cloud115", "parent-id", "parent-id") - if conflict == nil || !conflict.Nested { - t.Fatalf("parent mount over existing child = %#v, want nested conflict", conflict) - } - oldIDPath := BuildCloudLibraryPath("cloud115", "child-id", "old-parent-id/child-id") - conflict = FindCloudMountConflict([]model.Library{{Base: model.Base{ID: "old"}, Name: "Old", Path: oldIDPath, Enabled: true}}, "cloud115", "child-id", "父目录/子目录") - if conflict == nil || !conflict.Exact { - t.Fatalf("same scan dir with renamed display path = %#v, want exact conflict", conflict) - } - - root.CreatedAt = root.CreatedAt.Add(-1) - child := model.Library{Base: model.Base{ID: "child"}, Name: "Child", Path: childPath, Enabled: true} - if shadow := CloudLibraryShadowed([]model.Library{root, child}, child); shadow != nil { - t.Fatalf("child should not be shadowed by root: %#v", shadow) - } - if shadow := CloudLibraryShadowed([]model.Library{root, child}, root); shadow == nil || !shadow.Nested { - t.Fatalf("root should be shadowed by child, got %#v", shadow) - } -} - -func TestCancelCloudScansForProviderSignalsRunningScan(t *testing.T) { - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repository.New(nil), NewHub(zap.NewNop()), nil, nil) - cancelled := false - scanner.cloudScans["lib-1"] = &cloudScanEntry{ - status: CloudScanStatus{LibraryID: "lib-1", Provider: "openlist", State: "running"}, - cancel: func() { - cancelled = true - }, - } - - if got := scanner.CancelCloudScansForProvider("openlist"); got != 1 { - t.Fatalf("cancelled = %d, want 1", got) - } - if !cancelled { - t.Fatal("cancel func was not called") - } - if state := scanner.cloudScans["lib-1"].status.State; state != "canceling" { - t.Fatalf("state = %q, want canceling", state) - } -} - -func TestInferCloudMountMediaType(t *testing.T) { - cases := map[string]string{ - "/日漫": "anime", - "/国漫": "anime", - "/美漫": "anime", - "/电视剧/国产剧": "tv", - "/电视剧/欧美剧": "tv", - "/电视剧/日韩剧": "tv", - "/电影/动画电影": "movie", - "/电影/华语电影": "movie", - "/电影/欧美电影": "movie", - "/综艺": "variety", - } - for dir, want := range cases { - if got := InferCloudMountMediaType(dir, "OpenList · "+dir); got != want { - t.Fatalf("%s type = %s, want %s", dir, got, want) - } - } -} - -func TestCloudSeriesTitlePrefersShowFolder(t *testing.T) { - title, year := cloudSeriesTitleFromMediaPath("cloud://openlist/国产剧/紫川 (2024) {tmdb-247590}/Season 2/紫川.2024.S02E24.第24集.2160p.WEB-DL.H.265-ColorTV.mkv") - if title != "紫川" || year != 2024 { - t.Fatalf("cloud series title = %q/%d, want 紫川/2024", title, year) - } - title, year = cloudSeriesTitleFromMediaPath("cloud://openlist/国产剧/紫川.2024.S02E24.mkv") - if title != "" || year != 0 { - t.Fatalf("single category folder should not override title, got %q/%d", title, year) - } -} - -func TestCloudMetadataNeedsRefreshWhenPathHintConflicts(t *testing.T) { - existing := existingCloudMedia{ - Year: 2025, - TMDbID: 220269, - } - local := &LocalMetadata{ - Year: 2025, - TMDbID: 296753, - PathHint: true, - } - if !cloudMetadataNeedsRefresh(existing, local) { - t.Fatal("conflicting explicit cloud path hint should refresh existing media") - } -} - -func TestParseCloudArtworkURL(t *testing.T) { - typ, ref, ok := ParseCloudArtworkURL("http://nas.local/api/cloud/play/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg") - if !ok || typ != "openlist" || ref != "/Anime/JianLai/poster.jpg" { - t.Fatalf("parse cloud image url = typ=%q ref=%q ok=%v", typ, ref, ok) - } - typ, ref, ok = ParseCloudArtworkURL("/api/img/cloud/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg") - if !ok || typ != "openlist" || ref != "/Anime/JianLai/poster.jpg" { - t.Fatalf("parse cached cloud artwork url = typ=%q ref=%q ok=%v", typ, ref, ok) - } - typ, ref, ok = ParseCloudArtworkURL("/api/img/cloud/openlist?ref=%2FMovies%2FMovie.tbn") - if !ok || typ != "openlist" || ref != "/Movies/Movie.tbn" { - t.Fatalf("parse tbn cloud artwork url = typ=%q ref=%q ok=%v", typ, ref, ok) - } - if _, _, ok := ParseCloudArtworkURL("/api/cloud/play/openlist?ref=%2FAnime%2FJianLai%2Fmovie.mkv"); ok { - t.Fatal("video cloud url should not be treated as artwork") - } - if _, _, ok := ParseCloudArtworkURL("https://image.tmdb.org/t/p/w500/poster.jpg"); ok { - t.Fatal("remote HTTP poster should not be treated as cloud artwork") - } -} diff --git a/internal/service/scanner_cloud_test_helpers_test.go b/internal/service/scanner_cloud_test_helpers_test.go deleted file mode 100644 index 33504ba..0000000 --- a/internal/service/scanner_cloud_test_helpers_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package service - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -type openListTestEntry struct { - Name string - Size int64 - IsDir bool -} - -func newOpenListAPIServer(t *testing.T, list func(path string, page, perPage int) ([]openListTestEntry, int)) *httptest.Server { - t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/fs/list" { - t.Fatalf("unexpected openlist api request %s", r.URL.Path) - } - var in struct { - Path string `json:"path"` - Page int `json:"page"` - PerPage int `json:"per_page"` - } - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - t.Fatalf("decode openlist list request: %v", err) - } - if in.Path == "" { - in.Path = "/" - } - if in.Page <= 0 { - in.Page = 1 - } - if in.PerPage <= 0 { - in.PerPage = 500 - } - entries, total := list(in.Path, in.Page, in.PerPage) - content := make([]map[string]any, 0, len(entries)) - for _, entry := range entries { - content = append(content, map[string]any{ - "name": entry.Name, - "size": entry.Size, - "is_dir": entry.IsDir, - }) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "code": 200, - "message": "success", - "data": map[string]any{ - "content": content, - "total": total, - }, - }) - })) -} diff --git a/internal/service/scanner_existing_media.go b/internal/service/scanner_existing_media.go index b41abd7..15f224c 100644 --- a/internal/service/scanner_existing_media.go +++ b/internal/service/scanner_existing_media.go @@ -8,62 +8,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/model" ) -func (s *ScannerService) existingCloudMediaSnapshot(ctx context.Context, libraryID string) (map[string]existingCloudMedia, error) { - return s.existingCloudMediaSnapshotForLibraries(ctx, []string{libraryID}) -} - -func (s *ScannerService) existingCloudMediaSnapshotForLibraries(ctx context.Context, libraryIDs []string) (map[string]existingCloudMedia, error) { - if len(libraryIDs) == 0 { - return map[string]existingCloudMedia{}, nil - } - var rows []model.Media - if err := s.repo.DB.WithContext(ctx). - Model(&model.Media{}). - Select("library_id", "path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "poster_url", "backdrop_url", "strm_url", "overview", "year", "release_date", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "genres", "countries", "languages", "nsfw", "scrape_status"). - Where("library_id IN ? AND path LIKE ?", libraryIDs, "cloud://%"). - Find(&rows).Error; err != nil { - return nil, err - } - snapshot := make(map[string]existingCloudMedia, len(rows)) - for _, row := range rows { - if strings.TrimSpace(row.Path) == "" { - continue - } - snapshot[row.Path] = existingCloudMedia{ - LibraryID: row.LibraryID, - Title: row.Title, - OriginalName: row.OriginalName, - EpisodeTitle: row.EpisodeTitle, - SizeBytes: row.SizeBytes, - DurationSec: row.DurationSec, - Width: row.Width, - Height: row.Height, - VideoCodec: row.VideoCodec, - AudioCodec: row.AudioCodec, - Container: row.Container, - PosterURL: row.PosterURL, - BackdropURL: row.BackdropURL, - STRMURL: row.STRMURL, - Overview: row.Overview, - Year: row.Year, - ReleaseDate: row.ReleaseDate, - Rating: row.Rating, - TMDbID: row.TMDbID, - BangumiID: row.BangumiID, - DoubanID: row.DoubanID, - TheTVDBID: row.TheTVDBID, - SeasonNum: row.SeasonNum, - EpisodeNum: row.EpisodeNum, - Genres: row.Genres, - Countries: row.Countries, - Languages: row.Languages, - NSFW: row.NSFW, - ScrapeStatus: row.ScrapeStatus, - } - } - return snapshot, nil -} - func (s *ScannerService) existingLocalMediaSnapshot(ctx context.Context, libraryID string) (map[string]existingLocalMedia, error) { var rows []model.Media if err := s.repo.DB.WithContext(ctx). diff --git a/internal/service/scanner_existing_media_test.go b/internal/service/scanner_existing_media_test.go index ed49328..8d1aa7e 100644 --- a/internal/service/scanner_existing_media_test.go +++ b/internal/service/scanner_existing_media_test.go @@ -10,60 +10,6 @@ import ( "go.uber.org/zap" ) -func TestExistingCloudMediaSnapshotFiltersCloudRows(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - - if err := db.Create(&[]model.Media{ - { - LibraryID: "lib-1", - Path: "cloud://openlist/Movie.mkv", - SizeBytes: 2048, - DurationSec: 120, - Width: 1920, - Height: 1080, - VideoCodec: "h264", - AudioCodec: "aac", - Container: "mkv", - PosterURL: "/poster.jpg", - BackdropURL: "/backdrop.jpg", - STRMURL: "/api/cloud/play/openlist?ref=movie", - Year: 2026, - TMDbID: 123, - BangumiID: 456, - DoubanID: "douban-1", - TheTVDBID: "tvdb-1", - ScrapeStatus: "matched", - }, - {LibraryID: "lib-1", Path: "/media/local.mkv", SizeBytes: 99}, - {LibraryID: "lib-2", Path: "cloud://openlist/Other.mkv", SizeBytes: 88}, - }).Error; err != nil { - t.Fatal(err) - } - - got, err := scanner.existingCloudMediaSnapshot(t.Context(), "lib-1") - if err != nil { - t.Fatal(err) - } - if len(got) != 1 { - t.Fatalf("snapshot len = %d, want 1: %#v", len(got), got) - } - row := got["cloud://openlist/Movie.mkv"] - if row.SizeBytes != 2048 || row.DurationSec != 120 || row.Width != 1920 || row.Height != 1080 { - t.Fatalf("track fields not preserved: %#v", row) - } - if row.VideoCodec != "h264" || row.AudioCodec != "aac" || row.Container != "mkv" { - t.Fatalf("codec fields not preserved: %#v", row) - } - if row.PosterURL != "/poster.jpg" || row.BackdropURL != "/backdrop.jpg" || row.STRMURL == "" { - t.Fatalf("artwork/strm fields not preserved: %#v", row) - } - if row.Year != 2026 || row.TMDbID != 123 || row.BangumiID != 456 || row.DoubanID != "douban-1" || row.TheTVDBID != "tvdb-1" { - t.Fatalf("scraper ids not preserved: %#v", row) - } -} - func TestExistingLocalMediaSnapshotFiltersAndCleansLocalRows(t *testing.T) { db := newServiceTestDB(t, &model.Media{}) repos := repository.New(db) diff --git a/internal/service/scanner_local_probe_queue.go b/internal/service/scanner_local_probe_queue.go index 752f155..fd6d9e9 100644 --- a/internal/service/scanner_local_probe_queue.go +++ b/internal/service/scanner_local_probe_queue.go @@ -50,6 +50,39 @@ func (s *ScannerService) startLocalMediaProbeWorkers() { }) } +func (s *ScannerService) ffprobeWorkerCount() int { + if s == nil || s.cfg == nil { + return 1 + } + return normalizeFFprobeMaxConcurrent(s.cfg.App.FFprobeMaxConcurrent) +} + +func probeResultUpdates(probe *ProbeResult) map[string]any { + updates := map[string]any{} + if probe == nil { + return updates + } + if probe.DurationSec > 0 { + updates["duration_sec"] = probe.DurationSec + } + if probe.Width > 0 { + updates["width"] = probe.Width + } + if probe.Height > 0 { + updates["height"] = probe.Height + } + if strings.TrimSpace(probe.VideoCodec) != "" { + updates["video_codec"] = probe.VideoCodec + } + if strings.TrimSpace(probe.AudioCodec) != "" { + updates["audio_codec"] = probe.AudioCodec + } + if probe.Container != "" { + updates["container"] = probe.Container + } + return updates +} + func (s *ScannerService) reserveLocalMediaProbe(path string) bool { s.localMediaProbeMu.Lock() defer s.localMediaProbeMu.Unlock() diff --git a/internal/service/scanner_metadata_refresh.go b/internal/service/scanner_metadata_refresh.go index 1bd4d60..c46a079 100644 --- a/internal/service/scanner_metadata_refresh.go +++ b/internal/service/scanner_metadata_refresh.go @@ -19,100 +19,6 @@ type scanDerivedMetadata struct { EpisodeNum int } -func cloudMetadataNeedsRefresh(existing existingCloudMedia, localMeta *LocalMetadata) bool { - if localMeta == nil { - return false - } - if localMeta.PathHint && !localMeta.HasNFO && !localMeta.HasArtwork { - return cloudPathHintNeedsRefresh(existing, localMeta) - } - if localMetadataMarksMatched(localMeta) && strings.TrimSpace(existing.ScrapeStatus) != "matched" { - return true - } - if localMeta.Title != "" && strings.TrimSpace(existing.Title) != strings.TrimSpace(localMeta.Title) { - return true - } - if localMeta.OriginalName != "" && strings.TrimSpace(existing.OriginalName) != strings.TrimSpace(localMeta.OriginalName) { - return true - } - if localMeta.EpisodeTitle != "" && strings.TrimSpace(existing.EpisodeTitle) != strings.TrimSpace(localMeta.EpisodeTitle) { - return true - } - if localMeta.AdultCode != "" && !strings.EqualFold(strings.TrimSpace(existing.OriginalName), strings.TrimSpace(localMeta.AdultCode)) { - return true - } - if localMeta.Year > 0 && existing.Year != localMeta.Year { - return true - } - if localMeta.ReleaseDate != "" && strings.TrimSpace(existing.ReleaseDate) != strings.TrimSpace(localMeta.ReleaseDate) { - return true - } - if localMeta.Overview != "" && strings.TrimSpace(existing.Overview) != strings.TrimSpace(localMeta.Overview) { - return true - } - if localMeta.Rating > 0 && existing.Rating != localMeta.Rating { - return true - } - if localMeta.TMDbID > 0 && existing.TMDbID != localMeta.TMDbID { - return true - } - if localMeta.BangumiID > 0 && existing.BangumiID != localMeta.BangumiID { - return true - } - if strings.TrimSpace(localMeta.DoubanID) != "" && strings.TrimSpace(existing.DoubanID) != strings.TrimSpace(localMeta.DoubanID) { - return true - } - if strings.TrimSpace(localMeta.TheTVDBID) != "" && strings.TrimSpace(existing.TheTVDBID) != strings.TrimSpace(localMeta.TheTVDBID) { - return true - } - if strings.TrimSpace(localMeta.PosterURL) != "" && strings.TrimSpace(existing.PosterURL) != strings.TrimSpace(localMeta.PosterURL) { - return true - } - if strings.TrimSpace(localMeta.BackdropURL) != "" && strings.TrimSpace(existing.BackdropURL) != strings.TrimSpace(localMeta.BackdropURL) { - return true - } - if (localMeta.SeasonNum > 0 || localMeta.EpisodeNum > 0) && existing.SeasonNum != localMeta.SeasonNum { - return true - } - if localMeta.EpisodeNum > 0 && existing.EpisodeNum != localMeta.EpisodeNum { - return true - } - if localMeta.Genres != "" && strings.TrimSpace(existing.Genres) != strings.TrimSpace(localMeta.Genres) { - return true - } - if localMeta.Countries != "" && strings.TrimSpace(existing.Countries) != strings.TrimSpace(localMeta.Countries) { - return true - } - if localMeta.Languages != "" && strings.TrimSpace(existing.Languages) != strings.TrimSpace(localMeta.Languages) { - return true - } - if localMeta.NSFW && !existing.NSFW { - return true - } - return false -} - -func cloudPathHintNeedsRefresh(existing existingCloudMedia, localMeta *LocalMetadata) bool { - if localMeta.TMDbID > 0 && existing.TMDbID != localMeta.TMDbID { - return true - } - if localMeta.BangumiID > 0 && existing.BangumiID != localMeta.BangumiID { - return true - } - if strings.TrimSpace(localMeta.DoubanID) != "" && strings.TrimSpace(existing.DoubanID) != strings.TrimSpace(localMeta.DoubanID) { - return true - } - return strings.TrimSpace(localMeta.TheTVDBID) != "" && strings.TrimSpace(existing.TheTVDBID) != strings.TrimSpace(localMeta.TheTVDBID) -} - -func cloudTrackMetadataMissing(existing existingCloudMedia) bool { - return existing.DurationSec <= 0 || - existing.Width <= 0 || - existing.Height <= 0 || - strings.TrimSpace(existing.VideoCodec) == "" || - strings.TrimSpace(existing.AudioCodec) == "" -} - func localMetadataNeedsRefresh(existing existingLocalMedia, local *LocalMetadata) bool { if local == nil { return false @@ -180,24 +86,6 @@ func localMetadataNeedsRefresh(existing existingLocalMedia, local *LocalMetadata return local.NSFW && !existing.NSFW } -func cloudDerivedMetadataNeedsRefresh(existing existingCloudMedia, incoming *model.Media) bool { - if incoming == nil { - return false - } - return scanDerivedMetadataNeedsRefresh(scanDerivedMetadata{ - Title: existing.Title, - ScrapeStatus: existing.ScrapeStatus, - Year: existing.Year, - ReleaseDate: existing.ReleaseDate, - TMDbID: existing.TMDbID, - BangumiID: existing.BangumiID, - DoubanID: existing.DoubanID, - TheTVDBID: existing.TheTVDBID, - SeasonNum: existing.SeasonNum, - EpisodeNum: existing.EpisodeNum, - }, incoming) -} - func localDerivedMetadataNeedsRefresh(existing existingLocalMedia, incoming *model.Media) bool { if incoming == nil { return false diff --git a/internal/service/scanner_notifications.go b/internal/service/scanner_notifications.go deleted file mode 100644 index 5d652d6..0000000 --- a/internal/service/scanner_notifications.go +++ /dev/null @@ -1,36 +0,0 @@ -package service - -import ( - "context" - "fmt" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *ScannerService) notifyScanFinished(lib *model.Library, res *ScanResult, err error, cloud bool) { - if s == nil || s.notify == nil || lib == nil || res == nil { - return - } - if err != nil { - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - s.notify.Broadcast(ctx, "MediaStationGo 扫描异常", fmt.Sprintf("媒体库:%s\n错误:%s", lib.Name, err.Error()), EventSystemAlert) - }() - return - } - if res.Added+res.Updated <= 0 { - return - } - source := "本地媒体库" - if cloud { - source = "网盘媒体库" - } - body := fmt.Sprintf("%s:%s\n新增:%d\n更新:%d\n跳过:%d\n移除:%d", source, lib.Name, res.Added, res.Updated, res.Skipped, res.Removed) - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - s.notify.Broadcast(ctx, "MediaStationGo 入库完成", body, EventLibraryIngest) - }() -} diff --git a/internal/service/scanner_probe_queue.go b/internal/service/scanner_probe_queue.go deleted file mode 100644 index c9e48e8..0000000 --- a/internal/service/scanner_probe_queue.go +++ /dev/null @@ -1,98 +0,0 @@ -package service - -import ( - "strings" - "time" - - "go.uber.org/zap" -) - -func (s *ScannerService) cloudMediaProbeWorker() { - for task := range s.cloudMediaProbeQueue { - s.probeCloudMediaAsync(task) - } -} - -func (s *ScannerService) queueCloudMediaProbe(typ, ref, path string) bool { - task, ok := s.newCloudMediaProbeTask(typ, ref, path) - if !ok || !s.reserveCloudMediaProbe(task, time.Now()) { - return false - } - select { - case s.cloudMediaProbeQueue <- task: - return true - default: - s.deferCloudMediaProbe(task, cloudMediaProbeQueueFullBackoff) - s.logCloudMediaProbeQueueFull(task) - return false - } -} - -func (s *ScannerService) newCloudMediaProbeTask(typ, ref, path string) (cloudMediaProbeTask, bool) { - if s == nil || s.storage == nil || s.probe == nil { - return cloudMediaProbeTask{}, false - } - task := cloudMediaProbeTask{ - typ: strings.TrimSpace(typ), - ref: strings.TrimSpace(ref), - path: strings.TrimSpace(path), - } - return task, task.typ != "" && task.ref != "" && task.path != "" -} - -func (s *ScannerService) reserveCloudMediaProbe(task cloudMediaProbeTask, now time.Time) bool { - s.cloudMediaProbeMu.Lock() - defer s.cloudMediaProbeMu.Unlock() - if until, ok := s.cloudMediaProbeBackoff[task.path]; ok { - if now.Before(until) { - return false - } - delete(s.cloudMediaProbeBackoff, task.path) - } - if _, ok := s.cloudMediaProbing[task.path]; ok { - return false - } - s.cloudMediaProbing[task.path] = struct{}{} - return true -} - -func (s *ScannerService) deferCloudMediaProbe(task cloudMediaProbeTask, backoff time.Duration) { - s.cloudMediaProbeMu.Lock() - defer s.cloudMediaProbeMu.Unlock() - delete(s.cloudMediaProbing, task.path) - if s.cloudMediaProbeBackoff == nil { - s.cloudMediaProbeBackoff = make(map[string]time.Time) - } - s.cloudMediaProbeBackoff[task.path] = time.Now().Add(backoff) -} - -func (s *ScannerService) logCloudMediaProbeQueueFull(task cloudMediaProbeTask) { - if s == nil || s.log == nil { - return - } - now := time.Now() - s.cloudMediaProbeWarnMu.Lock() - shouldWarn := now.Sub(s.cloudMediaProbeLastWarn) >= time.Minute - if shouldWarn { - s.cloudMediaProbeLastWarn = now - } - s.cloudMediaProbeWarnMu.Unlock() - if shouldWarn { - s.log.Warn("cloud media probe queue full; deferring remaining probes (logged at most once per minute)", - zap.String("provider", task.typ), zap.String("path", task.path)) - return - } - s.log.Debug("cloud media probe queue full", zap.String("provider", task.typ), zap.String("path", task.path)) -} - -func (s *ScannerService) queueCloudMediaProbeWithBudget(typ, ref, path string, budget *int) bool { - if budget != nil { - if *budget <= 0 { - return false - } - // Budget is consumed per attempt, not only per successful enqueue, so a - // full probe queue cannot generate unbounded repeated attempts/logging. - *budget-- - } - return s.queueCloudMediaProbe(typ, ref, path) -} diff --git a/internal/service/scanner_probe_queue_test.go b/internal/service/scanner_probe_queue_test.go deleted file mode 100644 index 467f62f..0000000 --- a/internal/service/scanner_probe_queue_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package service - -import ( - "testing" - "time" - - "go.uber.org/zap" -) - -func newProbeQueueTestScanner(capacity int) *ScannerService { - return &ScannerService{ - log: zap.NewNop(), - storage: &StorageConfigService{}, - probe: &FFprobeService{}, - cloudMediaProbeQueue: make(chan cloudMediaProbeTask, capacity), - cloudMediaProbing: make(map[string]struct{}), - cloudMediaProbeBackoff: make(map[string]time.Time), - } -} - -func TestQueueCloudMediaProbeTrimsTaskAndRejectsDuplicate(t *testing.T) { - scanner := newProbeQueueTestScanner(1) - if !scanner.queueCloudMediaProbe(" openlist ", " /Movies/a.mkv ", " cloud://openlist/Movies/a.mkv ") { - t.Fatal("first cloud probe should enqueue") - } - if scanner.queueCloudMediaProbe("openlist", "/Movies/a.mkv", "cloud://openlist/Movies/a.mkv") { - t.Fatal("duplicate cloud probe should be rejected while in flight") - } - - task := <-scanner.cloudMediaProbeQueue - if task.typ != "openlist" || task.ref != "/Movies/a.mkv" || task.path != "cloud://openlist/Movies/a.mkv" { - t.Fatalf("task was not normalized: %#v", task) - } -} - -func TestQueueCloudMediaProbeFullQueueBacksOffAndReleases(t *testing.T) { - scanner := newProbeQueueTestScanner(0) - if scanner.queueCloudMediaProbe("openlist", "/Movies/a.mkv", "cloud://openlist/Movies/a.mkv") { - t.Fatal("unbuffered queue without receiver should reject enqueue") - } - - scanner.cloudMediaProbeMu.Lock() - _, probing := scanner.cloudMediaProbing["cloud://openlist/Movies/a.mkv"] - until, backedOff := scanner.cloudMediaProbeBackoff["cloud://openlist/Movies/a.mkv"] - scanner.cloudMediaProbeMu.Unlock() - if probing { - t.Fatal("queue-full path should release in-flight marker") - } - if !backedOff || !until.After(time.Now()) { - t.Fatalf("queue-full path should receive future backoff, got %v", until) - } - if scanner.queueCloudMediaProbe("openlist", "/Movies/a.mkv", "cloud://openlist/Movies/a.mkv") { - t.Fatal("backed-off path should not be retried immediately") - } -} - -func TestQueueCloudMediaProbeBudgetConsumesAttempts(t *testing.T) { - scanner := newProbeQueueTestScanner(0) - budget := 1 - if scanner.queueCloudMediaProbeWithBudget("openlist", "/Movies/a.mkv", "cloud://openlist/Movies/a.mkv", &budget) { - t.Fatal("unbuffered queue without receiver should reject enqueue") - } - if budget != 0 { - t.Fatalf("budget = %d, want 0 after attempted enqueue", budget) - } - if scanner.queueCloudMediaProbeWithBudget("openlist", "/Movies/b.mkv", "cloud://openlist/Movies/b.mkv", &budget) { - t.Fatal("zero budget should prevent enqueue") - } -} diff --git a/internal/service/scanner_scan.go b/internal/service/scanner_scan.go index 84ec7e3..010bee9 100644 --- a/internal/service/scanner_scan.go +++ b/internal/service/scanner_scan.go @@ -32,9 +32,6 @@ func (s *ScannerService) ScanLibraryRoot(ctx context.Context, libraryID, rootID if root == nil { return nil, errors.New("library root not found") } - if mount, ok := ParseCloudLibraryMount(root.Path); ok { - return s.scanCloudLibraryRoot(ctx, lib, root, mount, true) - } return s.scanLocalLibraryRoot(ctx, lib, root, true) } @@ -74,9 +71,6 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto if lib == nil { return nil, errors.New("library not found") } - if mount, ok := ParseCloudLibraryMount(lib.Path); ok { - return s.scanMountedCloudLibrary(ctx, lib, mount, autoScrape) - } res := &ScanResult{LibraryID: lib.ID} writeBatch := newLocalMediaWriteBatch(s, ctx, res, 100) existingMedia, err := s.existingLocalMediaSnapshot(ctx, lib.ID) @@ -216,9 +210,7 @@ func (s *ScannerService) finishLocalLibraryScan(ctx context.Context, lib *model. "error_count": res.ErrorCount, "errors": res.Errors, }) - s.notifyScanFinished(lib, res, nil, false) s.invalidateMediaCache(ctx) - s.maybeGenerateSTRMAfterScan(lib.ID) if scanHasImportChanges(res) && autoScrape && s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) { s.startAutoScrape(ctx, lib.ID) diff --git a/internal/service/scanner_strm.go b/internal/service/scanner_strm.go index 186b980..89371f1 100644 --- a/internal/service/scanner_strm.go +++ b/internal/service/scanner_strm.go @@ -1,40 +1,11 @@ package service import ( - "context" "net/url" "os" - "path/filepath" "strings" - - "go.uber.org/zap" ) -func (s *ScannerService) resolveCloudSTRMTarget(ctx context.Context, typ, ref string) (string, error) { - if s.storage == nil { - return "", nil - } - content, err := s.storage.CloudReadText(ctx, typ, ref, 64<<10) - if err != nil { - return "", err - } - for _, line := range strings.Split(content, "\n") { - candidate := strings.TrimSpace(strings.TrimPrefix(line, "\ufeff")) - if candidate == "" || strings.HasPrefix(candidate, "#") { - continue - } - u, err := url.Parse(candidate) - if err != nil { - continue - } - switch strings.ToLower(u.Scheme) { - case "http", "https", "webdav", "davs", "alist", "alists", "openlist", "openlists": - return candidate, nil - } - } - return "", nil -} - func readLocalSTRMTarget(path string) (string, error) { data, err := os.ReadFile(path) // #nosec G304 -- path is a discovered .strm file under the configured library root. if err != nil { @@ -59,56 +30,3 @@ func readLocalSTRMTarget(path string) (string, error) { } return "", nil } - -func (s *ScannerService) maybeGenerateSTRMAfterScan(libraryID string) { - if s == nil || s.repo == nil || s.repo.Setting == nil { - return - } - value, err := s.repo.Setting.Get(context.Background(), "strm.auto_generate_enabled") - if err != nil || !parseBoolSetting(value, false) { - return - } - go func() { - ctx := context.Background() - strmSvc := NewSTRMService(s.log, s.repo, s.cfg) - opts := GenerateSTRMOptions{ - LibraryID: libraryID, - Enabled: true, - IncludeLocal: true, - Overwrite: true, - PreserveTree: s.autoSTRMPreserveTree(ctx), - SkipSettingsSave: true, - } - if outDir, scope := s.autoSTRMOutputDir(ctx); outDir != "" { - opts.OutputDir = outDir - if scope == "all" { - if lib, err := s.repo.Library.FindByID(ctx, libraryID); err == nil && lib != nil { - opts.OutputDir = filepath.Join(outDir, strmLibraryOutputSubdir(*lib)) - } - } - } - if _, err := strmSvc.GenerateForLibrary(ctx, opts); err != nil && s.log != nil { - s.log.Warn("auto generate strm failed", zap.String("library_id", libraryID), zap.Error(err)) - } - }() -} - -func (s *ScannerService) autoSTRMOutputDir(ctx context.Context) (string, string) { - if s == nil || s.repo == nil || s.repo.Setting == nil { - return "", "" - } - outDir, err := s.repo.Setting.Get(ctx, "strm.output_dir") - if err != nil { - return "", "" - } - scope, _ := s.repo.Setting.Get(ctx, "strm.output_scope") - return resolveMappedDestinationPath(strings.TrimSpace(outDir)), strings.ToLower(strings.TrimSpace(scope)) -} - -func (s *ScannerService) autoSTRMPreserveTree(ctx context.Context) bool { - if s == nil || s.repo == nil || s.repo.Setting == nil { - return false - } - value, err := s.repo.Setting.Get(ctx, "strm.preserve_tree") - return err == nil && parseBoolSetting(value, false) -} diff --git a/internal/service/scanner_strm_test.go b/internal/service/scanner_strm_test.go deleted file mode 100644 index 2ccc9a4..0000000 --- a/internal/service/scanner_strm_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package service - -import ( - "os" - "path/filepath" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestAutoGenerateSTRMAfterScanUsesAllScopeRoot(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) - repos := repository.New(db) - outDir := t.TempDir() - if err := repos.Setting.Set(t.Context(), "strm.auto_generate_enabled", "true"); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "strm.output_dir", outDir); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "strm.output_scope", "all"); err != nil { - t.Fatal(err) - } - lib := model.Library{ - Base: model.Base{ID: "tv-lib"}, - Name: "OpenList 欧美剧", - Path: BuildCloudLibraryPath("openlist", "/电视剧/欧美剧", "/电视剧/欧美剧"), - Type: "tv", - Enabled: true, - } - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - media := model.Media{ - Base: model.Base{ID: "show-1"}, - LibraryID: lib.ID, - Title: "第一集", - Path: "cloud://openlist/电视剧/欧美剧/Show/S01E01.mkv", - STRMURL: "/api/cloud/play/openlist?ref=show", - SeasonNum: 1, - EpisodeNum: 1, - } - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - - scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - scanner.maybeGenerateSTRMAfterScan(lib.ID) - - want := filepath.Join(outDir, "电视剧", "欧美剧", "Show", "Season 01", "Show - S01E01.strm") - waitForFile(t, want) - assertFileContains(t, want, "/api/stream/show-1") - if _, err := os.Stat(filepath.Join(outDir, "电视剧", "欧美剧", "电视剧", "欧美剧")); !os.IsNotExist(err) { - t.Fatalf("auto STRM output was nested twice") - } -} - -func waitForFile(t *testing.T, path string) { - t.Helper() - deadline := time.Now().Add(3 * time.Second) - for { - if _, err := os.Stat(path); err == nil { - return - } - if time.Now().After(deadline) { - t.Fatalf("timed out waiting for %s", path) - } - time.Sleep(20 * time.Millisecond) - } -} diff --git a/internal/service/scheduler.go b/internal/service/scheduler.go index 1ee11fa..9c2f66c 100644 --- a/internal/service/scheduler.go +++ b/internal/service/scheduler.go @@ -5,8 +5,6 @@ // // library_scan every 24 h — optional full re-scan for local libraries; // filesystem watchers handle normal changes. -// cloud_sync low frequency — optional cloud library sync. -// cloud_upload low frequency — optional local-to-cloud metadata upload. // organize_source opt-in — organize the configured staging folder. // transcode_cleanup every 24 h — purge HLS transcode artefacts // older than 24 h. @@ -38,7 +36,6 @@ type SchedulerService struct { transcoder *TranscoderService organizer *OrganizerService organizePipeline *OrganizePipelineService - storageCfg *StorageConfigService hub *Hub tasks *TaskTrackerService cacheDir string @@ -75,10 +72,7 @@ type scheduledJob struct { type schedulerManualRunKey struct{} -const ( - localLastPeriodicScanDateKey = "scan.last_periodic_date" - cloudAutoSyncCompletedDateForm = "2006-01-02" -) +const localLastPeriodicScanDateKey = "scan.last_periodic_date" // NewSchedulerService is the constructor. func NewSchedulerService( @@ -87,7 +81,6 @@ func NewSchedulerService( scanner *ScannerService, transcoder *TranscoderService, organizer *OrganizerService, - storageCfg *StorageConfigService, hub *Hub, cacheDir string, ) *SchedulerService { @@ -97,7 +90,6 @@ func NewSchedulerService( scanner: scanner, transcoder: transcoder, organizer: organizer, - storageCfg: storageCfg, hub: hub, cacheDir: cacheDir, now: time.Now, @@ -113,16 +105,6 @@ func (s *SchedulerService) Start(ctx context.Context) { interval: 24 * time.Hour, run: s.jobScanLibraries, }, - { - name: "cloud_sync", - interval: s.cloudSyncInterval(ctx), - run: s.jobSyncCloudLibraries, - }, - { - name: "cloud_upload", - interval: s.cloudUploadInterval(ctx), - run: s.jobUploadLocalToCloud, - }, { name: "organize_source", interval: s.organizeSourceInterval(ctx), diff --git a/internal/service/scheduler_cloud.go b/internal/service/scheduler_cloud.go deleted file mode 100644 index 2bd643b..0000000 --- a/internal/service/scheduler_cloud.go +++ /dev/null @@ -1,201 +0,0 @@ -package service - -import ( - "context" - "strconv" - "strings" - "time" - - "go.uber.org/zap" -) - -const ( - cloudAutoSyncEnabledKey = "cloud.auto_sync_enabled" - cloudSyncIntervalSecondsKey = "cloud.sync_interval_seconds" - cloudLastAutoSyncDateKey = "cloud.last_auto_sync_date" - cloudAutoSyncWindowStartHour = 23 - cloudAutoSyncWindowEndHour = 5 -) - -// jobUploadLocalToCloud copies local media files into the configured external -// storage backend. It is opt-in and never deletes the local source files. -func (s *SchedulerService) jobUploadLocalToCloud(ctx context.Context) error { - manual, _ := ctx.Value(schedulerManualRunKey{}).(bool) - if s.storageCfg == nil || (!manual && !s.autoCloudUploadEnabled(ctx)) { - return nil - } - input := s.cloudUploadInput(ctx) - if strings.TrimSpace(input.Type) == "" || strings.TrimSpace(input.SourcePath) == "" { - return nil - } - res, err := s.storageCfg.UploadLocal(ctx, input) - if s.log != nil && res != nil { - s.log.Info("cloud upload finished", - zap.String("type", input.Type), - zap.String("source", res.SourcePath), - zap.String("dest", res.DestPath), - zap.Int("uploaded", res.Uploaded), - zap.Int("skipped", res.Skipped), - zap.Int64("bytes", res.Bytes), - zap.Int("errors", len(res.Errors)), - ) - } - return err -} - -func (s *SchedulerService) cloudUploadInput(ctx context.Context) CloudUploadInput { - get := func(key string) string { - if s.repo == nil || s.repo.Setting == nil { - return "" - } - v, _ := s.repo.Setting.Get(ctx, key) - return strings.TrimSpace(v) - } - return CloudUploadInput{ - Type: get(CloudUploadProviderKey), - SourcePath: get(CloudUploadSourceDirKey), - DestPath: get(CloudUploadDestPathKey), - Recursive: parseBoolSetting(get(CloudUploadRecursiveKey), true), - IncludeSidecars: parseBoolSetting(get(CloudUploadSidecarsKey), true), - Overwrite: parseBoolSetting(get(CloudUploadOverwriteKey), false), - TransferMode: get(CloudUploadTransferModeKey), - } -} - -func (s *SchedulerService) autoCloudUploadEnabled(ctx context.Context) bool { - if s.repo == nil || s.repo.Setting == nil { - return false - } - v, err := s.repo.Setting.Get(ctx, CloudUploadAutoEnabledKey) - if err != nil { - return false - } - return parseBoolSetting(v, false) -} - -func (s *SchedulerService) cloudUploadInterval(ctx context.Context) time.Duration { - const fallback = time.Hour - if s.repo == nil || s.repo.Setting == nil { - return fallback - } - v, err := s.repo.Setting.Get(ctx, CloudUploadIntervalSecondsKey) - if err != nil { - return fallback - } - seconds, err := strconv.Atoi(strings.TrimSpace(v)) - if err != nil || seconds <= 0 { - return fallback - } - if seconds < 300 { - seconds = 300 - } - return time.Duration(seconds) * time.Second -} - -// jobSyncCloudLibraries keeps mounted cloud:// libraries refreshed without -// enabling full disk scans. It imports remote cloud files as STRM-backed media -// rows; the actual bytes stay on the provider and playback continues through -// /api/cloud/play 302/proxy. -func (s *SchedulerService) jobSyncCloudLibraries(ctx context.Context) error { - manual, _ := ctx.Value(schedulerManualRunKey{}).(bool) - if s.scanner == nil || (!manual && !s.autoCloudSyncDue(ctx, s.currentTime())) { - return nil - } - libs, err := s.repo.Library.List(ctx) - if err != nil { - return err - } - libs = FilterScannableCloudLibraries(ctx, s.repo, libs) - var firstErr error - for _, l := range libs { - if !l.Enabled { - continue - } - if _, ok := ParseCloudLibraryMount(l.Path); !ok { - continue - } - if _, err := s.scanner.ScanLibraryWithoutAutoScrape(ctx, l.ID); err != nil { - s.log.Warn("cloud sync failed", zap.String("library", l.ID), zap.Error(err)) - if firstErr == nil { - firstErr = err - } - } - } - if firstErr != nil { - return firstErr - } - if !manual { - _ = s.markCloudAutoSyncCompleted(ctx, s.currentTime()) - } - return nil -} - -func (s *SchedulerService) autoCloudSyncEnabled(ctx context.Context) bool { - if s.repo == nil || s.repo.Setting == nil { - return false - } - v, err := s.repo.Setting.Get(ctx, cloudAutoSyncEnabledKey) - if err != nil { - return false - } - return parseBoolSetting(v, false) -} - -func (s *SchedulerService) autoCloudSyncDue(ctx context.Context, now time.Time) bool { - if !s.autoCloudSyncEnabled(ctx) || !cloudAutoSyncInWindow(now) { - return false - } - if s.repo == nil || s.repo.Setting == nil { - return true - } - last, err := s.repo.Setting.Get(ctx, cloudLastAutoSyncDateKey) - if err != nil { - return true - } - return strings.TrimSpace(last) != cloudAutoSyncWindowDate(now) -} - -func cloudAutoSyncInWindow(now time.Time) bool { - hour := now.In(time.Local).Hour() - if cloudAutoSyncWindowStartHour == cloudAutoSyncWindowEndHour { - return true - } - if cloudAutoSyncWindowStartHour < cloudAutoSyncWindowEndHour { - return hour >= cloudAutoSyncWindowStartHour && hour < cloudAutoSyncWindowEndHour - } - return hour >= cloudAutoSyncWindowStartHour || hour < cloudAutoSyncWindowEndHour -} - -func cloudAutoSyncWindowDate(now time.Time) string { - local := now.In(time.Local) - if cloudAutoSyncWindowStartHour > cloudAutoSyncWindowEndHour && local.Hour() < cloudAutoSyncWindowEndHour { - local = local.AddDate(0, 0, -1) - } - return local.Format(cloudAutoSyncCompletedDateForm) -} - -func (s *SchedulerService) markCloudAutoSyncCompleted(ctx context.Context, now time.Time) error { - if s.repo == nil || s.repo.Setting == nil { - return nil - } - return s.repo.Setting.Set(ctx, cloudLastAutoSyncDateKey, cloudAutoSyncWindowDate(now)) -} - -func (s *SchedulerService) cloudSyncInterval(ctx context.Context) time.Duration { - const fallback = 30 * time.Minute - if s.repo == nil || s.repo.Setting == nil { - return fallback - } - v, err := s.repo.Setting.Get(ctx, cloudSyncIntervalSecondsKey) - if err != nil { - return fallback - } - seconds, err := strconv.Atoi(strings.TrimSpace(v)) - if err != nil || seconds <= 0 { - return fallback - } - if seconds < 300 { - seconds = 300 - } - return time.Duration(seconds) * time.Second -} diff --git a/internal/service/scheduler_cloud_sync_test.go b/internal/service/scheduler_cloud_sync_test.go deleted file mode 100644 index ef5ecca..0000000 --- a/internal/service/scheduler_cloud_sync_test.go +++ /dev/null @@ -1,232 +0,0 @@ -package service - -import ( - "sync/atomic" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSchedulerCloudSyncImportsMountedCloudLibrary(t *testing.T) { - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - if path != "/" { - t.Fatalf("unexpected openlist path %q", path) - } - return []openListTestEntry{{Name: "Cloud.Movie.2026.mkv", Size: 1024}}, 1 - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - local := model.Library{Name: "电影", Path: "/media/电影", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &local); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · 电影", Path: "cloud://openlist", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "cloud.auto_sync_enabled", "true"); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - scheduler := NewSchedulerService(log, repos, scanner, nil, nil, storage, NewHub(log), "") - scheduler.now = func() time.Time { return fixedNightlySyncTime() } - - if err := scheduler.jobSyncCloudLibraries(t.Context()); err != nil { - t.Fatalf("cloud sync: %v", err) - } - var media model.Media - if err := repos.DB.First(&media, "path = ?", "cloud://openlist/Cloud.Movie.2026.mkv").Error; err != nil { - t.Fatalf("cloud media not imported: %v", err) - } - if media.STRMURL != "/api/cloud/play/openlist?ref=%2FCloud.Movie.2026.mkv" { - t.Fatalf("strm url = %q", media.STRMURL) - } -} - -func TestSchedulerCloudSyncRunsOnlyOnceInsideNightlyWindow(t *testing.T) { - var requests atomic.Int32 - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - requests.Add(1) - if path != "/" { - t.Fatalf("unexpected openlist path %q", path) - } - return []openListTestEntry{{Name: "Nightly.Cloud.Movie.2026.mkv", Size: 1024}}, 1 - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), cloudAutoSyncEnabledKey, "true"); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - scheduler := NewSchedulerService(log, repos, scanner, nil, nil, storage, NewHub(log), "") - - scheduler.now = func() time.Time { - return time.Date(2026, 6, 11, 22, 30, 0, 0, time.Local) - } - if err := scheduler.jobSyncCloudLibraries(t.Context()); err != nil { - t.Fatalf("cloud sync outside window: %v", err) - } - if got := requests.Load(); got != 0 { - t.Fatalf("outside nightly window made %d requests, want 0", got) - } - - scheduler.now = func() time.Time { return fixedNightlySyncTime() } - if err := scheduler.jobSyncCloudLibraries(t.Context()); err != nil { - t.Fatalf("cloud sync inside window: %v", err) - } - if got := requests.Load(); got != 1 { - t.Fatalf("inside nightly window requests = %d, want 1", got) - } - if got := countMedia(t, repos); got != 1 { - t.Fatalf("media count = %d, want 1", got) - } - - scheduler.now = func() time.Time { - return time.Date(2026, 6, 12, 4, 15, 0, 0, time.Local) - } - if !cloudAutoSyncInWindow(scheduler.now()) { - t.Fatalf("04:15 should still be inside overnight cloud sync window") - } - if got := cloudAutoSyncWindowDate(scheduler.now()); got != "2026-06-11" { - t.Fatalf("04:15 should belong to previous nightly window, got %s", got) - } - if err := scheduler.jobSyncCloudLibraries(t.Context()); err != nil { - t.Fatalf("second cloud sync same overnight window: %v", err) - } - if got := requests.Load(); got != 1 { - t.Fatalf("same overnight auto sync should not rerun, requests = %d", got) - } - - scheduler.now = func() time.Time { - return time.Date(2026, 6, 12, 5, 0, 0, 0, time.Local) - } - if cloudAutoSyncInWindow(scheduler.now()) { - t.Fatalf("05:00 should be outside overnight cloud sync window") - } -} - -func TestSchedulerRunNowCloudSyncBypassesNightlyWindow(t *testing.T) { - var requests atomic.Int32 - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - requests.Add(1) - return []openListTestEntry{{Name: "Manual.Cloud.Movie.2026.mkv", Size: 1024}}, 1 - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - scheduler := NewSchedulerService(log, repos, scanner, nil, nil, storage, NewHub(log), "") - scheduler.now = func() time.Time { - return time.Date(2026, 6, 11, 10, 0, 0, 0, time.Local) - } - scheduler.jobs = []*scheduledJob{{ - name: "cloud_sync", - interval: time.Minute, - run: scheduler.jobSyncCloudLibraries, - }} - - if err := scheduler.RunNow(t.Context(), "cloud_sync"); err != nil { - t.Fatalf("manual cloud sync: %v", err) - } - if got := requests.Load(); got != 1 { - t.Fatalf("manual cloud sync requests = %d, want 1", got) - } -} - -func TestSchedulerCloudSyncDisabledByDefault(t *testing.T) { - var requests atomic.Int32 - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - requests.Add(1) - return []openListTestEntry{{Name: "Cloud.Movie.2026.mkv", Size: 1024}}, 1 - }) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - scheduler := NewSchedulerService(log, repos, scanner, nil, nil, storage, NewHub(log), "") - - if err := scheduler.jobSyncCloudLibraries(t.Context()); err != nil { - t.Fatalf("disabled cloud sync should be a no-op: %v", err) - } - if got := requests.Load(); got != 0 { - t.Fatalf("cloud sync made %d upstream requests while disabled by default", got) - } - if got := countMedia(t, repos); got != 0 { - t.Fatalf("media count = %d, want 0 while cloud sync disabled by default", got) - } -} - -func fixedNightlySyncTime() time.Time { - return time.Date(2026, 6, 11, 23, 30, 0, 0, time.Local) -} diff --git a/internal/service/scheduler_local_jobs.go b/internal/service/scheduler_local_jobs.go index 5f17a3a..e93af0a 100644 --- a/internal/service/scheduler_local_jobs.go +++ b/internal/service/scheduler_local_jobs.go @@ -31,13 +31,6 @@ func (s *SchedulerService) jobScanLibraries(ctx context.Context) error { if !l.Enabled { continue } - if _, ok := ParseCloudLibraryMount(l.Path); ok { - // 云盘库由 cloud_sync 任务在夜间窗口低频同步;周期性整库 - // 重扫只面向本地磁盘库。否则十几个云盘库每小时全量遍历 - // 会把 CPU/网络长期吃满,还会占住唯一的云扫描槽位,让 - // 手动扫描看起来一直"卡死"在排队。 - continue - } if _, err := s.scanner.ScanLibrary(ctx, l.ID); err != nil { s.log.Warn("scheduled scan failed", zap.String("library", l.ID), zap.Error(err)) @@ -74,14 +67,14 @@ func (s *SchedulerService) periodicScanDue(ctx context.Context, now time.Time) b if err != nil { return true } - return strings.TrimSpace(last) != now.In(time.Local).Format(cloudAutoSyncCompletedDateForm) + return strings.TrimSpace(last) != now.In(time.Local).Format("2006-01-02") } func (s *SchedulerService) markPeriodicScanCompleted(ctx context.Context, now time.Time) error { if s.repo == nil || s.repo.Setting == nil { return nil } - return s.repo.Setting.Set(ctx, localLastPeriodicScanDateKey, now.In(time.Local).Format(cloudAutoSyncCompletedDateForm)) + return s.repo.Setting.Set(ctx, localLastPeriodicScanDateKey, now.In(time.Local).Format("2006-01-02")) } // jobOrganizeSource periodically organizes the configured staging/download diff --git a/internal/service/scheduler_organize_test.go b/internal/service/scheduler_organize_test.go index cf39a56..1c1fea9 100644 --- a/internal/service/scheduler_organize_test.go +++ b/internal/service/scheduler_organize_test.go @@ -31,7 +31,7 @@ func TestSchedulerOrganizeSourceDisabledByDefault(t *testing.T) { } organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) - scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, nil, NewHub(zap.NewNop()), "") + scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, NewHub(zap.NewNop()), "") if err := scheduler.jobOrganizeSource(t.Context()); err != nil { t.Fatalf("disabled organize source job should be a no-op: %v", err) } @@ -61,7 +61,7 @@ func TestSchedulerOrganizeSourceUsesConfiguredSourceAndDestination(t *testing.T) } organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) - scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, nil, NewHub(zap.NewNop()), "") + scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, NewHub(zap.NewNop()), "") if err := scheduler.jobOrganizeSource(t.Context()); err != nil { t.Fatalf("organize source job: %v", err) } @@ -90,7 +90,7 @@ func TestSchedulerRunNowOrganizeSourceBypassesDisabledSwitch(t *testing.T) { } organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) - scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, nil, NewHub(zap.NewNop()), "") + scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, organizer, NewHub(zap.NewNop()), "") scheduler.jobs = []*scheduledJob{{ name: "organize_source", interval: time.Minute, @@ -136,7 +136,7 @@ func TestSchedulerOrganizeSourceSyncsVisibilityWhenTargetAlreadyExists(t *testin t.Fatalf("seed organize destination: %v", err) } scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) - scheduler := NewSchedulerService(zap.NewNop(), repos, scanner, nil, organizer, nil, NewHub(zap.NewNop()), "") + scheduler := NewSchedulerService(zap.NewNop(), repos, scanner, nil, organizer, NewHub(zap.NewNop()), "") scheduler.jobs = []*scheduledJob{{ name: "organize_source", interval: time.Minute, diff --git a/internal/service/scheduler_scan_test.go b/internal/service/scheduler_scan_test.go index 4f65113..d8686dd 100644 --- a/internal/service/scheduler_scan_test.go +++ b/internal/service/scheduler_scan_test.go @@ -28,7 +28,7 @@ func TestSchedulerPeriodicLocalScanRunsAtMostOncePerDay(t *testing.T) { } log := zap.NewNop() scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scheduler := NewSchedulerService(log, repos, scanner, nil, nil, nil, NewHub(log), "") + scheduler := NewSchedulerService(log, repos, scanner, nil, nil, NewHub(log), "") scheduler.now = func() time.Time { return time.Date(2026, 6, 20, 10, 0, 0, 0, time.Local) } @@ -75,7 +75,7 @@ func TestSchedulerManualLocalScanBypassesDailyPeriodicLimit(t *testing.T) { } log := zap.NewNop() scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scheduler := NewSchedulerService(log, repos, scanner, nil, nil, nil, NewHub(log), "") + scheduler := NewSchedulerService(log, repos, scanner, nil, nil, NewHub(log), "") scheduler.now = func() time.Time { return time.Date(2026, 6, 20, 10, 0, 0, 0, time.Local) } diff --git a/internal/service/scheduler_test.go b/internal/service/scheduler_test.go index 66f2106..6c5de3c 100644 --- a/internal/service/scheduler_test.go +++ b/internal/service/scheduler_test.go @@ -12,7 +12,7 @@ import ( ) func TestSchedulerRunNowAsyncSurvivesCallerCancellation(t *testing.T) { - scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, nil, "") + scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, "") started := make(chan struct{}) release := make(chan struct{}) finished := make(chan struct{}) @@ -66,7 +66,7 @@ func TestSchedulerRunNowAsyncSurvivesCallerCancellation(t *testing.T) { } func TestSchedulerRunNowAsyncRejectsDuplicateRun(t *testing.T) { - scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, nil, "") + scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, "") started := make(chan struct{}) release := make(chan struct{}) scheduler.jobs = []*scheduledJob{{ @@ -90,7 +90,7 @@ func TestSchedulerRunNowAsyncRejectsDuplicateRun(t *testing.T) { } func TestSchedulerStartDoesNotRegisterSubscriptionPullJob(t *testing.T) { - scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, nil, "") + scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, "") ctx, cancel := context.WithCancel(t.Context()) cancel() scheduler.Start(ctx) @@ -104,7 +104,7 @@ func TestSchedulerStartDoesNotRegisterSubscriptionPullJob(t *testing.T) { } func TestSchedulerLoopWaitsIntervalAfterSlowRun(t *testing.T) { - scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, nil, "") + scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, "") ctx, cancel := context.WithCancel(t.Context()) defer cancel() diff --git a/internal/service/scraper_library.go b/internal/service/scraper_library.go index 5d71af3..2b4bab3 100644 --- a/internal/service/scraper_library.go +++ b/internal/service/scraper_library.go @@ -109,7 +109,6 @@ func (s *ScraperService) EnrichLibraryDetailedWithOptions(ctx context.Context, l } if err := s.EnrichOneWithOptions(ctx, &rows[i], runOptions); err != nil { s.log.Warn("enrich failed", zap.String("media", rows[i].ID), zap.Error(err)) - s.notifyScrapeFailed(rows[i], err) result.Failed++ continue } @@ -194,22 +193,6 @@ func shouldScrapeCandidateRow(media model.Media) bool { return organizeMediaTitleLooksLikeRelease(media.Title) } -func (s *ScraperService) notifyScrapeFailed(m model.Media, err error) { - if s == nil || s.notify == nil || err == nil { - return - } - body := strings.TrimSpace(m.Title) - if body == "" { - body = m.Path - } - body = "媒体:" + body + "\n错误:" + err.Error() - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - s.notify.Broadcast(ctx, "MediaStationGo 刮削失败", body, EventScrapeFailed) - }() -} - func (s *ScraperService) scrapeDelay(ctx context.Context) time.Duration { minMS := s.scrapeDelaySetting(ctx, "scrape.delay_min_ms", defaultScrapeDelayMinMS) maxMS := s.scrapeDelaySetting(ctx, "scrape.delay_max_ms", defaultScrapeDelayMaxMS) diff --git a/internal/service/scraper_library_test.go b/internal/service/scraper_library_test.go index 4089dc3..9f7afd4 100644 --- a/internal/service/scraper_library_test.go +++ b/internal/service/scraper_library_test.go @@ -116,47 +116,3 @@ func TestScrapeCandidateRowsPrioritizeLibraryArtworkBeforeEpisodes(t *testing.T) t.Fatalf("scrape order = [%s, %s], want series-level row before episode row", got[0].ID, got[1].ID) } } - -func TestEnrichLibraryIncludesMergedCloudLibraryMedia(t *testing.T) { - scraper, repos, closeServer := newTestScraper(t) - defer closeServer() - - local := model.Library{Name: "番剧", Path: t.TempDir(), Type: "tv", Enabled: true} - cloud := model.Library{ - Name: "OpenList · 番剧", - Path: BuildCloudLibraryPath("openlist", "/番剧", "/番剧"), - Type: "tv", - Enabled: true, - } - if err := repos.DB.Create(&local).Error; err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&cloud).Error; err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.Media{ - LibraryID: cloud.ID, - Title: "间谍过家家", - Path: "cloud://openlist/番剧/间谍过家家 - S02E02.mkv", - SeasonNum: 2, - EpisodeNum: 2, - ScrapeStatus: "pending", - }).Error; err != nil { - t.Fatal(err) - } - - result, err := scraper.EnrichLibraryDetailed(t.Context(), local.ID, true) - if err != nil { - t.Fatal(err) - } - if result.Matched != 1 || result.Processed != 1 || result.Candidates != 1 || result.Failed != 0 { - t.Fatalf("result=%+v, want merged cloud media to be scraped once", result) - } - var got model.Media - if err := repos.DB.First(&got, "library_id = ?", cloud.ID).Error; err != nil { - t.Fatal(err) - } - if got.ScrapeStatus != "matched" || got.TMDbID != 12345 { - t.Fatalf("merged cloud media was not enriched: status=%q tmdb=%d", got.ScrapeStatus, got.TMDbID) - } -} diff --git a/internal/service/scraper_query_paths.go b/internal/service/scraper_query_paths.go index f28ccda..236a6ef 100644 --- a/internal/service/scraper_query_paths.go +++ b/internal/service/scraper_query_paths.go @@ -67,6 +67,15 @@ func cleanSlashPath(value string) string { return strings.TrimRight(value, "/") } +func pathBaseSlash(value string) string { + value = strings.Trim(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"), "/") + if value == "" { + return "" + } + parts := strings.Split(value, "/") + return parts[len(parts)-1] +} + func comparableLibraryRoot(libraryRoot string) string { if info, ok := ParseCloudLibraryMount(libraryRoot); ok { if strings.TrimSpace(info.DisplayDir) == "" { diff --git a/internal/service/scraper_service.go b/internal/service/scraper_service.go index d898a87..e3150d2 100644 --- a/internal/service/scraper_service.go +++ b/internal/service/scraper_service.go @@ -22,7 +22,6 @@ type ScraperService struct { fanart *FanartProvider adult *AdultProvider hub *Hub - notify *NotifyChannelService cache *RuntimeCacheService images *ImageProxy } @@ -53,12 +52,6 @@ func (s *ScraperService) SetDouban(douban *DoubanProvider) { s.douban = douban } -func (s *ScraperService) SetNotifyChannels(notify *NotifyChannelService) { - if s != nil { - s.notify = notify - } -} - func (s *ScraperService) SetRuntimeCache(cache *RuntimeCacheService) *ScraperService { if s != nil { s.cache = cache diff --git a/internal/service/security_safety_test.go b/internal/service/security_safety_test.go index 921a36e..c5dede3 100644 --- a/internal/service/security_safety_test.go +++ b/internal/service/security_safety_test.go @@ -7,28 +7,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/config" ) -func TestValidateSTRMProxyURLBlocksPrivateTargets(t *testing.T) { - blocked := []string{ - "http://127.0.0.1/video.mkv", - "http://192.168.1.2/video.mkv", - "http://169.254.169.254/latest/meta-data", - "file:///etc/passwd", - } - for _, raw := range blocked { - if _, err := validateSTRMProxyURL(raw); err == nil { - t.Fatalf("validateSTRMProxyURL(%q) allowed unsafe target", raw) - } - } -} - -func TestValidateSTRMProxyURLAllowsPublicHTTP(t *testing.T) { - for _, raw := range []string{"https://example.com/video.mkv", "http://8.8.8.8/video.mkv"} { - if _, err := validateSTRMProxyURL(raw); err != nil { - t.Fatalf("validateSTRMProxyURL(%q) = %v, want nil", raw, err) - } - } -} - func TestBackupFilePathRejectsTraversal(t *testing.T) { svc := &BackupService{cfg: &config.Config{}} svc.cfg.App.DataDir = t.TempDir() diff --git a/internal/service/service.go b/internal/service/service.go index 9a298f2..c89cfff 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -31,46 +31,30 @@ type Container struct { Bangumi *BangumiProvider TheTVDB *TheTVDBProvider Fanart *FanartProvider - Scraper *ScraperService - Discover *DiscoverService - Playback *PlaybackService + Scraper *ScraperService + Playback *PlaybackService ImageProxy *ImageProxy Watcher *WatcherService - Downloads *DownloadService - Subscription *SubscriptionService Subtitle *SubtitleService - Stats *StatsService Profile *ProfileService Audit *AuditService NFO *NFOService - AI *AIService APIConfig *APIConfigService Crypto *CryptoService - Duplicate *DuplicateService FileManager *FileManagerService DLNA *DLNAService Scheduler *SchedulerService Storage *StorageService Emby *EmbyService Backup *BackupService - Notifier *NotifierService - NotifyChannels *NotifyChannelService - TelegramBot *TelegramBotService PlayProfiles *PlayProfileService Permissions *PermissionService - StorageCfg *StorageConfigService - STRM *STRMService SystemUpdate *SystemUpdateService - DownloadClients *DownloadClientService - Assistant *AssistantService Organizer *OrganizerService OrganizePipeline *OrganizePipelineService Douban *DoubanProvider Token *TokenService ApiConfig *ApiConfigService - DownloadMgr *DownloadManager - Notify *NotifyService - Site *SiteService Device *DeviceService Cache *RuntimeCacheService Sessions *SessionTrackerService @@ -96,14 +80,9 @@ func (c *Container) Boot() { if err := c.NormalizeLocalLibraryPaths(c.stopCtx); err != nil { c.Log.Warn("normalize local library paths failed", zap.Error(err)) } - if err := c.NormalizeCloudLibraryTypes(c.stopCtx); err != nil { - c.Log.Warn("normalize cloud library types failed", zap.Error(err)) - } if err := c.Watcher.Start(c.stopCtx); err != nil { c.Log.Warn("watcher start failed", zap.Error(err)) } - c.Downloads.Start(c.stopCtx) - c.Subscription.Start(c.stopCtx) if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil { c.Log.Warn("api config seed failed", zap.Error(err)) } @@ -112,12 +91,6 @@ func (c *Container) Boot() { // 启动调度器定时任务 c.Scheduler.Start(c.stopCtx) - // 云盘存储健康检查 - c.BootCloudStorageHealthCheck(c.stopCtx) - - // 自动扫描云盘媒体库,使内容对所有用户立即可见 - c.BootCloudLibraries(c.stopCtx) - // Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。 // 每天触发一次评估;规则里的窗口可随机,不固定。 if c.Device != nil { @@ -163,12 +136,6 @@ func (c *Container) Close() { if c.Watcher != nil { c.Watcher.Stop() } - if c.Subscription != nil { - c.Subscription.Stop() - } - if c.Downloads != nil { - c.Downloads.Stop() - } if c.Transcoder != nil { c.Transcoder.StopAll() } diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index d3dbbe7..8abdb30 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -37,7 +37,6 @@ func newServiceContainer(cfg *config.Config, log *zap.Logger, repos *repository. builder.initContentServices() builder.initAccessAndStorageServices() builder.initIdentityServices() - builder.initSiteDownloadServices() builder.initImageProxy() builder.attachRuntimeContext() return builder.c @@ -92,7 +91,6 @@ func (b *serviceContainerBuilder) initContentServices() { b.c.Organizer = NewOrganizerService(b.cfg, b.log, b.repos) b.c.Organizer.SetProbe(b.c.FFprobe) b.c.Organizer.SetScraper(b.c.Scraper) - b.c.Discover = NewDiscoverService(b.log, b.c.TMDb) b.c.Transcoder = NewTranscoderService(b.cfg, b.log, b.repos, b.c.WSHub) b.c.Scan = NewScannerService(b.cfg, b.log, b.repos, b.c.WSHub, b.c.FFprobe, b.c.Scraper) b.c.Scan.SetOrganizer(b.c.Organizer) @@ -100,22 +98,15 @@ func (b *serviceContainerBuilder) initContentServices() { b.c.OrganizePipeline = NewOrganizePipelineService(b.log, b.repos, b.c.Organizer, b.c.Scan, b.c.Tasks) b.c.Watcher = NewWatcherService(b.log, b.repos, b.c.Scan) b.c.NFO = NewNFOService(b.log, b.repos) - b.c.AI = NewAIService(b.cfg, b.log, b.c.APIConfig) - b.c.Duplicate = NewDuplicateService(b.log, b.repos, b.c.WSHub) b.c.FileManager = NewFileManagerService(b.cfg, b.log, b.repos) b.c.DLNA = NewDLNAService(b.log) b.c.Storage = NewStorageService(b.log, b.repos) b.c.Emby = NewEmbyService(b.cfg, b.log, b.repos) b.c.Backup = NewBackupService(b.cfg, b.log, b.repos.DB) - b.c.Notifier = NewNotifierService(b.log, b.repos) - b.c.NotifyChannels = NewNotifyChannelService(b.log, b.repos) - b.c.Scan.SetNotifyChannels(b.c.NotifyChannels) - b.c.Scraper.SetNotifyChannels(b.c.NotifyChannels) b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache) b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder) b.c.Playback = NewPlaybackService(b.log, b.repos) b.c.Subtitle = NewSubtitleService(b.log, b.repos) - b.c.Stats = NewStatsService(b.log, b.repos).SetRuntimeCache(b.c.Cache) b.c.Profile = NewProfileService(b.log, b.repos) b.c.Audit = NewAuditService(b.log, b.repos) } @@ -123,18 +114,11 @@ func (b *serviceContainerBuilder) initContentServices() { func (b *serviceContainerBuilder) initAccessAndStorageServices() { b.c.PlayProfiles = NewPlayProfileService(b.log, b.repos) b.c.Permissions = NewPermissionService(b.log, b.repos) - b.c.StorageCfg = NewStorageConfigService(b.log, b.repos, b.c.Crypto) - b.c.STRM = NewSTRMService(b.log, b.repos, b.cfg) - b.c.Scan.SetStorageConfig(b.c.StorageCfg) - b.c.Subtitle.SetStorageConfig(b.c.StorageCfg) b.c.Emby.SetRuntimeCache(b.c.Cache) - b.c.Emby.SetCloudProbe(b.c.StorageCfg, b.c.FFprobe) b.c.Emby.SetSubtitleService(b.c.Subtitle) - b.c.DownloadClients = NewDownloadClientService(b.log, b.repos) - b.c.Assistant = NewAssistantService(b.log, b.repos, b.c.AI) b.c.Scheduler = NewSchedulerService( b.log, b.repos, b.c.Scan, b.c.Transcoder, - b.c.Organizer, b.c.StorageCfg, b.c.WSHub, b.cfg.Cache.CacheDir, + b.c.Organizer, b.c.WSHub, b.cfg.Cache.CacheDir, ) b.c.Scheduler.SetTaskTracker(b.c.Tasks) b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline) @@ -146,28 +130,7 @@ func (b *serviceContainerBuilder) initIdentityServices() { b.c.Sessions = NewSessionTrackerService(b.log) b.c.Device = NewDeviceService(b.log, b.repos) b.c.Device.SetSessionTracker(b.c.Sessions) - b.c.TelegramBot = NewTelegramBotService(b.log, b.repos, b.c.Crypto, b.c.Auth) - b.c.TelegramBot.SetDeviceService(b.c.Device) - b.c.TelegramBot.SetBackupService(b.c.Backup) - // Device enforcement notifies users through their Telegram binding before destructive actions. - b.c.Device.SetNotifier(b.c.TelegramBot.NotifyUserByID) b.c.ApiConfig = NewApiConfigService(b.cfg, b.log, b.repos, b.c.Crypto) - b.c.DownloadMgr = NewDownloadManager(b.log, b.repos, b.c.Crypto) - b.c.Notify = NewNotifyService(b.log, b.repos, b.c.Crypto) -} - -func (b *serviceContainerBuilder) initSiteDownloadServices() { - b.c.Site = NewSiteService(b.log, b.repos, b.flareSolverrURL()) - b.c.Downloads = NewDownloadService(b.log, b.repos, b.c.WSHub, b.c.Organizer, b.c.Site) - b.c.Downloads.SetDownloadManager(b.c.DownloadMgr) - b.c.Organizer.SetActiveDownloadPathProvider(b.c.Downloads.ActiveDownloadPaths) - b.c.Downloads.SetScanner(b.c.Scan) - b.c.Downloads.SetTaskTracker(b.c.Tasks) - b.c.Downloads.SetOrganizePipeline(b.c.OrganizePipeline) - b.c.Downloads.SetNotifyChannels(b.c.NotifyChannels) - b.c.Subscription = NewSubscriptionService(b.cfg, b.log, b.repos, b.c.Downloads, b.c.Site, b.c.WSHub) - b.c.Subscription.SetScraper(b.c.Scraper) - b.c.Subscription.SetNotifyChannels(b.c.NotifyChannels) } func (b *serviceContainerBuilder) initImageProxy() { @@ -175,7 +138,6 @@ func (b *serviceContainerBuilder) initImageProxy() { b.c.ImageProxy.SetLibraryRootsProvider(b.libraryRoots) b.c.Scan.SetImageProxy(b.c.ImageProxy) b.c.Scraper.SetImageProxy(b.c.ImageProxy) - b.c.Discover.SetImageProxy(b.c.ImageProxy) } func (b *serviceContainerBuilder) libraryRoots() []string { @@ -201,13 +163,6 @@ func (b *serviceContainerBuilder) libraryRoots() []string { return roots } -func (b *serviceContainerBuilder) flareSolverrURL() string { - if b.cfg.FlareSolverr.Enabled && b.cfg.FlareSolverr.URL != "" { - return b.cfg.FlareSolverr.URL - } - return "" -} - func (b *serviceContainerBuilder) attachRuntimeContext() { b.c.stopCtx, b.c.stopCancel = context.WithCancel(context.Background()) } diff --git a/internal/service/service_library_normalize.go b/internal/service/service_library_normalize.go index 7c44f9a..d33bedd 100644 --- a/internal/service/service_library_normalize.go +++ b/internal/service/service_library_normalize.go @@ -79,28 +79,6 @@ func normalizePersistedLocalLibraryPath(pathValue string) string { } func (c *Container) NormalizeCloudLibraryTypes(ctx context.Context) error { - if c == nil || c.Repo == nil || c.Repo.Library == nil || c.Repo.DB == nil { - return nil - } - libs, err := c.Repo.Library.List(ctx) - if err != nil { - return err - } - for _, lib := range libs { - info, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - continue - } - want := InferCloudMountMediaType(info.DisplayDir, lib.Name) - if want == "" || want == lib.Type { - continue - } - if err := c.Repo.DB.WithContext(ctx). - Model(&model.Library{}). - Where("id = ?", lib.ID). - Update("type", want).Error; err != nil { - return err - } - } + // 网盘后端已移除,不再存在 cloud:// 挂载库类型需修正。 return nil } diff --git a/internal/service/session_tracker_test.go b/internal/service/session_tracker_test.go index f42530d..04a431a 100644 --- a/internal/service/session_tracker_test.go +++ b/internal/service/session_tracker_test.go @@ -1,7 +1,6 @@ package service import ( - "strings" "testing" "time" @@ -135,61 +134,6 @@ func TestLogoutKeepsRealtimeLastActivityWithoutOnlineSession(t *testing.T) { } } -func TestBotDevicesIncludesRealtimeSessionOnlyDevices(t *testing.T) { - repos, bot := newBotTestService(t) - user := model.User{Base: model.Base{ID: "u1"}, Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true} - if err := repos.User.Create(t.Context(), &user); err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.TelegramBinding{TelegramUserID: 9103, ChatID: 9103, UserID: user.ID}).Error; err != nil { - t.Fatal(err) - } - tracker := NewSessionTrackerService(zap.NewNop()) - now := time.Date(2026, 6, 21, 11, 0, 0, 0, time.UTC) - tracker.now = func() time.Time { return now } - tracker.RecordActivity(t.Context(), user.ID, user.Username, "dev-1", "Apple TV", "Yamby", "10.0.0.8") - device := NewDeviceService(zap.NewNop(), repos) - device.SetSessionTracker(tracker) - bot.SetDeviceService(device) - - reply := bot.replyDevices(t.Context(), &TelegramMessage{ - From: TelegramUser{ID: 9103, Username: "viewer"}, - Chat: TelegramChat{ID: 9103, Type: "private"}, - }) - - if !strings.Contains(reply.Text, "Apple TV / Yamby") || !strings.Contains(reply.Text, "在线") { - t.Fatalf("reply should include realtime online device, got %q", reply.Text) - } - if !strings.Contains(reply.Text, "06-21 11:00") { - t.Fatalf("reply should use realtime last seen time, got %q", reply.Text) - } -} - -func TestBotUserInfoUsesRealtimeLastLogin(t *testing.T) { - repos, bot := newBotTestService(t) - now := time.Date(2026, 6, 21, 13, 45, 0, 0, time.UTC) - old := now.Add(-6 * time.Hour) - user := model.User{Base: model.Base{ID: "u1"}, Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true, LastLoginAt: &old} - if err := repos.User.Create(t.Context(), &user); err != nil { - t.Fatal(err) - } - tracker := NewSessionTrackerService(zap.NewNop()) - tracker.now = func() time.Time { return now } - tracker.RecordActivity(t.Context(), user.ID, user.Username, "dev-1", "Apple TV", "Yamby", "10.0.0.8") - device := NewDeviceService(zap.NewNop(), repos) - device.SetSessionTracker(tracker) - bot.SetDeviceService(device) - - reply := bot.cmdMgoUserInfo(t.Context(), []string{"viewer"}) - - if !strings.Contains(reply.Text, "最后登录:2026-06-21 13:45") { - t.Fatalf("reply should use realtime last login, got %q", reply.Text) - } - if !strings.Contains(reply.Text, "设备:1") { - t.Fatalf("reply should count realtime device, got %q", reply.Text) - } -} - func TestRealtimeRecentLoginProtectsCleanupCandidate(t *testing.T) { repos := newSessionTrackerTestRepos(t) now := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) diff --git a/internal/service/site.go b/internal/service/site.go deleted file mode 100644 index 40a38f1..0000000 --- a/internal/service/site.go +++ /dev/null @@ -1,30 +0,0 @@ -// Package service — site management (PT/BT tracker CRUD + connection test). -// -// SiteService owns the lifecycle of Site rows and exposes a cross-site -// search dispatcher that fans out a keyword query to every enabled site's -// adapter, collects results and returns them merged + sorted. -package service - -import ( - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// SiteService manages PT/BT site configurations. -type SiteService struct { - log *zap.Logger - repo *repository.Container - flareSolverrURL string - apiRateLimiter siteAPIRateLimiter -} - -// NewSiteService is the constructor. -func NewSiteService(log *zap.Logger, repo *repository.Container, flareSolverrURL string) *SiteService { - return &SiteService{ - log: log, - repo: repo, - flareSolverrURL: flareSolverrURL, - apiRateLimiter: newPersistentSiteAPIRateLimiter(repo), - } -} diff --git a/internal/service/site_adapter.go b/internal/service/site_adapter.go deleted file mode 100644 index 298962a..0000000 --- a/internal/service/site_adapter.go +++ /dev/null @@ -1,233 +0,0 @@ -// Package service — PT 站点适配器接口及 6 种适配器实现。 -package service - -import ( - "context" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/helper" - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// SiteConfig 站点配置(从 model.Site 解密后的纯文本)。 -type SiteConfig struct { - SiteID string - Name string - Type string - URL string - AuthType string - Cookie string - APIKey string - AuthHeader string - UserAgent string // 自定义 User-Agent - Timeout time.Duration // 请求超时 - Extra map[string]string // JSON 扩展配置 - FlareSolverrURL string // FlareSolverr 服务地址(用于浏览器模拟绕过 Cloudflare/WAF) - UseProxy bool // 通过 HTTP(S)_PROXY 环境变量出站 - RateLimit bool - rateLimiter siteAPIRateLimiter -} - -// SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。 -type SiteSearchResult struct { - SiteName string `json:"site_name"` - Items []TorrentItem `json:"items"` - Total int `json:"total"` - Page int `json:"page"` -} - -// TorrentItem 种子条目。 -type TorrentItem struct { - ID string `json:"id"` - Title string `json:"title"` - Subtitle string `json:"subtitle"` - Labels string `json:"labels,omitempty"` - Category string `json:"category"` - Size int64 `json:"size"` - Seeders int `json:"seeders"` - Leechers int `json:"leechers"` - Snatched int `json:"snatched"` - Free bool `json:"free"` - FreeEndAt *time.Time `json:"free_end_at"` - UploadTime time.Time `json:"upload_time"` - DetailURL string `json:"detail_url"` - DownloadURL string `json:"download_url"` -} - -// TorrentDetail 种子详情。 -type TorrentDetail struct { - ID string `json:"id"` - Title string `json:"title"` - Subtitle string `json:"subtitle"` - Category string `json:"category"` - Size int64 `json:"size"` - Seeders int `json:"seeders"` - Leechers int `json:"leechers"` - Snatched int `json:"snatched"` - Free bool `json:"free"` - FreeEndAt *time.Time `json:"free_end_at"` - UploadTime time.Time `json:"upload_time"` - DetailURL string `json:"detail_url"` - DownloadURL string `json:"download_url"` - InfoHash string `json:"info_hash,omitempty"` - ImdbID string `json:"imdb_id,omitempty"` - Description string `json:"description,omitempty"` - Files []string `json:"files,omitempty"` -} - -// SiteAdapter 站点适配器接口。 -type SiteAdapter interface { - // Authenticate 测试站点认证是否有效。 - Authenticate(ctx context.Context, cfg SiteConfig) error - - // Search 搜索种子。 - Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) - - // Browse 浏览种子列表。 - Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) - - // GetDetail 获取种子详情。 - GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) - - // GetDownloadURL 获取下载链接。 - GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) -} - -// newHTTPClient 创建带有认证头的 HTTP 客户端。 -// 当 cfg.UseProxy 为 true 时,会读取 HTTP(S)_PROXY 环境变量; -// 否则忽略环境变量直连。 -func newHTTPClient(cfg SiteConfig, timeout time.Duration) *http.Client { - secs := int(timeout.Seconds()) - if secs <= 0 { - secs = 30 - } - return helper.NewSiteHTTPClient(secs, cfg.UseProxy) -} - -func siteRequestHTTPClient(client *http.Client, cfg SiteConfig) *http.Client { - timeout := cfg.Timeout - if timeout <= 0 { - timeout = 30 * time.Second - } - if client == nil || cfg.UseProxy || client.Timeout != timeout { - return newHTTPClient(cfg, timeout) - } - return client -} - -// buildRequest 构建带认证的 HTTP 请求。 -func buildRequest(ctx context.Context, method, rawURL string, cfg SiteConfig, body io.Reader) (*http.Request, error) { - req, err := http.NewRequestWithContext(ctx, method, rawURL, body) - if err != nil { - return nil, err - } - - switch cfg.AuthType { - case "cookie": - if cfg.Cookie != "" { - req.Header.Set("Cookie", cfg.Cookie) - } - case "api_key": - if cfg.APIKey != "" { - if isYemaPTConfig(cfg) { - req.Header.Set("Authorization", cfg.APIKey) - } else { - // M-Team / UNIT3D 等开放 API 的 PT 站点使用 `x-api-key`。 - req.Header.Set("x-api-key", cfg.APIKey) - } - } - case "auth_header": - if cfg.AuthHeader != "" { - parts := strings.SplitN(cfg.AuthHeader, ":", 2) - if len(parts) == 2 { - req.Header.Set(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])) - } else { - req.Header.Set("Authorization", "Bearer "+cfg.AuthHeader) - } - } - } - - // 使用 SiteConfig 中的 UserAgent(如果提供),否则使用默认值 - userAgent := cfg.UserAgent - if userAgent == "" { - userAgent = model.DefaultUserAgent - } - req.Header.Set("User-Agent", userAgent) - return req, nil -} - -// doRequest 执行 HTTP 请求并返回响应体。 -// 当 cfg.FlareSolverrURL 已配置且方法为 GET 时,通过 FlareSolverr 代理请求 -// 以绕过 Cloudflare/WAF 挑战验证。 -func doRequest(ctx context.Context, client *http.Client, method, rawURL string, cfg SiteConfig, body io.Reader) ([]byte, int, error) { - // ── FlareSolverr 浏览器模拟路径(仅 GET) ────────────────────────── - if cfg.FlareSolverrURL != "" && method == "GET" { - timeout := int(cfg.Timeout.Seconds()) - if timeout <= 0 { - timeout = 30 - } - pageBody, err := helper.FetchURLWithFlareSolverr( - cfg.FlareSolverrURL, rawURL, cfg.Cookie, timeout, "", nil) - if err != nil { - return nil, 0, fmt.Errorf("flareSolverr: %w", err) - } - return []byte(pageBody), http.StatusOK, nil - } - - // ── 直接 HTTP 请求路径 ───────────────────────────────────────────── - req, err := buildRequest(ctx, method, rawURL, cfg, body) - if err != nil { - return nil, 0, err - } - if body != nil { - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - } - - // 当站点开启了「使用代理」开关时,使用本次请求专用的、读取 HTTP(S)_PROXY - // 的 client;否则沿用适配器持有的全局 client。这与前端勾选行为对齐。 - httpClient := siteRequestHTTPClient(client, cfg) - - resp, err := httpClient.Do(req) - if err != nil { - return nil, 0, err - } - defer resp.Body.Close() - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, resp.StatusCode, err - } - return data, resp.StatusCode, nil -} - -// ─── 辅助函数 ──────────────────────────────────────────────────────────────── - -// doRequestJSON 执行 JSON 请求。 -func doRequestJSON(ctx context.Context, client *http.Client, method, rawURL string, cfg SiteConfig, body []byte) ([]byte, int, error) { - req, err := buildRequest(ctx, method, rawURL, cfg, nil) - if err != nil { - return nil, 0, err - } - req.Header.Set("Content-Type", "application/json") - if body != nil { - req.Body = io.NopCloser(strings.NewReader(string(body))) - req.ContentLength = int64(len(body)) - } - - httpClient := siteRequestHTTPClient(client, cfg) - resp, err := httpClient.Do(req) - if err != nil { - return nil, 0, err - } - defer resp.Body.Close() - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, resp.StatusCode, err - } - return data, resp.StatusCode, nil -} diff --git a/internal/service/site_adapter_custom_rss.go b/internal/service/site_adapter_custom_rss.go deleted file mode 100644 index f988a4a..0000000 --- a/internal/service/site_adapter_custom_rss.go +++ /dev/null @@ -1,172 +0,0 @@ -// Package service — custom RSS site adapter. -package service - -import ( - "context" - "fmt" - "net/http" - "net/url" - "regexp" - "strconv" - "strings" - "time" -) - -// ─── Custom RSS 适配器 ─────────────────────────────────────────────────────── - -// CustomRSSAdapter 自定义 RSS 源适配器。 -type CustomRSSAdapter struct { - client *http.Client -} - -// NewCustomRSSAdapter 创建 Custom RSS 适配器。 -func NewCustomRSSAdapter() *CustomRSSAdapter { - return &CustomRSSAdapter{ - client: &http.Client{Timeout: 30 * time.Second}, - } -} - -func (a *CustomRSSAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { - // RSS 源通常不需要认证,或者认证通过 URL 参数 - if cfg.URL == "" { - return fmt.Errorf("RSS URL is required") - } - _, status, err := doRequest(ctx, a.client, "GET", cfg.URL, cfg, nil) - if err != nil { - return fmt.Errorf("authenticate: %w", err) - } - if status != http.StatusOK { - return fmt.Errorf("authenticate failed: status %d", status) - } - return nil -} - -func (a *CustomRSSAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) { - searchURL := cfg.URL - // If extra has search URL template, use it - if searchTpl, ok := cfg.Extra["search_url"]; ok && searchTpl != "" { - searchURL = strings.ReplaceAll(searchTpl, "{keyword}", url.QueryEscape(keyword)) - searchURL = strings.ReplaceAll(searchURL, "{page}", strconv.Itoa(page)) - } - - data, status, err := doRequest(ctx, a.client, "GET", searchURL, cfg, nil) - if err != nil { - return nil, fmt.Errorf("search: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("search failed: status %d", status) - } - - result, err := parseRSSXML(data, cfg.Name, keyword) - if err != nil { - return nil, fmt.Errorf("parse RSS: %w", err) - } - - if page > 1 { - // Simple pagination for RSS: skip items already seen - start := (page - 1) * 50 - if start < len(result.Items) { - result.Items = result.Items[start:] - } else { - result.Items = []TorrentItem{} - } - } - result.Page = page - - return result, nil -} - -func (a *CustomRSSAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) { - // RSS browse is essentially the same as search with empty keyword - return a.Search(ctx, cfg, "", page) -} - -func (a *CustomRSSAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) { - // RSS typically doesn't support detail page; return basic info - return &TorrentDetail{ - ID: id, - Title: id, - }, nil -} - -func (a *CustomRSSAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) { - return id, nil // In RSS, the ID is often the download URL -} - -// parseRSSXML 解析 RSS XML 内容。 -func parseRSSXML(data []byte, siteName, keyword string) (*SiteSearchResult, error) { - result := &SiteSearchResult{ - SiteName: siteName, - Items: []TorrentItem{}, - } - - html := string(data) - // Simple regex-based XML parsing for RSS items - itemRegex := regexp.MustCompile(`(.*?)`) - items := itemRegex.FindAllStringSubmatch(html, -1) - - for i, item := range items { - ri := TorrentItem{} - - // Title - titleRegex := regexp.MustCompile(`(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?`) - if m := titleRegex.FindStringSubmatch(item[1]); len(m) >= 2 { - ri.Title = strings.TrimSpace(m[1]) - } - - // Filter by keyword - if keyword != "" && !strings.Contains(strings.ToLower(ri.Title), strings.ToLower(keyword)) { - continue - } - - ri.ID = strconv.Itoa(i) - - // Link - linkRegex := regexp.MustCompile(`(?:)?`) - if m := linkRegex.FindStringSubmatch(item[1]); len(m) >= 2 { - ri.DetailURL = strings.TrimSpace(m[1]) - ri.DownloadURL = strings.TrimSpace(m[1]) - } - - // Description - descRegex := regexp.MustCompile(`(?:)?`) - if m := descRegex.FindStringSubmatch(item[1]); len(m) >= 2 { - desc := stripHTML(m[1]) - ri.Subtitle = desc - } - - // Size from description - sizeRegex := regexp.MustCompile(`(\d+\.?\d*)\s*(GB|MB|TB|KB)`) - if m := sizeRegex.FindStringSubmatch(item[1]); len(m) >= 3 { - ri.Size = parseSizeString(m[1], m[2]) - } - - // Category - catRegex := regexp.MustCompile(`(?:)?`) - if m := catRegex.FindStringSubmatch(item[1]); len(m) >= 2 { - ri.Category = strings.TrimSpace(m[1]) - } - - // Date - dateRegex := regexp.MustCompile(`(?:)?`) - if m := dateRegex.FindStringSubmatch(item[1]); len(m) >= 2 { - for _, layout := range []string{ - time.RFC1123, time.RFC1123Z, time.RFC3339, - "2006-01-02 15:04:05", "2006-01-02T15:04:05-07:00", - } { - if t, err := time.Parse(layout, strings.TrimSpace(m[1])); err == nil { - ri.UploadTime = t - break - } - } - } - - result.Items = append(result.Items, ri) - } - - result.Total = len(result.Items) - return result, nil -} - -// TorrentDetail has a Description field used by RSS adapter. -// (Already defined above) diff --git a/internal/service/site_adapter_discuz.go b/internal/service/site_adapter_discuz.go deleted file mode 100644 index 3c66f2d..0000000 --- a/internal/service/site_adapter_discuz.go +++ /dev/null @@ -1,167 +0,0 @@ -// Package service — Discuz site adapter. -package service - -import ( - "context" - "fmt" - "net/http" - "net/url" - "regexp" - "strconv" - "strings" - "time" -) - -// ─── Discuz 适配器 ─────────────────────────────────────────────────────────── - -// DiscuzAdapter 基于 Discuz! X 的站点适配器。 -type DiscuzAdapter struct { - client *http.Client -} - -// NewDiscuzAdapter 创建 Discuz 适配器。 -func NewDiscuzAdapter() *DiscuzAdapter { - return &DiscuzAdapter{ - client: &http.Client{Timeout: 30 * time.Second}, - } -} - -func (a *DiscuzAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { - u := cfg.URL + "/home.php?mod=space" - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return fmt.Errorf("authenticate: %w", err) - } - if status == http.StatusFound { - return fmt.Errorf("authentication failed: redirected to login") - } - if status != http.StatusOK { - return fmt.Errorf("authenticate failed: status %d", status) - } - body := string(data) - if strings.Contains(body, "login") && !strings.Contains(body, "我的空间") { - return fmt.Errorf("authentication failed: not logged in") - } - return nil -} - -func (a *DiscuzAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) { - params := url.Values{} - params.Set("mod", "forum") - params.Set("srchtxt", keyword) - params.Set("searchsubmit", "true") - params.Set("page", strconv.Itoa(page)) - - u := cfg.URL + "/search.php?" + params.Encode() - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("search: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("search failed: status %d", status) - } - - return parseDiscuzHTML(string(data), cfg.Name, cfg.URL) -} - -func (a *DiscuzAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) { - params := url.Values{} - if category != "" { - params.Set("fid", category) - } - params.Set("page", strconv.Itoa(page)) - - u := cfg.URL + "/forum.php?" + params.Encode() - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("browse: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("browse failed: status %d", status) - } - - return parseDiscuzHTML(string(data), cfg.Name, cfg.URL) -} - -func (a *DiscuzAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) { - u := cfg.URL + "/forum.php?mod=viewthread&tid=" + id - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("detail: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("detail failed: status %d", status) - } - - html := string(data) - detail := &TorrentDetail{ - ID: id, - DetailURL: cfg.URL + "/forum.php?mod=viewthread&tid=" + id, - } - - // Title - titleRegex := regexp.MustCompile(`]*id="thread_subject"[^>]*>([^<]+)`) - if m := titleRegex.FindStringSubmatch(html); len(m) >= 2 { - detail.Title = strings.TrimSpace(m[1]) - } - - // Extract magnet/torrent links - magnetRegex := regexp.MustCompile(`magnet:\?[^\s"'<>]+`) - if m := magnetRegex.FindString(html); m != "" { - detail.DownloadURL = m - } - torrentRegex := regexp.MustCompile(`(attachment\.php\?aid=\d+)`) - if m := torrentRegex.FindString(html); m != "" && detail.DownloadURL == "" { - detail.DownloadURL = cfg.URL + "/" + m - } - - // Description - descRegex := regexp.MustCompile(`]*class="t_fsz"[^>]*>(.*?)`) - if m := descRegex.FindStringSubmatch(html); len(m) >= 2 { - detail.Description = stripHTML(m[1]) - } - - return detail, nil -} - -func (a *DiscuzAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) { - return cfg.URL + "/forum.php?mod=viewthread&tid=" + id, nil -} - -// parseDiscuzHTML 解析 Discuz HTML 响应。 -func parseDiscuzHTML(html, siteName, baseURL string) (*SiteSearchResult, error) { - result := &SiteSearchResult{ - SiteName: siteName, - Items: []TorrentItem{}, - Page: 1, - } - - // Extract thread links - threadRegex := regexp.MustCompile(`]*href="(?:forum\.php\?mod=viewthread&tid=|thread-(\d+)-1-1)\.html"[^>]*>([^<]+)`) - matches := threadRegex.FindAllStringSubmatch(html, -1) - - for _, m := range matches { - item := TorrentItem{} - if m[1] != "" { - item.ID = m[1] - } else { - // Extract tid from URL - tidRegex := regexp.MustCompile(`tid=(\d+)`) - if tidM := tidRegex.FindStringSubmatch(m[0]); len(tidM) >= 2 { - item.ID = tidM[1] - } - } - if item.ID == "" { - continue - } - - item.Title = strings.TrimSpace(m[2]) - item.DetailURL = baseURL + "/forum.php?mod=viewthread&tid=" + item.ID - item.UploadTime = time.Now() - - result.Items = append(result.Items, item) - } - - result.Total = len(result.Items) - return result, nil -} diff --git a/internal/service/site_adapter_factory.go b/internal/service/site_adapter_factory.go deleted file mode 100644 index 3e80682..0000000 --- a/internal/service/site_adapter_factory.go +++ /dev/null @@ -1,40 +0,0 @@ -package service - -import ( - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// GetAdapterForType 根据站点类型返回对应的适配器实例。 -func GetAdapterForType(siteType string) SiteAdapter { - switch strings.ToLower(siteType) { - case "nexusphp": - return NewNexusPHPAdapter() - case "gazelle": - return NewGazelleAdapter() - case "unit3d": - return NewUNIT3DAdapter() - case "mteam": - return NewMTeamAdapter() - case "yemapt": - return NewYemaPTAdapter() - case "discuz": - return NewDiscuzAdapter() - case "custom_rss": - return NewCustomRSSAdapter() - default: - return NewNexusPHPAdapter() - } -} - -// NewSiteAdapter 根据站点模型创建对应的适配器。 -func NewSiteAdapter(site *model.Site) SiteAdapter { - if site == nil { - return nil - } - if isYemaPTURL(site.URL) { - return NewYemaPTAdapter() - } - return GetAdapterForType(site.Type) -} diff --git a/internal/service/site_adapter_gazelle.go b/internal/service/site_adapter_gazelle.go deleted file mode 100644 index 9972561..0000000 --- a/internal/service/site_adapter_gazelle.go +++ /dev/null @@ -1,227 +0,0 @@ -// Package service — Gazelle site adapter. -package service - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "time" -) - -// ─── Gazelle 适配器 ────────────────────────────────────────────────────────── - -// GazelleAdapter Gazelle 框架适配器(What.cd 开源)。 -type GazelleAdapter struct { - client *http.Client -} - -// NewGazelleAdapter 创建 Gazelle 适配器。 -func NewGazelleAdapter() *GazelleAdapter { - return &GazelleAdapter{ - client: &http.Client{Timeout: 30 * time.Second}, - } -} - -func (a *GazelleAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { - u := cfg.URL + "/ajax.php?action=index" - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return fmt.Errorf("authenticate: %w", err) - } - if status != http.StatusOK { - return fmt.Errorf("authenticate failed: status %d", status) - } - var result map[string]interface{} - if err := json.Unmarshal(data, &result); err != nil { - return fmt.Errorf("parse response: %w", err) - } - if statusMsg, ok := result["status"].(string); ok && statusMsg == "failure" { - return fmt.Errorf("authentication failed: %v", result["error"]) - } - return nil -} - -func (a *GazelleAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) { - params := url.Values{} - params.Set("action", "browse") - params.Set("searchstr", keyword) - params.Set("page", strconv.Itoa(page)) - - u := cfg.URL + "/ajax.php?" + params.Encode() - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("search: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("search failed: status %d", status) - } - - return parseGazelleJSON(data, cfg.Name, cfg.URL) -} - -func (a *GazelleAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) { - params := url.Values{} - params.Set("action", "browse") - if category != "" { - params.Set("filter_cat["+category+"]", "1") - } - params.Set("page", strconv.Itoa(page)) - - u := cfg.URL + "/ajax.php?" + params.Encode() - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("browse: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("browse failed: status %d", status) - } - - return parseGazelleJSON(data, cfg.Name, cfg.URL) -} - -func (a *GazelleAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) { - params := url.Values{} - params.Set("action", "torrent") - params.Set("id", id) - - u := cfg.URL + "/ajax.php?" + params.Encode() - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("detail: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("detail failed: status %d", status) - } - - var resp map[string]interface{} - if err := json.Unmarshal(data, &resp); err != nil { - return nil, fmt.Errorf("parse response: %w", err) - } - - torrent, ok := resp["torrent"].(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("torrent not found") - } - - detail := &TorrentDetail{ - ID: id, - DetailURL: cfg.URL + "/torrents.php?torrentid=" + id, - DownloadURL: cfg.URL + "/torrents.php?action=download&id=" + id, - } - - if v, ok := torrent["groupName"].(string); ok { - detail.Title = v - } - if v, ok := torrent["subName"].(string); ok { - detail.Subtitle = v - } - if v, ok := torrent["size"].(float64); ok { - detail.Size = int64(v) - } - if v, ok := torrent["seeders"].(float64); ok { - detail.Seeders = int(v) - } - if v, ok := torrent["leechers"].(float64); ok { - detail.Leechers = int(v) - } - if v, ok := torrent["snatched"].(float64); ok { - detail.Snatched = int(v) - } - if v, ok := torrent["freeTorrent"].(string); ok && v == "1" { - detail.Free = true - } - if v, ok := torrent["freeTorrent"].(bool); ok { - detail.Free = v - } - if v, ok := torrent["infoHash"].(string); ok { - detail.InfoHash = v - } - if v, ok := torrent["groupDesc"].(string); ok { - detail.Description = stripHTML(v) - } - - return detail, nil -} - -func (a *GazelleAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) { - return cfg.URL + "/torrents.php?action=download&id=" + id, nil -} - -// parseGazelleJSON 解析 Gazelle JSON 响应。 -func parseGazelleJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) { - var resp map[string]interface{} - if err := json.Unmarshal(data, &resp); err != nil { - return nil, fmt.Errorf("parse JSON: %w", err) - } - - result := &SiteSearchResult{ - SiteName: siteName, - Items: []TorrentItem{}, - } - - if status, ok := resp["status"].(string); ok && status == "failure" { - return result, nil - } - - results, ok := resp["results"].([]interface{}) - if !ok { - return result, nil - } - - for _, r := range results { - torrent, ok := r.(map[string]interface{}) - if !ok { - continue - } - item := TorrentItem{} - if v, ok := torrent["torrentId"].(float64); ok { - item.ID = strconv.Itoa(int(v)) - } - if v, ok := torrent["groupName"].(string); ok { - item.Title = v - } - if v, ok := torrent["artist"].(string); ok { - item.Subtitle = v - } - if v, ok := torrent["category"].(string); ok { - item.Category = v - } - if v, ok := torrent["size"].(float64); ok { - item.Size = int64(v) - } - if v, ok := torrent["seeders"].(float64); ok { - item.Seeders = int(v) - } - if v, ok := torrent["leechers"].(float64); ok { - item.Leechers = int(v) - } - if v, ok := torrent["snatched"].(float64); ok { - item.Snatched = int(v) - } - if v, ok := torrent["freeTorrent"].(string); ok && v == "1" { - item.Free = true - } - if v, ok := torrent["freeTorrent"].(bool); ok { - item.Free = v - } - if v, ok := torrent["time"].(string); ok { - if t, err := time.Parse(time.RFC3339, v); err == nil { - item.UploadTime = t - } - } - - item.DetailURL = baseURL + "/torrents.php?torrentid=" + item.ID - item.DownloadURL = baseURL + "/torrents.php?action=download&id=" + item.ID - result.Items = append(result.Items, item) - } - - if total, ok := resp["totalResults"].(float64); ok { - result.Total = int(total) - } else { - result.Total = len(result.Items) - } - return result, nil -} diff --git a/internal/service/site_adapter_mteam.go b/internal/service/site_adapter_mteam.go deleted file mode 100644 index 4bd9ad0..0000000 --- a/internal/service/site_adapter_mteam.go +++ /dev/null @@ -1,258 +0,0 @@ -// Package service — M-Team site adapter. -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" - "time" -) - -// ─── MTeam 适配器 ──────────────────────────────────────────────────────────── - -// MTeamAdapter MTeam.cc 独立站适配器。 -type MTeamAdapter struct { - client *http.Client -} - -// NewMTeamAdapter 创建 MTeam 适配器。 -func NewMTeamAdapter() *MTeamAdapter { - return &MTeamAdapter{ - client: &http.Client{Timeout: 30 * time.Second}, - } -} - -func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { - if strings.TrimSpace(cfg.APIKey) == "" { - return fmt.Errorf("M-Team 需要填写 API Access Token(控制台 → 实验室 → 存取令牌),不能使用 Cookie 访问开放 API") - } - // 与旧版参考实现对齐: - // 用 camelCase 参数(pageNumber / pageSize),同时接受 code 为字符串 "0" - // 或数值 0;兼容 M-Team v3 API 不同版本的返回。 - if err := reserveMTeamAPIQuota(ctx, cfg, mteamAPIEndpointSearch); err != nil { - return fmt.Errorf("authenticate: %w", err) - } - u := cfg.URL + "/api/torrent/search" - payload := `{"pageNumber":1,"pageSize":1,"mode":"all"}` - data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte(payload)) - if err != nil { - return mteamRequestError("authenticate", cfg, err) - } - preview := string(data) - if len(preview) > 400 { - preview = preview[:400] + "..." - } - if status == http.StatusUnauthorized || status == http.StatusForbidden { - return fmt.Errorf("authentication failed: status %d, body=%s", status, preview) - } - if status >= 300 && status < 400 { - return fmt.Errorf("authentication failed: HTTP %d (API Key 无效或未登录), body=%s", status, preview) - } - if status != http.StatusOK { - return fmt.Errorf("authenticate failed: status %d, body=%s", status, preview) - } - var resp map[string]interface{} - if err := json.Unmarshal(data, &resp); err != nil { - return fmt.Errorf("parse response: %w (body=%s)", err, preview) - } - if mteamCodeOK(resp["code"]) { - return nil - } - msg, _ := resp["message"].(string) - if msg == "" { - msg = fmt.Sprintf("code=%s", mteamCodeString(resp["code"])) - } - return fmt.Errorf("authentication failed: %s (body=%s)", msg, preview) -} - -func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) { - // 与参考项目对齐:使用 camelCase 字段名,page 从 1 开始。 - if page <= 0 { - page = 1 - } - payload := map[string]interface{}{ - "keyword": keyword, - "pageNumber": page, - "pageSize": 50, - } - body, _ := json.Marshal(payload) - - if err := reserveMTeamAPIQuota(ctx, cfg, mteamAPIEndpointSearch); err != nil { - return nil, err - } - u := cfg.URL + "/api/torrent/search" - data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, body) - if err != nil { - return nil, mteamRequestError("search", cfg, err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("search failed: status %d", status) - } - - return parseMTeamJSON(data, cfg.Name, cfg.URL) -} - -func (a *MTeamAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) { - if page <= 0 { - page = 1 - } - payload := map[string]interface{}{ - "keyword": "", - "pageNumber": page, - "pageSize": 50, - } - if category != "" { - payload["categories"] = []string{category} - } - body, _ := json.Marshal(payload) - - if err := reserveMTeamAPIQuota(ctx, cfg, mteamAPIEndpointSearch); err != nil { - return nil, err - } - u := cfg.URL + "/api/torrent/search" - data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, body) - if err != nil { - return nil, mteamRequestError("browse", cfg, err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("browse failed: status %d", status) - } - - return parseMTeamJSON(data, cfg.Name, cfg.URL) -} - -func (a *MTeamAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) { - if err := reserveMTeamAPIQuota(ctx, cfg, mteamAPIEndpointDetail); err != nil { - return nil, err - } - u := cfg.URL + "/api/torrent/detail?id=" + url.QueryEscape(id) - data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, nil) - if err != nil { - return nil, mteamRequestError("detail", cfg, err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("detail failed: status %d", status) - } - - var resp map[string]interface{} - if err := json.Unmarshal(data, &resp); err != nil { - return nil, fmt.Errorf("parse response: %w", err) - } - - dataField, ok := resp["data"].(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("detail not found") - } - - detail := &TorrentDetail{ - ID: id, - DetailURL: cfg.URL + "/detail/" + id, - } - - if v, ok := dataField["name"].(string); ok { - detail.Title = v - } - if v, ok := dataField["subtitle"].(string); ok { - detail.Subtitle = v - } - if v, ok := dataField["size"].(float64); ok { - detail.Size = int64(v) - } - if v, ok := dataField["status"].(map[string]interface{}); ok { - if seeders, ok := v["seeders"].(float64); ok { - detail.Seeders = int(seeders) - } - if leechers, ok := v["leechers"].(float64); ok { - detail.Leechers = int(leechers) - } - if snatched, ok := v["completed"].(float64); ok { - detail.Snatched = int(snatched) - } - } - if v, ok := dataField["free"].(bool); ok { - detail.Free = v - } - if v, ok := dataField["download"].(string); ok { - detail.DownloadURL = v - } - if v, ok := dataField["description"].(string); ok { - detail.Description = stripHTML(v) - } - - return detail, nil -} - -// GetDownloadURL 解析 M-Team 种子的真实下载链接。 -// -// M-Team v3 流程: -// -// POST /api/torrent/genDlToken?id={tid} (带 x-api-key) -// → {"code":"0","data":"https://api.m-team.cc/api/rss/dlv2?sign=..."} -// -// 拿到的 sign URL 可被任何下载客户端无认证地直接 GET。这是旧版参考实现 -// _download_torrent_file 方法的子集。 -func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) { - if err := reserveMTeamAPIQuota(ctx, cfg, mteamAPIEndpointDownload); err != nil { - return "", err - } - u := cfg.URL + "/api/torrent/genDlToken?id=" + id - // genDlToken 是 POST 但参数走 query string;body 留空。 - data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte("{}")) - if err != nil { - return "", mteamRequestError("genDlToken", cfg, err) - } - if status >= 300 { - return "", fmt.Errorf("genDlToken: HTTP %d", status) - } - var resp map[string]interface{} - if err := json.Unmarshal(data, &resp); err != nil { - return "", fmt.Errorf("genDlToken parse: %w", err) - } - codeStr := "" - switch v := resp["code"].(type) { - case string: - codeStr = v - case float64: - codeStr = strconv.Itoa(int(v)) - } - if codeStr != "0" && codeStr != "200" { - msg, _ := resp["message"].(string) - if msg == "" { - msg = "unknown error" - } - return "", fmt.Errorf("genDlToken: %s", msg) - } - dl, _ := resp["data"].(string) - if dl == "" { - return "", fmt.Errorf("genDlToken: empty data field") - } - return dl, nil -} - -func mteamRequestError(action string, cfg SiteConfig, err error) error { - if err == nil { - return nil - } - if isSiteRequestTimeout(err) { - timeout := cfg.Timeout - if timeout <= 0 { - timeout = 30 * time.Second - } - return fmt.Errorf("%s: M-Team API request timed out after %s; check Docker/IPv6/proxy access to api.m-team.cc or increase the site timeout to 45-60s: %w", - action, timeout.Round(time.Second), err) - } - return fmt.Errorf("%s: %w", action, err) -} - -func isSiteRequestTimeout(err error) bool { - if errors.Is(err, context.DeadlineExceeded) { - return true - } - var timeout interface{ Timeout() bool } - return errors.As(err, &timeout) && timeout.Timeout() -} diff --git a/internal/service/site_adapter_mteam_parse.go b/internal/service/site_adapter_mteam_parse.go deleted file mode 100644 index c8917ea..0000000 --- a/internal/service/site_adapter_mteam_parse.go +++ /dev/null @@ -1,135 +0,0 @@ -// Package service — M-Team search response parsing. -package service - -import ( - "encoding/json" - "fmt" - "strconv" - "time" -) - -// parseMTeamJSON 解析 MTeam v3 JSON 响应。 -// -// 响应结构(与旧版参考实现一致): -// -// { -// "code": "0", // 字符串 "0" 表示成功 -// "message": "SUCCESS", -// "data": { -// "total": "123", -// "data": [ ... ] // 旧字段名 "lists" 已被替换为 "data" -// } -// } -func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) { - // 用 map 反序列化以兼容 code/total 既可能是字符串又可能是数字。 - var raw map[string]interface{} - if err := json.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("parse JSON: %w", err) - } - - // code 兼容字符串与数字。 - codeStr := "" - switch v := raw["code"].(type) { - case string: - codeStr = v - case float64: - codeStr = strconv.Itoa(int(v)) - } - if codeStr != "" && codeStr != "0" && codeStr != "200" { - msg, _ := raw["message"].(string) - if msg == "" { - msg = fmt.Sprintf("code=%s", codeStr) - } - return nil, fmt.Errorf("mteam: %s", msg) - } - - dataField, _ := raw["data"].(map[string]interface{}) - if dataField == nil { - return &SiteSearchResult{SiteName: siteName, Items: []TorrentItem{}}, nil - } - - // total 兼容字符串与数字。 - total := 0 - switch v := dataField["total"].(type) { - case string: - total, _ = strconv.Atoi(v) - case float64: - total = int(v) - } - - // data.data(v3)优先;兜底兼容旧的 data.lists。 - var rows []interface{} - switch v := dataField["data"].(type) { - case []interface{}: - rows = v - } - if rows == nil { - if v, ok := dataField["lists"].([]interface{}); ok { - rows = v - } - } - - result := &SiteSearchResult{ - SiteName: siteName, - Items: []TorrentItem{}, - Total: total, - } - - for _, rawT := range rows { - t, ok := rawT.(map[string]interface{}) - if !ok { - continue - } - item := TorrentItem{} - if v, ok := t["id"].(string); ok { - item.ID = v - } else if v, ok := t["id"].(float64); ok { - item.ID = strconv.Itoa(int(v)) - } - if v, ok := t["name"].(string); ok { - item.Title = v - } - if v, ok := t["subtitle"].(string); ok { - item.Subtitle = v - } - if v, ok := t["category"].(map[string]interface{}); ok { - if name, ok := v["name"].(string); ok { - item.Category = name - } - } - if v, ok := t["size"].(float64); ok { - item.Size = int64(v) - } else if v, ok := t["size"].(string); ok { - // v3 API 把 size 序列化成字符串。 - if n, err := strconv.ParseInt(v, 10, 64); err == nil { - item.Size = n - } - } - if v, ok := t["status"].(map[string]interface{}); ok { - if seeders, ok := v["seeders"].(float64); ok { - item.Seeders = int(seeders) - } - if leechers, ok := v["leechers"].(float64); ok { - item.Leechers = int(leechers) - } - if snatched, ok := v["completed"].(float64); ok { - item.Snatched = int(snatched) - } - } - if v, ok := t["free"].(bool); ok { - item.Free = v - } - if v, ok := t["uploadTime"].(float64); ok { - item.UploadTime = time.Unix(int64(v), 0) - } - - item.DetailURL = baseURL + "/detail/" + item.ID - // 标记 download_url 指向 genDlToken;真正的下载链接由 handler 层 - // 在用户点"下载"时通过 MTeamAdapter.GetDownloadURL 解析。 - // 这样前端 SiteSearchPage 才知道这一行有可用的下载入口。 - item.DownloadURL = baseURL + "/api/torrent/genDlToken?id=" + item.ID - result.Items = append(result.Items, item) - } - - return result, nil -} diff --git a/internal/service/site_adapter_nexusphp.go b/internal/service/site_adapter_nexusphp.go deleted file mode 100644 index 1700a45..0000000 --- a/internal/service/site_adapter_nexusphp.go +++ /dev/null @@ -1,122 +0,0 @@ -// Package service — NexusPHP site adapter. -package service - -import ( - "context" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" - "time" -) - -// ─── NexusPHP 适配器 ───────────────────────────────────────────────────────── - -// NexusPHPAdapter NexusPHP 框架适配器(馒头、HDHome、CHDBits 等)。 -type NexusPHPAdapter struct { - client *http.Client -} - -// NewNexusPHPAdapter 创建 NexusPHP 适配器。 -func NewNexusPHPAdapter() *NexusPHPAdapter { - return &NexusPHPAdapter{ - client: &http.Client{Timeout: 30 * time.Second}, - } -} - -func (a *NexusPHPAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { - // 走 doRequest 以便复用代理 / FlareSolverr / 浏览器头。 - data, status, err := doRequest(ctx, a.client, "GET", cfg.URL+"/index.php", cfg, nil) - if err != nil { - return fmt.Errorf("request failed: %w", err) - } - - if status == http.StatusFound { - return fmt.Errorf("authentication failed: redirected to login page") - } - if status == http.StatusUnauthorized || status == http.StatusForbidden { - return fmt.Errorf("authentication failed: status %d", status) - } - if status >= 400 { - return fmt.Errorf("authentication failed: status %d", status) - } - - body := string(data) - // NexusPHP 登录后页面通常包含 logout 或 userdetails; - // 仅当二者都不存在且明确显示登录表单时才判失败。 - if strings.Contains(body, "userdetails") || strings.Contains(body, "logout") || strings.Contains(body, "退出") { - return nil - } - if strings.Contains(body, "takelogin.php") || strings.Contains(body, "id=\"loginform\"") { - return fmt.Errorf("authentication failed: not logged in") - } - // 状态码 OK 但页面不含明显标记时不再武断判失败。 - return nil -} - -func (a *NexusPHPAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) { - params := url.Values{} - params.Set("searchstr", keyword) - params.Set("search", keyword) - params.Set("search_area", "0") - params.Set("search_mode", "0") - params.Set("page", strconv.Itoa(page)) - params.Set("inclbookmarked", "0") - params.Set("incldead", "0") - - u := cfg.URL + "/torrents.php?" + params.Encode() - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("search request: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("search failed: status %d", status) - } - - body := string(data) - if nexusPHPPageLooksLogin(body) { - return nil, fmt.Errorf("search failed: not logged in or cookie expired") - } - return parseNexusPHPHTML(body, cfg.Name, cfg.URL) -} - -func (a *NexusPHPAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) { - params := url.Values{} - if category != "" { - params.Set("cat", category) - } - params.Set("page", strconv.Itoa(page)) - - u := cfg.URL + "/torrents.php?" + params.Encode() - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("browse request: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("browse failed: status %d", status) - } - - body := string(data) - if nexusPHPPageLooksLogin(body) { - return nil, fmt.Errorf("browse failed: not logged in or cookie expired") - } - return parseNexusPHPHTML(body, cfg.Name, cfg.URL) -} - -func (a *NexusPHPAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) { - u := cfg.URL + "/details.php?id=" + id - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("detail request: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("detail failed: status %d", status) - } - - return parseNexusPHPDetailHTML(string(data), id, cfg.URL) -} - -func (a *NexusPHPAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) { - return cfg.URL + "/download.php?id=" + id, nil -} diff --git a/internal/service/site_adapter_nexusphp_detail.go b/internal/service/site_adapter_nexusphp_detail.go deleted file mode 100644 index 6ce57a8..0000000 --- a/internal/service/site_adapter_nexusphp_detail.go +++ /dev/null @@ -1,43 +0,0 @@ -package service - -import ( - "regexp" - "strconv" - "strings" -) - -// parseNexusPHPDetailHTML 解析种子详情页。 -func parseNexusPHPDetailHTML(html, id, baseURL string) (*TorrentDetail, error) { - detail := &TorrentDetail{ - ID: id, - DetailURL: baseURL + "/details.php?id=" + id, - } - if m := regexp.MustCompile(`]*>([^<]+)`).FindStringSubmatch(html); len(m) >= 2 { - detail.Title = strings.TrimSpace(m[1]) - } - if m := regexp.MustCompile(`]*class="[^"]*sub[^"]*"[^>]*>([^<]+)`).FindStringSubmatch(html); len(m) >= 2 { - detail.Subtitle = strings.TrimSpace(m[1]) - } - if m := regexp.MustCompile(`(?i)info_hash[^<]*\s*]*>([^<]+)`).FindStringSubmatch(html); len(m) >= 2 { - detail.InfoHash = strings.TrimSpace(m[1]) - } - if m := regexp.MustCompile(`(?i)imdb[^<]*\s*]*>[^<]*(tt\d+)`).FindStringSubmatch(html); len(m) >= 2 { - detail.ImdbID = m[1] - } - if m := regexp.MustCompile(`(?i)size[^<]*\s*]*>(\d+\.?\d*)\s*(GB|MB|TB|KB)`).FindStringSubmatch(html); len(m) >= 3 { - detail.Size = parseSizeString(m[1], m[2]) - } - if m := regexp.MustCompile(`seeders[^<]*\s*]*>(\d+)\s*]*>\s*\s*]*>\s*\s*]*>leechers[^<]*\s*]*>(\d+)`).FindStringSubmatch(html); len(m) >= 3 { - detail.Seeders, _ = strconv.Atoi(m[1]) - detail.Leechers, _ = strconv.Atoi(m[2]) - } - if m := regexp.MustCompile(`(?i)times completed[^<]*\s*]*>(\d+)`).FindStringSubmatch(html); len(m) >= 2 { - detail.Snatched, _ = strconv.Atoi(m[1]) - } - if m := regexp.MustCompile(`(?i)]*id="kdescr"[^>]*>(.*?)`).FindStringSubmatch(html); len(m) >= 2 { - detail.Description = stripHTML(m[1]) - } - detail.DownloadURL = baseURL + "/download.php?id=" + id - detail.Free = strings.Contains(html, "free") || strings.Contains(html, "免费") - return detail, nil -} diff --git a/internal/service/site_adapter_nexusphp_list.go b/internal/service/site_adapter_nexusphp_list.go deleted file mode 100644 index 1d2d553..0000000 --- a/internal/service/site_adapter_nexusphp_list.go +++ /dev/null @@ -1,232 +0,0 @@ -package service - -import ( - "html" - "net/url" - "regexp" - "strconv" - "strings" - "time" -) - -var ( - nexusPHPFreeLabelRE = regexp.MustCompile(`(?i)(class="[^"]*(?:free|free2|twoupfree|free_download)[^"]*"|促销|免费)`) - nexusPHPRiskLabelRE = regexp.MustCompile(`(?i)(?:class|title|alt)=["'][^"']*\bhr\b[^"']*["']`) -) - -// parseNexusPHPHTML 解析 NexusPHP 种子列表 HTML。 -func parseNexusPHPHTML(html, siteName, baseURL string) (*SiteSearchResult, error) { - result := &SiteSearchResult{ - SiteName: siteName, - Items: []TorrentItem{}, - Page: 1, - } - - for _, row := range nexusPHPTorrentRows(html) { - item := parseNexusPHPRow(row, baseURL) - if item.ID != "" { - result.Items = append(result.Items, item) - } - } - - result.Total = len(result.Items) - return result, nil -} - -func nexusPHPPageLooksLogin(pageHTML string) bool { - lower := strings.ToLower(pageHTML) - if strings.Contains(lower, "details.php") || strings.Contains(lower, "download.php") { - return false - } - for _, marker := range []string{ - "takelogin.php", - "id=\"loginform\"", - "id='loginform'", - "name=\"loginform\"", - "name='loginform'", - "type=\"password\"", - "type='password'", - } { - if strings.Contains(lower, marker) { - return true - } - } - return false -} - -// parseNexusPHPRow 解析单行种子条目。 -func parseNexusPHPRow(row, baseURL string) TorrentItem { - item := TorrentItem{} - if link := firstNexusPHPLink(row, "details.php"); link != nil { - item.ID = link.query.Get("id") - item.Title = nexusPHPTitleFromLink(*link) - item.Subtitle = nexusPHPSubtitle(row) - item.Labels = nexusPHPRowLabels(row) - item.DetailURL = resolveSiteURL(baseURL, link.href) - } - if link := firstNexusPHPLink(row, "download.php"); link != nil { - item.DownloadURL = resolveSiteURL(baseURL, link.href) - } - if sizeMatches := regexp.MustCompile(`(?i)(\d+\.?\d*)\s*(GiB|MiB|TiB|KiB|GB|MB|TB|KB)`).FindStringSubmatch(row); len(sizeMatches) >= 3 { - item.Size = parseSizeString(sizeMatches[1], sizeMatches[2]) - } - if value, ok := nexusPHPIntByClass(row, "seeders"); ok { - item.Seeders = value - } - if value, ok := nexusPHPIntByClass(row, "leechers"); ok { - item.Leechers = value - } - if value, ok := nexusPHPIntByClass(row, "snatched"); ok { - item.Snatched = value - } - if item.Snatched == 0 { - if m := regexp.MustCompile(`snatched[^"]*"[^>]*>(\d+)`).FindStringSubmatch(row); len(m) >= 2 { - item.Snatched, _ = strconv.Atoi(m[1]) - } - } - item.Free = regexp.MustCompile(`(?i)(class="free|free2|twoupfree|free_download|促销|免费)`).MatchString(row) - if m := regexp.MustCompile(`(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})`).FindStringSubmatch(row); len(m) >= 2 { - if t, err := time.Parse("2006-01-02 15:04", m[1]); err == nil { - item.UploadTime = t - } - } - if m := regexp.MustCompile(`cat=(\d+)[^"]*"[^>]*title="([^"]+)"`).FindStringSubmatch(row); len(m) >= 3 { - item.Category = strings.TrimSpace(m[2]) - } - return item -} - -type nexusPHPLink struct { - href string - attrs string - text string - query url.Values -} - -func nexusPHPTorrentRows(pageHTML string) []string { - rows := regexp.MustCompile(`(?is)]*>.*?`).FindAllString(pageHTML, -1) - out := make([]string, 0, len(rows)) - for _, row := range rows { - if strings.Contains(strings.ToLower(row), "details.php") { - out = append(out, row) - } - } - return out -} - -func firstNexusPHPLink(row, path string) *nexusPHPLink { - pattern := regexp.MustCompile(`(?is)]*href\s*=\s*["']([^"']*)["'][^>]*)>(.*?)`) - for _, match := range pattern.FindAllStringSubmatch(row, -1) { - if len(match) < 4 { - continue - } - href := html.UnescapeString(strings.TrimSpace(match[2])) - parsed, err := url.Parse(href) - if err != nil || !nexusPHPLinkPathMatches(parsed, path) { - continue - } - return &nexusPHPLink{ - href: href, - attrs: match[1], - text: cleanNexusPHPText(match[3]), - query: parsed.Query(), - } - } - return nil -} - -func nexusPHPLinkPathMatches(parsed *url.URL, want string) bool { - if parsed == nil { - return false - } - path := strings.TrimSpace(parsed.Path) - if path == "" { - path = strings.TrimSpace(parsed.Opaque) - } - path = strings.Trim(strings.ToLower(path), "/") - want = strings.Trim(strings.ToLower(strings.TrimSpace(want)), "/") - if path == "" || want == "" { - return false - } - return path == want || strings.HasSuffix(path, "/"+want) -} - -func nexusPHPTitleFromLink(link nexusPHPLink) string { - for _, attr := range []string{"title", "data-title"} { - if value := htmlAttr(link.attrs, attr); value != "" { - return value - } - } - return link.text -} - -func nexusPHPSubtitle(row string) string { - for _, pattern := range []*regexp.Regexp{ - regexp.MustCompile(`(?is)]*(?:class|id)\s*=\s*["'][^"']*(?:subtitle|small_descr|descr|sub)[^"']*["'][^>]*>(.*?)`), - regexp.MustCompile(`(?is)]*(?:class|id)\s*=\s*["'][^"']*(?:subtitle|small_descr|descr|sub)[^"']*["'][^>]*>(.*?)`), - } { - if match := pattern.FindStringSubmatch(row); len(match) >= 2 { - return cleanNexusPHPText(match[1]) - } - } - return "" -} - -func nexusPHPRowLabels(row string) string { - labels := make([]string, 0, 4) - lower := strings.ToLower(row) - add := func(label string) { - for _, existing := range labels { - if existing == label { - return - } - } - labels = append(labels, label) - } - if nexusPHPFreeLabelRE.MatchString(row) { - add("free") - } - if strings.Contains(lower, "hit and run") || strings.Contains(lower, "hit&run") || strings.Contains(lower, "h&r") || - nexusPHPRiskLabelRE.MatchString(row) || - strings.Contains(row, "禁转") || strings.Contains(row, "禁止转载") || strings.Contains(row, "禁下") || strings.Contains(row, "禁止下载") { - add("HR") - } - return strings.Join(labels, " ") -} - -func nexusPHPIntByClass(row, className string) (int, bool) { - pattern := regexp.MustCompile(`(?is)]*(?:class|id)\s*=\s*["'][^"']*` + regexp.QuoteMeta(className) + `[^"']*["'][^>]*>(.*?)`) - if match := pattern.FindStringSubmatch(row); len(match) >= 2 { - text := cleanNexusPHPText(match[1]) - valueMatch := regexp.MustCompile(`\d+`).FindString(text) - if valueMatch != "" { - value, _ := strconv.Atoi(valueMatch) - return value, true - } - } - return 0, false -} - -func htmlAttr(attrs, name string) string { - pattern := regexp.MustCompile(`(?is)\b` + regexp.QuoteMeta(name) + `\s*=\s*["']([^"']*)["']`) - if match := pattern.FindStringSubmatch(attrs); len(match) >= 2 { - return cleanNexusPHPText(match[1]) - } - return "" -} - -func cleanNexusPHPText(value string) string { - return strings.Join(strings.Fields(html.UnescapeString(stripHTML(value))), " ") -} - -func resolveSiteURL(baseURL, href string) string { - base, err := url.Parse(strings.TrimRight(baseURL, "/") + "/") - if err != nil { - return strings.TrimSpace(href) - } - ref, err := url.Parse(strings.TrimSpace(href)) - if err != nil { - return strings.TrimSpace(href) - } - return base.ResolveReference(ref).String() -} diff --git a/internal/service/site_adapter_parse.go b/internal/service/site_adapter_parse.go deleted file mode 100644 index 8e25c50..0000000 --- a/internal/service/site_adapter_parse.go +++ /dev/null @@ -1,51 +0,0 @@ -package service - -import ( - "regexp" - "strconv" - "strings" -) - -func mteamCodeOK(code any) bool { - codeStr := mteamCodeString(code) - return codeStr == "0" || codeStr == "200" -} - -func mteamCodeString(code any) string { - switch v := code.(type) { - case string: - return strings.TrimSpace(v) - case float64: - return strconv.Itoa(int(v)) - case int: - return strconv.Itoa(v) - default: - return "" - } -} - -// parseSizeString 将带单位的字符串转换为字节数。 -func parseSizeString(value string, unit string) int64 { - v, err := strconv.ParseFloat(value, 64) - if err != nil { - return 0 - } - switch strings.TrimSuffix(strings.ToLower(unit), "i") { - case "kb": - return int64(v * 1024) - case "mb": - return int64(v * 1024 * 1024) - case "gb": - return int64(v * 1024 * 1024 * 1024) - case "tb": - return int64(v * 1024 * 1024 * 1024 * 1024) - default: - return int64(v) - } -} - -// stripHTML 移除 HTML 标签。 -func stripHTML(s string) string { - re := regexp.MustCompile(`<[^>]*>`) - return re.ReplaceAllString(s, "") -} diff --git a/internal/service/site_adapter_test.go b/internal/service/site_adapter_test.go deleted file mode 100644 index ae32043..0000000 --- a/internal/service/site_adapter_test.go +++ /dev/null @@ -1,448 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestMTeamAuthenticateRequiresAPIKey(t *testing.T) { - adapter := NewMTeamAdapter() - err := adapter.Authenticate(context.Background(), SiteConfig{ - URL: "https://api.m-team.cc", - AuthType: "api_key", - }) - if err == nil || !strings.Contains(err.Error(), "API Access Token") { - t.Fatalf("Authenticate error = %v, want API Access Token hint", err) - } -} - -func TestMTeamAuthenticateUsesOpenAPIKeyHeader(t *testing.T) { - var gotPath string - var gotKey string - var gotPayload map[string]any - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - gotKey = r.Header.Get("x-api-key") - if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil { - t.Fatalf("decode request body: %v", err) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`)) - })) - defer server.Close() - - adapter := NewMTeamAdapter() - err := adapter.Authenticate(context.Background(), SiteConfig{ - URL: server.URL, - AuthType: "api_key", - APIKey: "token-123", - Timeout: 5 * time.Second, - }) - if err != nil { - t.Fatalf("Authenticate returned error: %v", err) - } - if gotPath != "/api/torrent/search" { - t.Fatalf("path = %q, want /api/torrent/search", gotPath) - } - if gotKey != "token-123" { - t.Fatalf("x-api-key = %q, want token-123", gotKey) - } - if gotPayload["mode"] != "all" || gotPayload["keyword"] != nil { - t.Fatalf("payload = %#v, want mode all without keyword probe", gotPayload) - } -} - -func TestMTeamAuthenticateReportsAPIMessage(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":1,"message":"key無效","data":null}`)) - })) - defer server.Close() - - adapter := NewMTeamAdapter() - err := adapter.Authenticate(context.Background(), SiteConfig{ - URL: server.URL, - AuthType: "api_key", - APIKey: "bad-token", - Timeout: 5 * time.Second, - }) - if err == nil || !strings.Contains(err.Error(), "key無效") { - t.Fatalf("Authenticate error = %v, want key invalid message", err) - } -} - -func TestMTeamAuthenticateHonorsConfiguredTimeout(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(2 * time.Second) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`)) - })) - defer server.Close() - - adapter := NewMTeamAdapter() - started := time.Now() - err := adapter.Authenticate(context.Background(), SiteConfig{ - URL: server.URL, - AuthType: "api_key", - APIKey: "token-123", - Timeout: time.Second, - }) - if err == nil { - t.Fatal("Authenticate error = nil, want timeout") - } - if elapsed := time.Since(started); elapsed >= 1500*time.Millisecond { - t.Fatalf("Authenticate elapsed = %s, want configured timeout to stop before upstream response", elapsed) - } - if !strings.Contains(err.Error(), "M-Team API request timed out") { - t.Fatalf("Authenticate error = %v, want M-Team timeout hint", err) - } -} - -func TestAPISiteDefaultTimeoutIsRaised(t *testing.T) { - if got := siteRequestTimeout("mteam", 15); got != 45*time.Second { - t.Fatalf("mteam timeout = %s, want 45s", got) - } - if got := siteRequestTimeout("yemapt", 0); got != 45*time.Second { - t.Fatalf("yemapt timeout = %s, want 45s", got) - } - if got := siteRequestTimeout("nexusphp", 15); got != 15*time.Second { - t.Fatalf("nexusphp timeout = %s, want 15s", got) - } - if got := siteRequestTimeout("mteam", 60); got != 60*time.Second { - t.Fatalf("custom mteam timeout = %s, want 60s", got) - } -} - -func TestYemaPTAuthenticateUsesAuthorizationHeader(t *testing.T) { - var gotPath string - var gotAuth string - var gotXAPIKey string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - gotAuth = r.Header.Get("Authorization") - gotXAPIKey = r.Header.Get("x-api-key") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true,"showType":0,"data":{"id":10,"name":"tester"}}`)) - })) - defer server.Close() - - adapter := NewYemaPTAdapter() - err := adapter.Authenticate(context.Background(), SiteConfig{ - Type: "yemapt", - URL: server.URL, - AuthType: "api_key", - APIKey: "auth-123", - Timeout: 5 * time.Second, - }) - if err != nil { - t.Fatalf("Authenticate returned error: %v", err) - } - if gotPath != "/openApi/user/fetchBasicInfo.json" { - t.Fatalf("path = %q, want /openApi/user/fetchBasicInfo.json", gotPath) - } - if gotAuth != "auth-123" { - t.Fatalf("Authorization = %q, want auth-123", gotAuth) - } - if gotXAPIKey != "" { - t.Fatalf("x-api-key = %q, want empty", gotXAPIKey) - } -} - -func TestYemaPTAuthenticateReportsAPIMessage(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":false,"errorCode":403,"errorMessage":"need api auth"}`)) - })) - defer server.Close() - - adapter := NewYemaPTAdapter() - err := adapter.Authenticate(context.Background(), SiteConfig{ - Type: "yemapt", - URL: server.URL, - AuthType: "api_key", - APIKey: "bad-auth", - Timeout: 5 * time.Second, - }) - if err == nil || !strings.Contains(err.Error(), "need api auth") { - t.Fatalf("Authenticate error = %v, want need api auth", err) - } -} - -func TestNewSiteAdapterDetectsYemaPTURL(t *testing.T) { - adapter := NewSiteAdapter(&model.Site{ - Type: "nexusphp", - URL: "https://www.yemapt.org", - }) - if _, ok := adapter.(*YemaPTAdapter); !ok { - t.Fatalf("adapter = %T, want *YemaPTAdapter", adapter) - } -} - -func TestBuildRequestAPIKeyHeaderBySite(t *testing.T) { - yemaReq, err := buildRequest(context.Background(), http.MethodGet, "https://www.yemapt.org/openApi/user/fetchBasicInfo.json", SiteConfig{ - Type: "yemapt", - URL: "https://www.yemapt.org", - AuthType: "api_key", - APIKey: "yema-auth", - }, nil) - if err != nil { - t.Fatal(err) - } - if got := yemaReq.Header.Get("Authorization"); got != "yema-auth" { - t.Fatalf("YemaPT Authorization = %q, want yema-auth", got) - } - if got := yemaReq.Header.Get("x-api-key"); got != "" { - t.Fatalf("YemaPT x-api-key = %q, want empty", got) - } - - mteamReq, err := buildRequest(context.Background(), http.MethodGet, "https://api.m-team.cc/api/torrent/search", SiteConfig{ - Type: "mteam", - URL: "https://api.m-team.cc", - AuthType: "api_key", - APIKey: "mteam-auth", - }, nil) - if err != nil { - t.Fatal(err) - } - if got := mteamReq.Header.Get("x-api-key"); got != "mteam-auth" { - t.Fatalf("M-Team x-api-key = %q, want mteam-auth", got) - } - if got := mteamReq.Header.Get("Authorization"); got != "" { - t.Fatalf("M-Team Authorization = %q, want empty", got) - } -} - -func TestNexusPHPSearchUsesSearchstr(t *testing.T) { - var gotQuery string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotQuery = r.URL.RawQuery - _, _ = w.Write([]byte(`
测试资源下载
`)) - })) - defer server.Close() - - adapter := NewNexusPHPAdapter() - result, err := adapter.Search(t.Context(), SiteConfig{ - Name: "Nexus", - URL: server.URL, - AuthType: "cookie", - Cookie: "uid=1; pass=token", - Timeout: 5 * time.Second, - }, "测试", 2) - if err != nil { - t.Fatalf("Search returned error: %v", err) - } - values, err := url.ParseQuery(gotQuery) - if err != nil { - t.Fatal(err) - } - if values.Get("searchstr") != "测试" || values.Get("search") != "测试" || values.Get("page") != "2" { - t.Fatalf("query = %q", gotQuery) - } - if len(result.Items) != 1 || result.Items[0].Title != "测试资源" { - t.Fatalf("items = %#v", result.Items) - } -} - -func TestNexusPHPSearchReportsExpiredCookieLoginPage(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`
`)) - })) - defer server.Close() - - adapter := NewNexusPHPAdapter() - _, err := adapter.Search(t.Context(), SiteConfig{ - Name: "Nexus", - URL: server.URL, - AuthType: "cookie", - Cookie: "uid=1; pass=expired", - Timeout: 5 * time.Second, - }, "测试", 1) - if err == nil || !strings.Contains(err.Error(), "cookie expired") { - t.Fatalf("Search error = %v, want cookie expired hint", err) - } -} - -func TestParseNexusPHPHTMLModernRows(t *testing.T) { - page := ` - - - - - - - - - -
电影 - ignored - 副标题 & 描述 - 下载 - 12.5 GiB33499
` - result, err := parseNexusPHPHTML(page, "Nexus", "https://pt.example") - if err != nil { - t.Fatal(err) - } - if len(result.Items) != 1 { - t.Fatalf("items = %#v", result.Items) - } - item := result.Items[0] - if item.ID != "456" || item.Title != "Some & Movie 2026 2160p" || item.Subtitle != "副标题 & 描述" { - t.Fatalf("parsed item = %#v", item) - } - if item.DetailURL != "https://pt.example/details.php?id=456&hit=1" || item.DownloadURL != "https://pt.example/download.php?id=456&passkey=abc" { - t.Fatalf("urls = detail %q download %q", item.DetailURL, item.DownloadURL) - } - if item.Seeders != 33 || item.Leechers != 4 || item.Snatched != 99 { - t.Fatalf("stats = %#v", item) - } -} - -func TestParseNexusPHPHTMLCapturesRiskAndPromotionLabels(t *testing.T) { - page := ` - - - - - - -
Some Show免费H&R下载
` - result, err := parseNexusPHPHTML(page, "Nexus", "https://pt.example") - if err != nil { - t.Fatal(err) - } - if len(result.Items) != 1 { - t.Fatalf("items = %#v", result.Items) - } - item := result.Items[0] - if !item.Free { - t.Fatalf("item.Free = false, want free promotion detected: %#v", item) - } - if !strings.Contains(item.Labels, "HR") || !strings.Contains(item.Labels, "free") { - t.Fatalf("labels = %q, want HR and free", item.Labels) - } -} - -func TestParseNexusPHPHTMLIgnoresUserDetailsLinks(t *testing.T) { - page := ` - - - -
shukBeta15.5 GiB
问心下载1.5 GiB
` - result, err := parseNexusPHPHTML(page, "Nexus", "https://pt.example") - if err != nil { - t.Fatal(err) - } - if len(result.Items) != 1 { - t.Fatalf("items = %#v, want only real torrent details row", result.Items) - } - if result.Items[0].ID != "789" || result.Items[0].Title != "问心 S01 1080p" { - t.Fatalf("parsed item = %#v", result.Items[0]) - } -} - -func TestMTeamAPIRateLimits(t *testing.T) { - search := mteamAPIRateLimits(mteamAPIEndpointSearch) - if len(search) != 1 || search[0].Limit != 1500 || search[0].Window != 24*time.Hour { - t.Fatalf("search limits = %#v, want 1500/24h", search) - } - detail := mteamAPIRateLimits(mteamAPIEndpointDetail) - if len(detail) != 1 || detail[0].Limit != 100 || detail[0].Window != time.Hour { - t.Fatalf("detail limits = %#v, want 100/1h", detail) - } - download := mteamAPIRateLimits(mteamAPIEndpointDownload) - if len(download) != 2 || - download[0].Limit != 100 || download[0].Window != time.Hour || - download[1].Limit != 1000 || download[1].Window != 24*time.Hour { - t.Fatalf("download limits = %#v, want 100/1h and 1000/24h", download) - } -} - -func TestPersistentSiteAPIRateLimiterPersistsSlidingWindow(t *testing.T) { - db := newServiceTestDB(t, &model.Setting{}) - repos := repository.New(db) - now := time.Date(2026, 6, 20, 12, 0, 0, 0, time.UTC) - limiter := newPersistentSiteAPIRateLimiter(repos) - limiter.now = func() time.Time { return now } - limit := siteAPIRateLimit{Bucket: "test_1h", Limit: 2, Window: time.Hour} - - if err := limiter.Allow(t.Context(), "mteam:test", limit); err != nil { - t.Fatalf("first allow: %v", err) - } - if err := limiter.Allow(t.Context(), "mteam:test", limit); err != nil { - t.Fatalf("second allow: %v", err) - } - err := limiter.Allow(t.Context(), "mteam:test", limit) - var limited *siteAPIRateLimitError - if !errors.As(err, &limited) { - t.Fatalf("third allow error = %v, want siteAPIRateLimitError", err) - } - if limited.RetryAfter != time.Hour { - t.Fatalf("retry_after = %v, want 1h", limited.RetryAfter) - } - - restarted := newPersistentSiteAPIRateLimiter(repos) - restarted.now = func() time.Time { return now.Add(30 * time.Minute) } - if err := restarted.Allow(t.Context(), "mteam:test", limit); !errors.As(err, &limited) { - t.Fatalf("restarted allow error = %v, want persisted limit", err) - } - - restarted.now = func() time.Time { return now.Add(time.Hour + time.Second) } - if err := restarted.Allow(t.Context(), "mteam:test", limit); err != nil { - t.Fatalf("allow after window: %v", err) - } -} - -func TestMTeamRateLimitStopsRequestBeforeHTTP(t *testing.T) { - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests.Add(1) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`)) - })) - defer server.Close() - - adapter := NewMTeamAdapter() - limiter := &staticSiteAPIRateLimiter{err: &siteAPIRateLimitError{ - Bucket: "torrent_search_24h", - Limit: 1000, - Window: 24 * time.Hour, - RetryAfter: time.Hour, - }} - _, err := adapter.Search(t.Context(), SiteConfig{ - URL: server.URL, - AuthType: "api_key", - APIKey: "token-123", - Timeout: 5 * time.Second, - rateLimiter: limiter, - }, "show", 1) - if err == nil || !strings.Contains(err.Error(), "rate limit") { - t.Fatalf("Search error = %v, want rate limit", err) - } - if got := requests.Load(); got != 0 { - t.Fatalf("HTTP requests = %d, want 0", got) - } - if limiter.calls != 1 { - t.Fatalf("limiter calls = %d, want 1", limiter.calls) - } -} - -type staticSiteAPIRateLimiter struct { - err error - calls int -} - -func (l *staticSiteAPIRateLimiter) Allow(context.Context, string, ...siteAPIRateLimit) error { - l.calls++ - return l.err -} diff --git a/internal/service/site_adapter_unit3d.go b/internal/service/site_adapter_unit3d.go deleted file mode 100644 index f99fb75..0000000 --- a/internal/service/site_adapter_unit3d.go +++ /dev/null @@ -1,198 +0,0 @@ -// Package service — UNIT3D site adapter. -package service - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "time" -) - -// ─── UNIT3D 适配器 ─────────────────────────────────────────────────────────── - -// UNIT3DAdapter UNIT3D 框架适配器。 -type UNIT3DAdapter struct { - client *http.Client -} - -// NewUNIT3DAdapter 创建 UNIT3D 适配器。 -func NewUNIT3DAdapter() *UNIT3DAdapter { - return &UNIT3DAdapter{ - client: &http.Client{Timeout: 30 * time.Second}, - } -} - -func (a *UNIT3DAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { - u := cfg.URL + "/api/torrents?limit=1" - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return fmt.Errorf("authenticate: %w", err) - } - if status == http.StatusUnauthorized || status == http.StatusForbidden { - return fmt.Errorf("authentication failed: status %d", status) - } - if status != http.StatusOK { - return fmt.Errorf("authenticate failed: status %d", status) - } - var resp map[string]interface{} - if err := json.Unmarshal(data, &resp); err == nil { - if errMsg, ok := resp["message"].(string); ok { - return fmt.Errorf("authentication failed: %s", errMsg) - } - } - return nil -} - -func (a *UNIT3DAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) { - params := url.Values{} - params.Set("search", keyword) - params.Set("page", strconv.Itoa(page)) - - u := cfg.URL + "/api/torrents?" + params.Encode() - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("search: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("search failed: status %d", status) - } - - return parseUNIT3DJSON(data, cfg.Name, cfg.URL) -} - -func (a *UNIT3DAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) { - params := url.Values{} - if category != "" { - params.Set("category", category) - } - params.Set("page", strconv.Itoa(page)) - - u := cfg.URL + "/api/torrents?" + params.Encode() - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("browse: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("browse failed: status %d", status) - } - - return parseUNIT3DJSON(data, cfg.Name, cfg.URL) -} - -func (a *UNIT3DAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) { - u := cfg.URL + "/api/torrents/" + id - data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil) - if err != nil { - return nil, fmt.Errorf("detail: %w", err) - } - if status != http.StatusOK { - return nil, fmt.Errorf("detail failed: status %d", status) - } - - var torrent map[string]interface{} - if err := json.Unmarshal(data, &torrent); err != nil { - return nil, fmt.Errorf("parse response: %w", err) - } - - detail := &TorrentDetail{ - ID: id, - DetailURL: cfg.URL + "/torrents/" + id, - } - - if v, ok := torrent["name"].(string); ok { - detail.Title = v - } - if v, ok := torrent["description"].(string); ok { - detail.Description = stripHTML(v) - } - if v, ok := torrent["size"].(float64); ok { - detail.Size = int64(v) - } - if v, ok := torrent["seeders"].(float64); ok { - detail.Seeders = int(v) - } - if v, ok := torrent["leechers"].(float64); ok { - detail.Leechers = int(v) - } - if v, ok := torrent["times_completed"].(float64); ok { - detail.Snatched = int(v) - } - if v, ok := torrent["free"].(bool); ok { - detail.Free = v - } - if v, ok := torrent["info_hash"].(string); ok { - detail.InfoHash = v - } - - detail.DownloadURL = cfg.URL + "/api/torrents/" + id + "/download" - return detail, nil -} - -func (a *UNIT3DAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) { - return cfg.URL + "/api/torrents/" + id + "/download", nil -} - -// parseUNIT3DJSON 解析 UNIT3D JSON 响应。 -func parseUNIT3DJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) { - var resp struct { - Data []map[string]interface{} `json:"data"` - Meta struct { - Total int `json:"total"` - CurrentPage int `json:"current_page"` - } `json:"meta"` - } - if err := json.Unmarshal(data, &resp); err != nil { - return nil, fmt.Errorf("parse JSON: %w", err) - } - - result := &SiteSearchResult{ - SiteName: siteName, - Items: []TorrentItem{}, - Page: resp.Meta.CurrentPage, - Total: resp.Meta.Total, - } - - for _, t := range resp.Data { - item := TorrentItem{} - if v, ok := t["id"].(float64); ok { - item.ID = strconv.Itoa(int(v)) - } - if v, ok := t["name"].(string); ok { - item.Title = v - } - if v, ok := t["category"].(map[string]interface{}); ok { - if name, ok := v["name"].(string); ok { - item.Category = name - } - } - if v, ok := t["size"].(float64); ok { - item.Size = int64(v) - } - if v, ok := t["seeders"].(float64); ok { - item.Seeders = int(v) - } - if v, ok := t["leechers"].(float64); ok { - item.Leechers = int(v) - } - if v, ok := t["times_completed"].(float64); ok { - item.Snatched = int(v) - } - if v, ok := t["free"].(bool); ok { - item.Free = v - } - if v, ok := t["created_at"].(string); ok { - if t2, err := time.Parse(time.RFC3339, v); err == nil { - item.UploadTime = t2 - } - } - - item.DetailURL = baseURL + "/torrents/" + item.ID - item.DownloadURL = baseURL + "/api/torrents/" + item.ID + "/download" - result.Items = append(result.Items, item) - } - - return result, nil -} diff --git a/internal/service/site_adapter_yemapt.go b/internal/service/site_adapter_yemapt.go deleted file mode 100644 index d7082f1..0000000 --- a/internal/service/site_adapter_yemapt.go +++ /dev/null @@ -1,92 +0,0 @@ -// Package service — YemaPT site adapter. -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "strings" - "time" -) - -type YemaPTAdapter struct { - client *http.Client -} - -func NewYemaPTAdapter() *YemaPTAdapter { - return &YemaPTAdapter{ - client: &http.Client{Timeout: 30 * time.Second}, - } -} - -func (a *YemaPTAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { - if strings.TrimSpace(cfg.APIKey) == "" { - return errors.New("YemaPT 需要填写个人详情页创建的第三方对接专用 API Auth Key") - } - u := strings.TrimRight(cfg.URL, "/") + "/openApi/user/fetchBasicInfo.json" - data, status, err := doRequestJSON(ctx, a.client, http.MethodGet, u, cfg, nil) - if err != nil { - return fmt.Errorf("yemapt authenticate: %w", err) - } - if status != http.StatusOK { - return fmt.Errorf("yemapt authenticate failed: status %d", status) - } - var resp yemaPTAPIResponse - if err := json.Unmarshal(data, &resp); err != nil { - return fmt.Errorf("yemapt authenticate parse: %w", err) - } - if resp.Success { - return nil - } - if resp.ErrorMessage != "" { - return fmt.Errorf("yemapt authenticate failed: %s", resp.ErrorMessage) - } - if resp.ErrorCode != 0 { - return fmt.Errorf("yemapt authenticate failed: errorCode=%d", resp.ErrorCode) - } - return errors.New("yemapt authenticate failed") -} - -func (a *YemaPTAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) { - return nil, errYemaPTTorrentOpenAPIUnsupported() -} - -func (a *YemaPTAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) { - return nil, errYemaPTTorrentOpenAPIUnsupported() -} - -func (a *YemaPTAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) { - return nil, errYemaPTTorrentOpenAPIUnsupported() -} - -func (a *YemaPTAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) { - return "", errYemaPTTorrentOpenAPIUnsupported() -} - -type yemaPTAPIResponse struct { - Success bool `json:"success"` - ShowType int `json:"showType"` - ErrorCode int `json:"errorCode"` - ErrorMessage string `json:"errorMessage"` - Data json.RawMessage `json:"data"` -} - -func errYemaPTTorrentOpenAPIUnsupported() error { - return errors.New("YemaPT 当前公开 OpenAPI 未提供种子搜索/详情/下载接口") -} - -func isYemaPTConfig(cfg SiteConfig) bool { - return strings.EqualFold(strings.TrimSpace(cfg.Type), "yemapt") || isYemaPTURL(cfg.URL) -} - -func isYemaPTURL(raw string) bool { - u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil { - return false - } - host := strings.ToLower(u.Hostname()) - return host == "yemapt.org" || strings.HasSuffix(host, ".yemapt.org") -} diff --git a/internal/service/site_connection.go b/internal/service/site_connection.go deleted file mode 100644 index 7ecd469..0000000 --- a/internal/service/site_connection.go +++ /dev/null @@ -1,99 +0,0 @@ -package service - -import ( - "context" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/helper" - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// TestConnection tries to reach the site's base URL with the configured -// credentials and reports success/failure. -// -// 测试逻辑(与旧版参考实现对齐): -// -// 1. 优先调用对应站点适配器的 Authenticate(),让 PT 站点(M-Team / UNIT3D / -// Gazelle 等)使用各自的开放 API 验证,而不是去拉首页 HTML——后者通常 -// 被 Cloudflare 直接 403 但 API 能正常访问。 -// 2. 适配器不可用或站点类型未知时,回退到 helper.TestSiteConnectivity 的 -// 通用浏览器头 GET 方案。 -// 3. helper.TestSiteConnectivity 在全局 FlareSolverr 启用且站点开启了 -// BrowserEmulation 时,会自动走 FlareSolverr。 -func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, string, error) { - site, err := s.FindByID(ctx, id) - if err != nil || site == nil { - return false, "site not found", err - } - - flareSolverrURL := s.flareSolverrURL - - // ── Path 1: site-aware adapter Authenticate ──────────────────────── - // custom_rss 没有真适配器,跳过;其它类型先尝试针对性认证端点。 - if adapter := NewSiteAdapter(site); adapter != nil && site.Type != "" && site.Type != "custom_rss" { - cfg := s.siteModelToConfig(site) - actx, cancel := context.WithTimeout(ctx, cfg.Timeout) - defer cancel() - if authErr := adapter.Authenticate(actx, cfg); authErr == nil { - now := time.Now() - _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). - Updates(map[string]any{ - "login_status": "ok", - "last_error": "", - "last_check_at": &now, - }).Error - return true, "连接成功", nil - } else { - if site.Type == "mteam" || site.Type == "yemapt" || isYemaPTURL(site.URL) { - s.log.Warn("site adapter authenticate failed", - zap.String("site", site.Name), - zap.String("type", site.Type), - zap.Error(authErr)) - now := time.Now() - _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). - Updates(map[string]any{ - "login_status": "fail", - "last_error": authErr.Error(), - "last_check_at": &now, - }).Error - return false, authErr.Error(), nil - } - s.log.Warn("site adapter authenticate failed, falling back to generic test", - zap.String("site", site.Name), - zap.String("type", site.Type), - zap.Error(authErr)) - // 回退到通用 GET 测试 — 给 Cookie/RSS 类站点一个机会 - } - } - - // ── Path 2: generic GET with browser headers / FlareSolverr ─────── - timeout := int(siteRequestTimeout(site.Type, site.Timeout).Seconds()) - ok, msg, err := helper.TestSiteConnectivity(site, flareSolverrURL, timeout, s.log) - if err != nil { - now := time.Now() - _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). - Updates(map[string]any{ - "login_status": "fail", - "last_error": err.Error(), - "last_check_at": &now, - }).Error - return false, err.Error(), nil - } - - loginStatus := "ok" - storedError := "" - if !ok { - loginStatus = "fail" - storedError = msg - } - now := time.Now() - _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). - Updates(map[string]any{ - "login_status": loginStatus, - "last_error": storedError, - "last_check_at": &now, - }).Error - return ok, msg, nil -} diff --git a/internal/service/site_crud.go b/internal/service/site_crud.go deleted file mode 100644 index 46a2183..0000000 --- a/internal/service/site_crud.go +++ /dev/null @@ -1,161 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "strings" - "time" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// Create persists a new site. -func (s *SiteService) Create(ctx context.Context, site *model.Site) error { - if strings.TrimSpace(site.Name) == "" || strings.TrimSpace(site.URL) == "" { - return errors.New("name and url required") - } - site.URL = strings.TrimRight(site.URL, "/") - if site.Type == "" { - site.Type = "nexusphp" - } - if site.AuthType == "" { - site.AuthType = "cookie" - } - return s.repo.DB.WithContext(ctx).Create(site).Error -} - -// List returns every site ordered by created_at. -func (s *SiteService) List(ctx context.Context) ([]model.Site, error) { - var sites []model.Site - err := s.repo.DB.WithContext(ctx).Order("created_at asc").Find(&sites).Error - if sites == nil { - sites = []model.Site{} - } - return sites, err -} - -// FindByID returns a single site or nil. -func (s *SiteService) FindByID(ctx context.Context, id string) (*model.Site, error) { - var site model.Site - err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&site).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - return &site, err -} - -// siteUpdatableFields is the whitelist of columns that may be patched via -// the update endpoint. Fields like id, created_at, deleted_at, login_status, -// upload_bytes, download_bytes are excluded to prevent injection. -var siteUpdatableFields = map[string]bool{ - "name": true, - "url": true, - "type": true, - "auth_type": true, - "api_key": true, - "cookie": true, - "auth_header": true, - "user_agent": true, - "rss_url": true, - "timeout": true, - "priority": true, - "use_proxy": true, - "rate_limit": true, - "browser_emulation": true, - "downloader": true, - "enabled": true, - "is_default": true, - "extra": true, -} - -// Update applies a partial patch to an existing site. -func (s *SiteService) Update(ctx context.Context, id string, updates map[string]any) error { - if id == "" { - return errors.New("site id required") - } - filtered := make(map[string]any, len(updates)) - for k, v := range updates { - if siteUpdatableFields[k] { - filtered[k] = v - } - } - if len(filtered) == 0 { - return errors.New("no valid fields to update") - } - if raw, ok := filtered["url"].(string); ok { - filtered["url"] = strings.TrimRight(strings.TrimSpace(raw), "/") - } - for _, key := range []string{"api_key", "cookie", "auth_header"} { - if raw, ok := filtered[key].(string); ok && strings.TrimSpace(raw) == "" { - delete(filtered, key) - } - } - return s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).Updates(filtered).Error -} - -// Delete removes a site. -func (s *SiteService) Delete(ctx context.Context, id string) error { - return s.repo.DB.WithContext(ctx).Where("id = ?", id).Delete(&model.Site{}).Error -} - -// siteModelToConfig 将 model.Site 转换为适配器使用的 SiteConfig。 -// 当全局 FlareSolverr 已启用且此站点开启了 BrowserEmulation 时,填充 FlareSolverrURL。 -func (svc *SiteService) siteModelToConfig(s *model.Site) SiteConfig { - timeout := siteRequestTimeout(s.Type, s.Timeout) - userAgent := s.UserAgent - if userAgent == "" { - userAgent = model.DefaultUserAgent - } - var extra map[string]string - if s.Extra != "" { - _ = json.Unmarshal([]byte(s.Extra), &extra) - } - - // Per-site FlareSolverr opt-in: only when global FlareSolverr is enabled - // AND this site has BrowserEmulation turned on. - flareSolverrURL := "" - if svc.flareSolverrURL != "" && s.BrowserEmulation { - flareSolverrURL = svc.flareSolverrURL - } - - return SiteConfig{ - SiteID: s.ID, - Name: s.Name, - Type: s.Type, - URL: s.URL, - AuthType: s.AuthType, - Cookie: s.Cookie, - APIKey: s.APIKey, - AuthHeader: s.AuthHeader, - UserAgent: userAgent, - Timeout: timeout, - Extra: extra, - FlareSolverrURL: flareSolverrURL, - UseProxy: s.UseProxy, - RateLimit: s.RateLimit, - rateLimiter: svc.apiRateLimiter, - } -} - -func siteRequestTimeout(siteType string, timeoutSeconds int) time.Duration { - timeout := time.Duration(timeoutSeconds) * time.Second - if timeout <= 0 { - timeout = 15 * time.Second - } - if isAPISiteType(siteType) && timeout <= 15*time.Second { - return 45 * time.Second - } - return timeout -} - -func isAPISiteType(siteType string) bool { - switch strings.ToLower(strings.TrimSpace(siteType)) { - case "mteam", "yemapt": - return true - default: - return false - } -} diff --git a/internal/service/site_download.go b/internal/service/site_download.go deleted file mode 100644 index 4e3bc74..0000000 --- a/internal/service/site_download.go +++ /dev/null @@ -1,179 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "io" - "mime" - "net/http" - "net/url" - "path" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// ResolveDownloadURL converts tracker-specific search result URLs into a URL -// that a downloader can fetch directly. M-Team, NexusPHP and similar sites -// often expose a signed/detail endpoint in search results; qBittorrent cannot -// call those APIs with the configured site credentials, so subscriptions need -// the same resolution path as the manual download button. -func (s *SiteService) ResolveDownloadURL(ctx context.Context, raw string) string { - if strings.TrimSpace(raw) == "" { - return raw - } - matched := s.matchSiteForURL(ctx, raw) - if matched == nil { - return raw - } - - u, err := url.Parse(raw) - if err != nil || u.Host == "" { - return raw - } - id := u.Query().Get("id") - if id == "" { - return raw - } - adapter := GetAdapterForType(matched.Type) - if adapter == nil { - return raw - } - cfg := s.siteModelToConfig(matched) - timeout := cfg.Timeout - if timeout <= 0 { - timeout = 15 * time.Second - } - resolveCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - resolved, err := adapter.GetDownloadURL(resolveCtx, cfg, id) - if err != nil || resolved == "" { - if s.log != nil { - s.log.Warn("resolve PT download URL failed", - zap.String("site", matched.Name), - zap.String("raw", redactSensitiveDownloadURL(raw)), - zap.Error(err)) - } - return raw - } - return resolved -} - -func redactSensitiveDownloadURL(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if strings.HasPrefix(strings.ToLower(raw), "magnet:") { - return "magnet:?xt=***" - } - u, err := url.Parse(raw) - if err != nil || u.Host == "" { - return "[redacted-download-url]" - } - u.RawQuery = "" - u.Fragment = "" - return u.String() -} - -func (s *SiteService) FetchTorrentFile(ctx context.Context, raw string) ([]byte, string, error) { - parsed, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { - return nil, "", errors.New("no matching PT site for torrent URL") - } - matched := s.matchSiteForURL(ctx, raw) - cfg := SiteConfig{Timeout: 30 * time.Second} - if matched != nil { - cfg = s.siteModelToConfig(matched) - } - timeout := cfg.Timeout - if timeout <= 0 { - timeout = 30 * time.Second - } - req, err := buildRequest(ctx, http.MethodGet, raw, cfg, nil) - if err != nil { - return nil, "", err - } - req.Header.Set("Accept", "application/x-bittorrent,application/octet-stream,*/*") - client := newHTTPClient(cfg, timeout) - resp, err := client.Do(req) - if err != nil { - return nil, "", err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, "", fmt.Errorf("torrent fetch: HTTP %d", resp.StatusCode) - } - const maxTorrentSize = 32 << 20 - data, err := io.ReadAll(io.LimitReader(resp.Body, maxTorrentSize+1)) - if err != nil { - return nil, "", err - } - if len(data) == 0 { - return nil, "", errors.New("torrent fetch: empty body") - } - if len(data) > maxTorrentSize { - return nil, "", errors.New("torrent fetch: body too large") - } - if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") { - return nil, "", errors.New("torrent fetch: upstream returned HTML") - } - if torrentInfoHash(data) == "" { - return nil, "", errors.New("torrent fetch: upstream did not return a valid torrent") - } - return data, torrentFilename(raw, resp.Header.Get("Content-Disposition")), nil -} - -func (s *SiteService) matchSiteForURL(ctx context.Context, raw string) *model.Site { - u, err := url.Parse(raw) - if err != nil || u.Host == "" { - return nil - } - host := strings.ToLower(u.Host) - - sites, err := s.List(ctx) - if err != nil || len(sites) == 0 { - return nil - } - for i := range sites { - if siteHostMatches(host, sites[i].URL) || siteHostMatches(host, sites[i].RSSURL) { - return &sites[i] - } - } - return nil -} - -func siteHostMatches(host, raw string) bool { - if raw == "" { - return false - } - u, err := url.Parse(raw) - if err != nil || u.Host == "" { - return false - } - siteHost := strings.ToLower(u.Host) - return strings.EqualFold(siteHost, host) || strings.HasSuffix(host, "."+siteHost) -} - -func torrentFilename(rawURL, disposition string) string { - if disposition != "" { - if _, params, err := mime.ParseMediaType(disposition); err == nil { - if filename := strings.TrimSpace(params["filename"]); filename != "" { - return filename - } - } - } - if u, err := url.Parse(rawURL); err == nil { - if name := strings.TrimSpace(path.Base(u.Path)); name != "" && name != "." && name != "/" { - if !strings.HasSuffix(strings.ToLower(name), ".torrent") { - name += ".torrent" - } - return name - } - } - return "download.torrent" -} diff --git a/internal/service/site_rate_limiter.go b/internal/service/site_rate_limiter.go deleted file mode 100644 index 322cde0..0000000 --- a/internal/service/site_rate_limiter.go +++ /dev/null @@ -1,280 +0,0 @@ -package service - -import ( - "context" - "crypto/sha1" - "encoding/hex" - "encoding/json" - "fmt" - "sort" - "strings" - "sync" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -const ( - mteamAPIEndpointSearch = "torrent_search" - mteamAPIEndpointDetail = "torrent_detail" - mteamAPIEndpointDownload = "torrent_download" - - mteamAPISearchDailyLimit = 1500 -) - -type siteAPIRateLimit struct { - Bucket string - Limit int - Window time.Duration -} - -type siteAPIRateLimiter interface { - Allow(ctx context.Context, siteKey string, limits ...siteAPIRateLimit) error -} - -type siteAPIRateLimitError struct { - SiteKey string - Bucket string - Limit int - Window time.Duration - RetryAfter time.Duration -} - -func (e *siteAPIRateLimitError) Error() string { - if e == nil { - return "" - } - return fmt.Sprintf("M-Team API rate limit reached for %s: %d requests per %s, retry after %s", - e.Bucket, e.Limit, formatRateLimitDuration(e.Window), formatRateLimitDuration(e.RetryAfter)) -} - -type persistentSiteAPIRateLimiter struct { - repo *repository.Container - fallback *memorySiteAPIRateLimiter - now func() time.Time - mu sync.Mutex -} - -func newPersistentSiteAPIRateLimiter(repo *repository.Container) *persistentSiteAPIRateLimiter { - return &persistentSiteAPIRateLimiter{ - repo: repo, - fallback: newMemorySiteAPIRateLimiter(time.Now), - now: time.Now, - } -} - -func (l *persistentSiteAPIRateLimiter) Allow(ctx context.Context, siteKey string, limits ...siteAPIRateLimit) error { - if err := ctx.Err(); err != nil { - return err - } - if l == nil || l.repo == nil || l.repo.Setting == nil { - if l != nil && l.fallback != nil { - return l.fallback.Allow(ctx, siteKey, limits...) - } - return defaultMemorySiteAPIRateLimiter.Allow(ctx, siteKey, limits...) - } - l.mu.Lock() - defer l.mu.Unlock() - - now := time.Now() - if l.now != nil { - now = l.now() - } - records := make([]siteAPIRateLimitRecord, 0, len(limits)) - for _, limit := range normalizeSiteAPIRateLimits(limits) { - key := siteAPIRateLimitSettingKey(siteKey, limit.Bucket) - raw, err := l.repo.Setting.Get(ctx, key) - if err != nil { - return err - } - timestamps := pruneSiteAPIRateTimestamps(parseSiteAPIRateTimestamps(raw), now, limit.Window) - if err := checkSiteAPIRateLimit(siteKey, limit, timestamps, now); err != nil { - return err - } - records = append(records, siteAPIRateLimitRecord{key: key, timestamps: timestamps}) - } - nowUnix := now.Unix() - for _, record := range records { - next := append(record.timestamps, nowUnix) - if err := l.repo.Setting.Set(ctx, record.key, encodeSiteAPIRateTimestamps(next)); err != nil { - return err - } - } - return nil -} - -type memorySiteAPIRateLimiter struct { - now func() time.Time - mu sync.Mutex - buckets map[string][]int64 -} - -var defaultMemorySiteAPIRateLimiter = newMemorySiteAPIRateLimiter(time.Now) - -func newMemorySiteAPIRateLimiter(now func() time.Time) *memorySiteAPIRateLimiter { - if now == nil { - now = time.Now - } - return &memorySiteAPIRateLimiter{now: now, buckets: map[string][]int64{}} -} - -func (l *memorySiteAPIRateLimiter) Allow(ctx context.Context, siteKey string, limits ...siteAPIRateLimit) error { - if err := ctx.Err(); err != nil { - return err - } - if l == nil { - return nil - } - l.mu.Lock() - defer l.mu.Unlock() - - now := l.now() - records := make([]siteAPIRateLimitRecord, 0, len(limits)) - for _, limit := range normalizeSiteAPIRateLimits(limits) { - key := siteAPIRateLimitSettingKey(siteKey, limit.Bucket) - timestamps := pruneSiteAPIRateTimestamps(l.buckets[key], now, limit.Window) - if err := checkSiteAPIRateLimit(siteKey, limit, timestamps, now); err != nil { - return err - } - records = append(records, siteAPIRateLimitRecord{key: key, timestamps: timestamps}) - } - nowUnix := now.Unix() - for _, record := range records { - l.buckets[record.key] = append(record.timestamps, nowUnix) - } - return nil -} - -type siteAPIRateLimitRecord struct { - key string - timestamps []int64 -} - -func reserveMTeamAPIQuota(ctx context.Context, cfg SiteConfig, endpoint string) error { - limits := mteamAPIRateLimits(endpoint) - if len(limits) == 0 { - return nil - } - // M-Team's published API quotas are upstream hard limits, so protect them - // regardless of the generic per-site RateLimit toggle. - limiter := cfg.rateLimiter - if limiter == nil { - limiter = defaultMemorySiteAPIRateLimiter - } - return limiter.Allow(ctx, mteamAPIRateSiteKey(cfg), limits...) -} - -func mteamAPIRateLimits(endpoint string) []siteAPIRateLimit { - switch endpoint { - case mteamAPIEndpointSearch: - return []siteAPIRateLimit{{Bucket: "torrent_search_24h", Limit: mteamAPISearchDailyLimit, Window: 24 * time.Hour}} - case mteamAPIEndpointDetail: - return []siteAPIRateLimit{{Bucket: "torrent_detail_1h", Limit: 100, Window: time.Hour}} - case mteamAPIEndpointDownload: - return []siteAPIRateLimit{ - {Bucket: "torrent_download_1h", Limit: 100, Window: time.Hour}, - {Bucket: "torrent_download_24h", Limit: 1000, Window: 24 * time.Hour}, - } - default: - return nil - } -} - -func mteamAPIRateSiteKey(cfg SiteConfig) string { - base := strings.TrimRight(strings.ToLower(strings.TrimSpace(cfg.URL)), "/") - if base == "" { - base = "mteam" - } - if apiKey := strings.TrimSpace(cfg.APIKey); apiKey != "" { - sum := sha1.Sum([]byte(apiKey)) - return base + "|api:" + hex.EncodeToString(sum[:]) - } - if siteID := strings.TrimSpace(cfg.SiteID); siteID != "" { - return base + "|site:" + siteID - } - if name := strings.TrimSpace(cfg.Name); name != "" { - return base + "|name:" + strings.ToLower(name) - } - return base -} - -func normalizeSiteAPIRateLimits(limits []siteAPIRateLimit) []siteAPIRateLimit { - out := make([]siteAPIRateLimit, 0, len(limits)) - for _, limit := range limits { - limit.Bucket = strings.TrimSpace(limit.Bucket) - if limit.Bucket == "" || limit.Limit <= 0 || limit.Window <= 0 { - continue - } - out = append(out, limit) - } - return out -} - -func checkSiteAPIRateLimit(siteKey string, limit siteAPIRateLimit, timestamps []int64, now time.Time) error { - if len(timestamps) < limit.Limit { - return nil - } - oldest := time.Unix(timestamps[0], 0) - retryAfter := oldest.Add(limit.Window).Sub(now) - if retryAfter < time.Second { - retryAfter = time.Second - } - return &siteAPIRateLimitError{ - SiteKey: siteKey, - Bucket: limit.Bucket, - Limit: limit.Limit, - Window: limit.Window, - RetryAfter: retryAfter, - } -} - -func siteAPIRateLimitSettingKey(siteKey, bucket string) string { - sum := sha1.Sum([]byte(siteKey)) - return "site.api_rate." + hex.EncodeToString(sum[:])[:20] + "." + bucket -} - -func parseSiteAPIRateTimestamps(raw string) []int64 { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil - } - var out []int64 - if err := json.Unmarshal([]byte(raw), &out); err != nil { - return nil - } - return out -} - -func encodeSiteAPIRateTimestamps(values []int64) string { - data, _ := json.Marshal(values) - return string(data) -} - -func pruneSiteAPIRateTimestamps(values []int64, now time.Time, window time.Duration) []int64 { - if len(values) == 0 { - return nil - } - cutoff := now.Add(-window).Unix() - out := make([]int64, 0, len(values)) - for _, value := range values { - if value > cutoff && value <= now.Add(time.Minute).Unix() { - out = append(out, value) - } - } - sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) - return out -} - -func formatRateLimitDuration(value time.Duration) string { - if value < 0 { - value = 0 - } - value = value.Round(time.Second) - if value%time.Hour == 0 && value >= time.Hour { - return fmt.Sprintf("%dh", int(value/time.Hour)) - } - if value%time.Minute == 0 && value >= time.Minute { - return fmt.Sprintf("%dm", int(value/time.Minute)) - } - return value.String() -} diff --git a/internal/service/site_search.go b/internal/service/site_search.go deleted file mode 100644 index 53bd491..0000000 --- a/internal/service/site_search.go +++ /dev/null @@ -1,209 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "sort" - "strings" - "sync" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// SearchResult is one torrent returned by a site adapter search. -type SearchResult struct { - SiteName string `json:"site_name"` - SiteID string `json:"site_id"` - Title string `json:"title"` - Subtitle string `json:"subtitle,omitempty"` - Labels string `json:"labels,omitempty"` - TorrentURL string `json:"torrent_url"` - DownloadURL string `json:"download_url"` - Category string `json:"category,omitempty"` - SearchKeyword string `json:"search_keyword,omitempty"` - Size int64 `json:"size"` - Seeders int `json:"seeders"` - Leechers int `json:"leechers"` - Free bool `json:"free"` -} - -// Search fans out a keyword query to every enabled site and returns -// merged results sorted by seeders descending. -// Uses concurrent search with sync.WaitGroup for performance. -func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResult, error) { - if strings.TrimSpace(keyword) == "" { - return []SearchResult{}, nil - } - sites, err := s.List(ctx) - if err != nil { - return nil, err - } - - var ( - mu sync.Mutex - wg sync.WaitGroup - enabledCount int - failedCount int - failureErrs []error - failures []string - results []SearchResult - ) - - for i := range sites { - if !sites[i].Enabled { - continue - } - enabledCount++ - wg.Add(1) - go func(site model.Site) { - defer wg.Done() - - adapter := NewSiteAdapter(&site) - if adapter == nil { - mu.Lock() - failedCount++ - err := fmt.Errorf("%s: unsupported site type %s", site.Name, site.Type) - failureErrs = append(failureErrs, err) - failures = append(failures, err.Error()) - mu.Unlock() - return - } - - cfg := s.siteModelToConfig(&site) - timeout := cfg.Timeout - ctxWithTimeout, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - result, err := adapter.Search(ctxWithTimeout, cfg, keyword, 1) - if err != nil { - mu.Lock() - failedCount++ - failureErr := fmt.Errorf("%s: %w", site.Name, err) - failureErrs = append(failureErrs, failureErr) - failures = append(failures, failureErr.Error()) - mu.Unlock() - s.log.Warn("site search failed", - zap.String("site", site.Name), - zap.String("type", site.Type), - zap.String("url", site.URL), - zap.String("keyword", keyword), - zap.Duration("timeout", timeout), - zap.Error(err)) - return - } - if result == nil { - return - } - siteResults := siteSearchResultsFromItems(site, result, keyword) - mu.Lock() - results = append(results, siteResults...) - mu.Unlock() - }(sites[i]) - } - wg.Wait() - - // Ensure results is never nil (return [] instead of null in JSON) - if results == nil { - results = []SearchResult{} - } - - // Sort by seeders desc. - sort.Slice(results, func(i, j int) bool { - return results[i].Seeders > results[j].Seeders - }) - if s.log != nil { - s.log.Info("site search completed", - zap.String("keyword", keyword), - zap.Int("enabled_sites", enabledCount), - zap.Int("failed_sites", failedCount), - zap.Int("results_count", len(results))) - } - if enabledCount > 0 && failedCount >= enabledCount && len(results) == 0 { - if len(failureErrs) > 0 { - return results, fmt.Errorf("all enabled sites failed while searching %q: %w", keyword, errors.Join(failureErrs...)) - } - return results, fmt.Errorf("all enabled sites failed while searching %q: %s", keyword, strings.Join(failures, "; ")) - } - return results, nil -} - -// SearchSite runs a keyword search against one configured site, regardless of -// whether the site is enabled globally. This is used by per-site diagnostics in -// the management UI, where the user expects the selected site to be tested -// directly instead of a full fan-out followed by filtering. -func (s *SiteService) SearchSite(ctx context.Context, siteID, keyword string, page int) ([]SearchResult, error) { - if strings.TrimSpace(keyword) == "" { - return []SearchResult{}, nil - } - if page <= 0 { - page = 1 - } - site, err := s.FindByID(ctx, siteID) - if err != nil { - return nil, err - } - if site == nil { - return nil, fmt.Errorf("site not found") - } - adapter := NewSiteAdapter(site) - if adapter == nil { - return nil, fmt.Errorf("%s: unsupported site type %s", site.Name, site.Type) - } - cfg := s.siteModelToConfig(site) - timeout := cfg.Timeout - ctxWithTimeout, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - result, err := adapter.Search(ctxWithTimeout, cfg, keyword, page) - if err != nil { - if s.log != nil { - s.log.Warn("single site search failed", - zap.String("site", site.Name), - zap.String("type", site.Type), - zap.String("url", site.URL), - zap.String("keyword", keyword), - zap.Duration("timeout", timeout), - zap.Error(err)) - } - return nil, err - } - out := siteSearchResultsFromItems(*site, result, keyword) - sort.Slice(out, func(i, j int) bool { - return out[i].Seeders > out[j].Seeders - }) - if s.log != nil { - s.log.Info("single site search completed", - zap.String("site", site.Name), - zap.String("keyword", keyword), - zap.Int("results_count", len(out))) - } - return out, nil -} - -func siteSearchResultsFromItems(site model.Site, result *SiteSearchResult, keyword string) []SearchResult { - if result == nil || len(result.Items) == 0 { - return []SearchResult{} - } - out := make([]SearchResult, 0, len(result.Items)) - for _, item := range result.Items { - out = append(out, SearchResult{ - SiteName: site.Name, - SiteID: site.ID, - Title: item.Title, - Subtitle: item.Subtitle, - Labels: item.Labels, - TorrentURL: item.DetailURL, - DownloadURL: item.DownloadURL, - Category: item.Category, - SearchKeyword: keyword, - Size: item.Size, - Seeders: item.Seeders, - Leechers: item.Leechers, - Free: item.Free, - }) - } - return out -} diff --git a/internal/service/site_test.go b/internal/service/site_test.go deleted file mode 100644 index 82894a2..0000000 --- a/internal/service/site_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package service - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSiteUpdateKeepsSecretsWhenPatchIsBlank(t *testing.T) { - db := newServiceTestDB(t, &model.Site{}) - svc := NewSiteService(zap.NewNop(), &repository.Container{DB: db}, "") - site := &model.Site{ - Name: "M-Team", - Type: "mteam", - URL: "https://api.m-team.cc", - AuthType: "api_key", - APIKey: "token-123", - Enabled: true, - } - if err := svc.Create(context.Background(), site); err != nil { - t.Fatal(err) - } - - if err := svc.Update(context.Background(), site.ID, map[string]any{ - "url": "https://api.m-team.cc/", - "api_key": "", - "cookie": "", - }); err != nil { - t.Fatal(err) - } - - got, err := svc.FindByID(context.Background(), site.ID) - if err != nil { - t.Fatal(err) - } - if got.APIKey != "token-123" { - t.Fatalf("APIKey = %q, want original token", got.APIKey) - } - if got.URL != "https://api.m-team.cc" { - t.Fatalf("URL = %q, want trimmed URL", got.URL) - } -} - -func TestYemaPTTestConnectionDoesNotFallbackAfterAuthFailure(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":false,"errorCode":403,"errorMessage":"need api auth"}`)) - })) - defer server.Close() - - db := newServiceTestDB(t, &model.Site{}) - repos := repository.New(db) - svc := NewSiteService(zap.NewNop(), repos, "") - site := &model.Site{ - Name: "YemaPT", - Type: "yemapt", - URL: server.URL, - AuthType: "api_key", - APIKey: "bad-auth", - Enabled: true, - } - if err := svc.Create(context.Background(), site); err != nil { - t.Fatal(err) - } - - ok, msg, err := svc.TestConnection(context.Background(), site.ID) - if err != nil { - t.Fatal(err) - } - if ok { - t.Fatal("TestConnection succeeded after YemaPT auth failure") - } - if !strings.Contains(msg, "need api auth") { - t.Fatalf("message = %q, want need api auth", msg) - } -} - -func TestRedactSensitiveDownloadURL(t *testing.T) { - tests := []struct { - name string - raw string - want string - }{ - { - name: "query secrets", - raw: "https://pt.example/download.php?id=123&passkey=secret#frag", - want: "https://pt.example/download.php", - }, - { - name: "magnet", - raw: "magnet:?xt=urn:btih:abc&dn=movie", - want: "magnet:?xt=***", - }, - { - name: "invalid", - raw: "not a url", - want: "[redacted-download-url]", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := redactSensitiveDownloadURL(tt.raw); got != tt.want { - t.Fatalf("redactSensitiveDownloadURL() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestSiteSearchReturnsErrorWhenAllEnabledSitesFail(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "upstream timeout simulation", http.StatusGatewayTimeout) - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Site{}) - repos := repository.New(db) - svc := NewSiteService(zap.NewNop(), repos, "") - site := &model.Site{ - Name: "馒头", - Type: "mteam", - URL: upstream.URL, - AuthType: "api_key", - APIKey: "token-123", - Enabled: true, - Timeout: 5, - } - if err := svc.Create(context.Background(), site); err != nil { - t.Fatal(err) - } - - results, err := svc.Search(context.Background(), "南部档案 2026") - if err == nil { - t.Fatalf("Search error = nil, want all-sites-failed error; results=%#v", results) - } - if len(results) != 0 { - t.Fatalf("results = %#v, want none on all-sites failure", results) - } - if !strings.Contains(err.Error(), "all enabled sites failed") || !strings.Contains(err.Error(), "馒头") { - t.Fatalf("error = %q, want site failure context", err.Error()) - } -} - -func TestSearchSiteQueriesSelectedSiteEvenWhenDisabled(t *testing.T) { - var gotQuery string - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotQuery = r.URL.RawQuery - _, _ = w.Write([]byte(`
Selected Site Result下载
`)) - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Site{}) - repos := repository.New(db) - svc := NewSiteService(zap.NewNop(), repos, "") - site := &model.Site{ - Name: "Selected Nexus", - Type: "nexusphp", - URL: upstream.URL, - AuthType: "cookie", - Cookie: "uid=1; pass=token", - Enabled: false, - Timeout: 5, - } - if err := svc.Create(context.Background(), site); err != nil { - t.Fatal(err) - } - - results, err := svc.SearchSite(context.Background(), site.ID, "Selected", 1) - if err != nil { - t.Fatalf("SearchSite returned error: %v", err) - } - if !strings.Contains(gotQuery, "searchstr=Selected") { - t.Fatalf("query = %q, want searchstr=Selected", gotQuery) - } - if len(results) != 1 || results[0].SiteID != site.ID || results[0].Title != "Selected Site Result" { - t.Fatalf("results = %#v", results) - } -} diff --git a/internal/service/stats.go b/internal/service/stats.go deleted file mode 100644 index 6ab8728..0000000 --- a/internal/service/stats.go +++ /dev/null @@ -1,163 +0,0 @@ -// Package service — statistics aggregator. -// -// StatsService computes the dashboard numbers for the admin / home page: -// - total libraries, media items, users -// - total disk size and durations -// - top recently-watched media -// - process metadata (CPU / memory) via gopsutil -package service - -import ( - "context" - "runtime" - "time" - - "github.com/shirou/gopsutil/v3/cpu" - "github.com/shirou/gopsutil/v3/disk" - "github.com/shirou/gopsutil/v3/mem" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// StatsService computes aggregate stats. -type StatsService struct { - log *zap.Logger - repo *repository.Container - cache *RuntimeCacheService -} - -// NewStatsService is the constructor. -func NewStatsService(log *zap.Logger, repo *repository.Container) *StatsService { - return &StatsService{log: log, repo: repo} -} - -func (s *StatsService) SetRuntimeCache(cache *RuntimeCacheService) *StatsService { - if s != nil { - s.cache = cache - } - return s -} - -// Snapshot is the JSON returned by /api/stats. -type Snapshot struct { - Libraries int64 `json:"libraries"` - MediaCount int64 `json:"media_count"` - UsersCount int64 `json:"users_count"` - TotalSizeBytes int64 `json:"total_size_bytes"` - TotalSeconds int64 `json:"total_seconds"` - RecentlyAdded []model.Media `json:"recently_added"` - Hardware Hardware `json:"hardware"` - GeneratedAt time.Time `json:"generated_at"` -} - -// Hardware is the live CPU / memory / disk readings. -type Hardware struct { - CPUPercent float64 `json:"cpu_percent"` - MemoryUsed uint64 `json:"memory_used"` - MemoryTotal uint64 `json:"memory_total"` - DiskUsed uint64 `json:"disk_used"` - DiskTotal uint64 `json:"disk_total"` - GoVersion string `json:"go_version"` - Goroutines int `json:"goroutines"` -} - -// Compute builds a fresh snapshot. -func (s *StatsService) Compute(ctx context.Context, dataDir string) (*Snapshot, error) { - const cacheKey = "stats:snapshot:base" - if s.cache != nil { - var cached Snapshot - if s.cache.GetJSON(ctx, cacheKey, &cached) { - cached.GeneratedAt = time.Now() - cached.Hardware = readHardware(dataDir) - return &cached, nil - } - } - snap := &Snapshot{GeneratedAt: time.Now()} - libs, err := s.repo.Library.List(ctx) - if err != nil { - return nil, err - } - libs = FilterDisplayCloudLibraries(ctx, s.repo, libs) - activeLibraryIDs := make([]string, 0, len(libs)) - for _, lib := range libs { - if !lib.Enabled { - continue - } - activeLibraryIDs = append(activeLibraryIDs, lib.ID) - } - snap.Libraries = int64(len(activeLibraryIDs)) - mediaQuery := s.repo.DB.Model(&model.Media{}) - if len(activeLibraryIDs) == 0 { - mediaQuery = mediaQuery.Where("1 = 0") - } else { - mediaQuery = mediaQuery.Where("library_id IN ?", activeLibraryIDs) - } - if err := mediaQuery.Count(&snap.MediaCount).Error; err != nil { - return nil, err - } - if err := s.repo.DB.Model(&model.User{}).Count(&snap.UsersCount).Error; err != nil { - return nil, err - } - type sumRow struct { - Size int64 - Seconds int64 - } - var sum sumRow - sumQuery := s.repo.DB.Model(&model.Media{}) - if len(activeLibraryIDs) == 0 { - sumQuery = sumQuery.Where("1 = 0") - } else { - sumQuery = sumQuery.Where("library_id IN ?", activeLibraryIDs) - } - if err := sumQuery. - Select("COALESCE(SUM(size_bytes),0) as size, COALESCE(SUM(duration_sec),0) as seconds"). - Scan(&sum).Error; err != nil { - return nil, err - } - snap.TotalSizeBytes = sum.Size - snap.TotalSeconds = sum.Seconds - - recentQuery := s.repo.DB.Model(&model.Media{}) - if len(activeLibraryIDs) == 0 { - recentQuery = recentQuery.Where("1 = 0") - } else { - recentQuery = recentQuery.Where("library_id IN ?", activeLibraryIDs) - } - if err := recentQuery. - Order("created_at desc").Limit(12). - Find(&snap.RecentlyAdded).Error; err != nil { - return nil, err - } - - if s.cache != nil { - cacheCopy := *snap - cacheCopy.Hardware = Hardware{} - s.cache.SetJSON(ctx, cacheKey, cacheCopy, 10*time.Second) - } - snap.Hardware = readHardware(dataDir) - return snap, nil -} - -func readHardware(dataDir string) Hardware { - hw := Hardware{ - GoVersion: runtime.Version(), - Goroutines: runtime.NumGoroutine(), - } - if usage, err := cpu.Percent(0, false); err == nil && len(usage) > 0 { - hw.CPUPercent = usage[0] - } - if v, err := mem.VirtualMemory(); err == nil { - hw.MemoryUsed = v.Used - hw.MemoryTotal = v.Total - } - if dataDir == "" { - dataDir = "/" - } - if d, err := disk.Usage(dataDir); err == nil { - hw.DiskUsed = d.Used - hw.DiskTotal = d.Total - } - return hw -} diff --git a/internal/service/stats_test.go b/internal/service/stats_test.go deleted file mode 100644 index 7844395..0000000 --- a/internal/service/stats_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package service - -import ( - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestStatsComputeFiltersDisabledLibraries(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.User{}) - repos := repository.New(db) - enabled := &model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true} - disabled := &model.Library{Name: "停用库", Path: "/media/disabled", Type: "movie", Enabled: false} - if err := repos.Library.Create(t.Context(), enabled); err != nil { - t.Fatal(err) - } - if err := repos.Library.Create(t.Context(), disabled); err != nil { - t.Fatal(err) - } - if err := db.Model(&model.Library{}).Where("id = ?", disabled.ID).Update("enabled", false).Error; err != nil { - t.Fatal(err) - } - for _, media := range []*model.Media{ - {LibraryID: enabled.ID, Title: "Visible", Path: "/media/movies/a.mkv", SizeBytes: 100}, - {LibraryID: disabled.ID, Title: "Hidden", Path: "/media/disabled/b.mkv", SizeBytes: 900}, - } { - if err := repos.Media.Upsert(t.Context(), media); err != nil { - t.Fatal(err) - } - } - - snap, err := NewStatsService(zap.NewNop(), repos).Compute(t.Context(), t.TempDir()) - if err != nil { - t.Fatal(err) - } - if snap.Libraries != 1 || snap.MediaCount != 1 || snap.TotalSizeBytes != 100 { - t.Fatalf("stats = libraries=%d media=%d size=%d, want 1/1/100", snap.Libraries, snap.MediaCount, snap.TotalSizeBytes) - } -} diff --git a/internal/service/storage_cloud_resolve.go b/internal/service/storage_cloud_resolve.go deleted file mode 100644 index b816834..0000000 --- a/internal/service/storage_cloud_resolve.go +++ /dev/null @@ -1,277 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -type cloudResolveCacheEntry struct { - link *cloud.DirectLink - expiresAt time.Time - hits int - lastHit time.Time -} - -type cloudResolveCall struct { - done chan struct{} - link *cloud.DirectLink - err error -} - -const ( - cloudResolveHotHitThreshold = 3 - cloudResolveBackgroundRefreshMax = 30 * time.Second -) - -// CloudResolve resolves a cloud file reference to a direct link. -// -// clientUA is the User-Agent of the playback client that will follow the 302 -// redirect. Some provider CDN links are bound to the UA used to request them, -// so we resolve with the client's own UA. When clientUA is empty the provider's -// default UA is used. -func (s *StorageConfigService) CloudResolve(ctx context.Context, typ, fileRef, clientUA string) (*cloud.DirectLink, error) { - if s == nil { - return nil, errors.New("storage config service unavailable") - } - cacheKey := s.resolveCacheKey(typ, fileRef, clientUA) - if link, ok, refresh := s.cachedResolve(cacheKey, typ); ok { - if refresh { - s.refreshResolveInBackground(cacheKey, typ, fileRef, clientUA) - } - return link, nil - } - if call, owner := s.beginResolve(cacheKey); !owner { - select { - case <-call.done: - if call.err != nil { - return nil, call.err - } - return cloneDirectLink(call.link), nil - case <-ctx.Done(): - return nil, ctx.Err() - } - } else { - defer s.finishResolve(cacheKey, call) - p, err := s.cloudProviderWithUA(ctx, typ, clientUA) - if err != nil { - call.err = err - return nil, err - } - link, err := p.Resolve(ctx, fileRef) - if err != nil { - call.err = err - return nil, err - } - call.link = cloneDirectLink(link) - s.storeResolvedLink(cacheKey, typ, link) - return cloneDirectLink(link), nil - } -} - -func (s *StorageConfigService) resolveCacheKey(typ, fileRef, clientUA string) string { - return strings.TrimSpace(typ) + "\x00" + strings.TrimSpace(fileRef) + "\x00" + strings.TrimSpace(clientUA) -} - -func (s *StorageConfigService) cachedResolve(key, typ string) (*cloud.DirectLink, bool, bool) { - s.resolveMu.Lock() - defer s.resolveMu.Unlock() - if s.resolveCache == nil { - s.resolveCache = make(map[string]cloudResolveCacheEntry) - return nil, false, false - } - entry, ok := s.resolveCache[key] - now := time.Now() - if !ok || now.After(entry.expiresAt) { - if ok { - delete(s.resolveCache, key) - } - return nil, false, false - } - entry.hits++ - entry.lastHit = now - s.resolveCache[key] = entry - refreshWindow := cloudResolveHotRefreshWindow(cloudResolveCacheTTL(typ)) - shouldRefresh := entry.hits >= cloudResolveHotHitThreshold && - refreshWindow > 0 && - now.Add(refreshWindow).After(entry.expiresAt) - return cloneDirectLink(entry.link), true, shouldRefresh -} - -func (s *StorageConfigService) beginResolve(key string) (*cloudResolveCall, bool) { - s.resolveMu.Lock() - defer s.resolveMu.Unlock() - if s.resolveFlight == nil { - s.resolveFlight = make(map[string]*cloudResolveCall) - } - if call := s.resolveFlight[key]; call != nil { - return call, false - } - call := &cloudResolveCall{done: make(chan struct{})} - s.resolveFlight[key] = call - return call, true -} - -func (s *StorageConfigService) finishResolve(key string, call *cloudResolveCall) { - s.resolveMu.Lock() - if current := s.resolveFlight[key]; current == call { - delete(s.resolveFlight, key) - } - s.resolveMu.Unlock() - close(call.done) -} - -func (s *StorageConfigService) refreshResolveInBackground(key, typ, fileRef, clientUA string) { - if s == nil { - return - } - go func() { - call, owner := s.beginResolve(key) - if !owner { - return - } - defer s.finishResolve(key, call) - ctx, cancel := context.WithTimeout(context.Background(), cloudResolveBackgroundRefreshMax) - defer cancel() - p, err := s.cloudProviderWithUA(ctx, typ, clientUA) - if err != nil { - call.err = err - if s.log != nil { - s.log.Debug("refresh cloud direct link failed", zap.String("provider", typ), zap.Error(err)) - } - return - } - link, err := p.Resolve(ctx, fileRef) - if err != nil { - call.err = err - if s.log != nil { - s.log.Debug("refresh cloud direct link failed", zap.String("provider", typ), zap.Error(err)) - } - return - } - call.link = cloneDirectLink(link) - s.storeResolvedLink(key, typ, link) - }() -} - -func (s *StorageConfigService) storeResolvedLink(key, typ string, link *cloud.DirectLink) { - if link == nil || strings.TrimSpace(link.URL) == "" { - return - } - ttl := cloudResolveCacheTTL(typ) - if ttl <= 0 { - return - } - s.resolveMu.Lock() - defer s.resolveMu.Unlock() - if s.resolveCache == nil { - s.resolveCache = make(map[string]cloudResolveCacheEntry) - } - now := time.Now() - hits := 0 - if existing, ok := s.resolveCache[key]; ok { - hits = existing.hits - } - s.resolveCache[key] = cloudResolveCacheEntry{link: cloneDirectLink(link), expiresAt: now.Add(ttl), hits: hits, lastHit: now} -} - -func cloudResolveHotRefreshWindow(ttl time.Duration) time.Duration { - if ttl <= 0 { - return 0 - } - window := ttl / 4 - if window < 15*time.Second { - window = 15 * time.Second - } - if window > 2*time.Minute { - window = 2 * time.Minute - } - return window -} - -func cloudResolveCacheTTL(typ string) time.Duration { - switch typ { - case cloud.Type115, cloud.TypeCloudDrive2, cloud.TypeOpenList: - return 2 * time.Minute - default: - return 5 * time.Minute - } -} - -func cloneDirectLink(link *cloud.DirectLink) *cloud.DirectLink { - if link == nil { - return nil - } - out := &cloud.DirectLink{ - URL: link.URL, - Headers: make(map[string]string, len(link.Headers)), - Proxy: link.Proxy, - } - for k, v := range link.Headers { - out.Headers[k] = v - } - return out -} - -func (s *StorageConfigService) clearResolveCacheForType(typ string) { - typ = strings.TrimSpace(typ) - if typ == "" { - return - } - prefix := typ + "\x00" - s.resolveMu.Lock() - defer s.resolveMu.Unlock() - for key := range s.resolveCache { - if strings.HasPrefix(key, prefix) { - delete(s.resolveCache, key) - } - } - for key, call := range s.resolveFlight { - if strings.HasPrefix(key, prefix) && call != nil { - call.err = fmt.Errorf("%s storage config changed", typ) - } - } -} - -func (s *StorageConfigService) CloudResolveUncached(ctx context.Context, typ, fileRef, clientUA string) (*cloud.DirectLink, error) { - p, err := s.cloudProviderWithUA(ctx, typ, clientUA) - if err != nil { - return nil, err - } - return p.Resolve(ctx, fileRef) -} - -// cloudProviderWithUA builds a provider, overriding the request UA when a -// non-empty clientUA is supplied. -func (s *StorageConfigService) cloudProviderWithUA(ctx context.Context, typ, clientUA string) (cloud.Provider, error) { - if !cloud.IsCloudType(typ) { - return nil, fmt.Errorf("not a cloud provider: %q", typ) - } - view, err := s.Get(ctx, typ) - if err != nil { - return nil, err - } - if view == nil { - return nil, fmt.Errorf("%s storage not configured", typ) - } - if !view.Enabled { - return nil, fmt.Errorf("%s storage disabled", typ) - } - cfg := view.Config - if strings.TrimSpace(clientUA) != "" { - // Copy so we never mutate the cached view config. - cp := make(map[string]any, len(cfg)+1) - for k, v := range cfg { - cp[k] = v - } - cp["ua"] = clientUA - cfg = cp - } - return cloud.New(typ, cfg, s.clientForConfig(cfg)) -} diff --git a/internal/service/storage_cloud_text.go b/internal/service/storage_cloud_text.go deleted file mode 100644 index 60d1060..0000000 --- a/internal/service/storage_cloud_text.go +++ /dev/null @@ -1,45 +0,0 @@ -package service - -import ( - "context" - "fmt" - "io" - "net/http" - "strings" -) - -// CloudReadText resolves a small cloud file and returns its text payload. It is -// used for cloud-hosted .strm files: the scanner reads the STRM target once and -// stores the real playback URL, while the media bytes still stay in the cloud. -func (s *StorageConfigService) CloudReadText(ctx context.Context, typ, fileRef string, limit int64) (string, error) { - if limit <= 0 { - limit = 64 << 10 - } - link, err := s.CloudResolve(ctx, typ, fileRef, "") - if err != nil { - return "", err - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil) - if err != nil { - return "", err - } - for k, v := range link.Headers { - req.Header.Set(k, v) - } - resp, err := s.client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("%s: read strm returned http %d", typ, resp.StatusCode) - } - body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) - if err != nil { - return "", err - } - if int64(len(body)) > limit { - return "", fmt.Errorf("%s: strm file is too large", typ) - } - return strings.TrimSpace(strings.TrimPrefix(string(body), "\ufeff")), nil -} diff --git a/internal/service/storage_config.go b/internal/service/storage_config.go deleted file mode 100644 index a820feb..0000000 --- a/internal/service/storage_config.go +++ /dev/null @@ -1,276 +0,0 @@ -// Package service — external storage configuration management. -// -// StorageConfigService stores connection settings encrypted at rest -// (via CryptoService). It also exposes a Test() probe so the React UI -// can verify the credentials before saving. -package service - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strings" - "sync" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -// StorageConfigService encrypts + persists external storage configs. -type StorageConfigService struct { - log *zap.Logger - repo *repository.Container - crypto *CryptoService - client *http.Client - resolveMu sync.Mutex - resolveCache map[string]cloudResolveCacheEntry - resolveFlight map[string]*cloudResolveCall -} - -// NewStorageConfigService is the constructor. -func NewStorageConfigService(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *StorageConfigService { - return &StorageConfigService{ - log: log, - repo: repo, - crypto: crypto, - client: &http.Client{Timeout: 120 * time.Second}, - resolveCache: make(map[string]cloudResolveCacheEntry), - resolveFlight: make(map[string]*cloudResolveCall), - } -} - -// StorageInput is the create / update payload accepted by the API. -// Config is a free-form map whose required keys depend on Type. -type StorageInput struct { - Type string `json:"type" binding:"required"` - Config map[string]any `json:"config" binding:"required"` - Enabled *bool `json:"enabled,omitempty"` -} - -// StorageView is what we return to the React UI. The actual ciphertext -// is decoded back to a map (with secret keys still redacted in the -// list endpoint via Redact). -type StorageView struct { - model.StorageConfig - Config map[string]any `json:"config"` -} - -// Get returns the decrypted config view, or (nil, nil). -func (s *StorageConfigService) Get(ctx context.Context, kind string) (*StorageView, error) { - row, err := s.repo.StorageConfig.Get(ctx, kind) - if err != nil { - return nil, err - } - if row == nil { - return nil, nil - } - plain := s.crypto.Decrypt(row.Config) - var cfg map[string]any - _ = json.Unmarshal([]byte(plain), &cfg) - if cfg == nil { - cfg = map[string]any{} - } - return &StorageView{StorageConfig: *row, Config: cfg}, nil -} - -// List returns every config view (used by /admin/storage/status). -func (s *StorageConfigService) List(ctx context.Context) ([]StorageView, error) { - rows, err := s.repo.StorageConfig.List(ctx) - if err != nil { - return nil, err - } - out := make([]StorageView, 0, len(rows)) - for _, r := range rows { - if !IsAdminStorageConfigurable(r.Type) { - continue - } - plain := s.crypto.Decrypt(r.Config) - var cfg map[string]any - _ = json.Unmarshal([]byte(plain), &cfg) - // Redact secrets when listing. - for _, k := range []string{"password", "secret_key", "token", "cookie", "access_key"} { - if v, ok := cfg[k]; ok && fmt.Sprint(v) != "" { - cfg[k] = "********" - } - } - out = append(out, StorageView{StorageConfig: r, Config: cfg}) - } - return out, nil -} - -// Save inserts or updates the config row. -func (s *StorageConfigService) Save(ctx context.Context, in StorageInput) (*StorageView, error) { - if !validStorageType(in.Type) { - return nil, fmt.Errorf("unsupported storage type %q", in.Type) - } - cfg := cloneStorageConfigMap(in.Config) - if shouldPreserveStorageSecretsOnSave(in.Enabled) { - merged, err := s.mergeExistingStorageSecrets(ctx, in.Type, cfg) - if err != nil { - return nil, err - } - cfg = merged - } - blob, err := json.Marshal(cfg) - if err != nil { - return nil, err - } - cipher := s.crypto.Encrypt(string(blob)) - row := &model.StorageConfig{ - Type: in.Type, - Config: cipher, - Enabled: true, - } - if in.Enabled != nil { - row.Enabled = *in.Enabled - } - if err := s.repo.StorageConfig.Upsert(ctx, row); err != nil { - return nil, err - } - s.clearResolveCacheForType(in.Type) - return s.Get(ctx, in.Type) -} - -// Logout clears saved cloud login credentials, disables the storage backend, -// and removes virtual cloud libraries/media for that provider. It intentionally -// keeps non-secret connection hints such as server / WebDAV URL / timeout so -// the admin can log in again without rebuilding the form. -func (s *StorageConfigService) Logout(ctx context.Context, typ string) (*StorageView, error) { - if !validStorageType(typ) { - return nil, fmt.Errorf("unsupported storage type %q", typ) - } - if !cloud.IsCloudType(typ) { - return nil, fmt.Errorf("not a cloud provider: %q", typ) - } - view, err := s.Get(ctx, typ) - if err != nil { - return nil, err - } - if view == nil { - return nil, fmt.Errorf("%s storage not configured", typ) - } - cfg := make(map[string]any, len(view.Config)) - for k, v := range view.Config { - if isStorageLoginSecretKey(k) || isDeprecatedStoragePlaybackKey(k) { - continue - } - cfg[k] = v - } - enabled := false - saved, err := s.Save(ctx, StorageInput{Type: typ, Config: cfg, Enabled: &enabled}) - if err != nil { - return nil, err - } - purged, err := s.purgeCloudLibraries(ctx, typ) - if err != nil { - return nil, err - } - if s.log != nil { - s.log.Info("storage logout cleared cloud libraries", - zap.String("storage_type", typ), - zap.Int("libraries_deleted", purged)) - } - return saved, nil -} - -func (s *StorageConfigService) purgeCloudLibraries(ctx context.Context, storageType string) (int, error) { - if s == nil || s.repo == nil || s.repo.Library == nil || s.repo.Media == nil { - return 0, nil - } - libs, err := s.repo.Library.List(ctx) - if err != nil { - return 0, fmt.Errorf("list libraries: %w", err) - } - var affectedLibs []string - for _, lib := range libs { - if mount, ok := ParseCloudLibraryMount(lib.Path); ok && mount.Provider == storageType { - affectedLibs = append(affectedLibs, lib.ID) - } - } - for _, libID := range affectedLibs { - if err := s.repo.Media.PurgeByLibrary(ctx, libID); err != nil { - if s.log != nil { - s.log.Warn("purge media by library failed", zap.String("library_id", libID), zap.Error(err)) - } - return len(affectedLibs), fmt.Errorf("purge media by library %s: %w", libID, err) - } - } - for _, libID := range affectedLibs { - if err := s.repo.Library.Delete(ctx, libID); err != nil { - if s.log != nil { - s.log.Warn("delete library failed", zap.String("library_id", libID), zap.Error(err)) - } - return len(affectedLibs), fmt.Errorf("delete library %s: %w", libID, err) - } - } - return len(affectedLibs), nil -} - -func isStorageLoginSecretKey(key string) bool { - switch strings.ToLower(strings.TrimSpace(key)) { - case "cookie", "token", "username", "password", "access_key", "secret_key": - return true - default: - return false - } -} - -func isDeprecatedStoragePlaybackKey(key string) bool { - switch strings.ToLower(strings.TrimSpace(key)) { - case "force_302", "force_proxy": - return true - default: - return false - } -} - -func validStorageType(t string) bool { - switch t { - case "alist", "s3", "webdav", cloud.Type115, cloud.TypeCloudDrive2, cloud.TypeOpenList: - return true - } - return false -} - -// strr is a tiny helper to avoid importing fmt.Sprint just to coerce -// interface{} → string. (Named "strr" so it doesn't collide with the -// notify channel's `str` helper which already lives in this package.) -func strr(v any) string { - if v == nil { - return "" - } - if s, ok := v.(string); ok { - return strings.TrimSpace(s) - } - return strings.TrimSpace(fmt.Sprint(v)) -} - -// DeleteStorage 删除存储配置并清理关联数据 -func (s *StorageConfigService) DeleteStorage(ctx context.Context, storageType string) error { - // 查找配置 - cfg, err := s.repo.StorageConfig.Get(ctx, storageType) - if err != nil || cfg == nil { - return fmt.Errorf("storage config not found: %s", storageType) - } - - affectedLibs, err := s.purgeCloudLibraries(ctx, storageType) - if err != nil { - return err - } - - // 删除存储配置 - if err := s.repo.StorageConfig.Delete(ctx, cfg.ID); err != nil { - return fmt.Errorf("delete storage config: %w", err) - } - - s.log.Info("storage deleted", - zap.String("storage_type", storageType), - zap.Int("libraries_deleted", affectedLibs)) - - return nil -} diff --git a/internal/service/storage_config_cache_test.go b/internal/service/storage_config_cache_test.go deleted file mode 100644 index 37a5e4b..0000000 --- a/internal/service/storage_config_cache_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package service - -import ( - "fmt" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - "time" -) - -func TestCloudResolveHotCacheRefreshesInBackground(t *testing.T) { - var resolves atomic.Int32 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/fs/get" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - n := resolves.Add(1) - w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintf(w, `{"code":200,"data":{"raw_url":"http://cdn.local/%d.mkv"}}`, n) - })) - defer upstream.Close() - - _, storage := newStorageUploadTestService(t) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": upstream.URL, - "token": "token", - }, - }); err != nil { - t.Fatal(err) - } - - link, err := storage.CloudResolve(t.Context(), "openlist", "/Movies/f1.mkv", "Player/1") - if err != nil { - t.Fatal(err) - } - if link.URL != "http://cdn.local/1.mkv" || resolves.Load() != 1 { - t.Fatalf("first resolve link=%#v resolves=%d", link, resolves.Load()) - } - for i := 0; i < cloudResolveHotHitThreshold-1; i++ { - link, err = storage.CloudResolve(t.Context(), "openlist", "/Movies/f1.mkv", "Player/1") - if err != nil { - t.Fatal(err) - } - if link.URL != "http://cdn.local/1.mkv" || resolves.Load() != 1 { - t.Fatalf("cached resolve link=%#v resolves=%d", link, resolves.Load()) - } - } - - key := storage.resolveCacheKey("openlist", "/Movies/f1.mkv", "Player/1") - storage.resolveMu.Lock() - entry := storage.resolveCache[key] - entry.hits = cloudResolveHotHitThreshold - entry.expiresAt = time.Now().Add(5 * time.Second) - storage.resolveCache[key] = entry - storage.resolveMu.Unlock() - - link, err = storage.CloudResolve(t.Context(), "openlist", "/Movies/f1.mkv", "Player/1") - if err != nil { - t.Fatal(err) - } - if link.URL != "http://cdn.local/1.mkv" { - t.Fatalf("hot hit should return cached link immediately, got %s", link.URL) - } - deadline := time.Now().Add(2 * time.Second) - for resolves.Load() < 2 && time.Now().Before(deadline) { - time.Sleep(10 * time.Millisecond) - } - if resolves.Load() < 2 { - t.Fatalf("background refresh did not run, resolves=%d", resolves.Load()) - } - link, err = storage.CloudResolve(t.Context(), "openlist", "/Movies/f1.mkv", "Player/1") - if err != nil { - t.Fatal(err) - } - if link.URL != "http://cdn.local/2.mkv" { - t.Fatalf("refreshed link = %s, want second URL", link.URL) - } -} - -func TestCloudResolveCacheTTLUsesShortTTLForCloudPlaybackLinks(t *testing.T) { - for _, typ := range []string{"cloud115", "clouddrive2", "openlist"} { - if got := cloudResolveCacheTTL(typ); got != 2*time.Minute { - t.Fatalf("%s cloud resolve cache ttl = %v, want 2m", typ, got) - } - } -} diff --git a/internal/service/storage_config_cloud.go b/internal/service/storage_config_cloud.go deleted file mode 100644 index a415ac5..0000000 --- a/internal/service/storage_config_cloud.go +++ /dev/null @@ -1,147 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -// CloudProvider constructs a cloud-disk provider from the saved (decrypted) -// config for the given type, or returns an error if not configured. -func (s *StorageConfigService) CloudProvider(ctx context.Context, typ string) (cloud.Provider, error) { - if !cloud.IsCloudType(typ) { - return nil, fmt.Errorf("not a cloud provider: %q", typ) - } - view, err := s.Get(ctx, typ) - if err != nil { - return nil, err - } - if view == nil { - return nil, fmt.Errorf("%s storage not configured", typ) - } - if !view.Enabled { - return nil, fmt.Errorf("%s storage disabled", typ) - } - return cloud.New(typ, view.Config, s.clientForConfig(view.Config)) -} - -// CloudList lists entries under dirID for the configured cloud provider. -func (s *StorageConfigService) CloudList(ctx context.Context, typ, dirID string) ([]cloud.FileEntry, error) { - p, err := s.CloudProvider(ctx, typ) - if err != nil { - return nil, err - } - return p.List(ctx, dirID) -} - -func (s *StorageConfigService) CloudMkdir(ctx context.Context, typ, parentDir, name string) (*cloud.FileEntry, error) { - p, err := s.CloudProvider(ctx, typ) - if err != nil { - return nil, err - } - mutable, ok := p.(cloud.MutableProvider) - if !ok { - return nil, fmt.Errorf("%s does not support folder creation", typ) - } - return mutable.Mkdir(ctx, parentDir, name) -} - -func (s *StorageConfigService) CloudRename(ctx context.Context, typ, ref, name string) (*cloud.FileEntry, error) { - p, err := s.CloudProvider(ctx, typ) - if err != nil { - return nil, err - } - mutable, ok := p.(cloud.MutableProvider) - if !ok { - return nil, fmt.Errorf("%s does not support rename", typ) - } - return mutable.Rename(ctx, ref, name) -} - -func (s *StorageConfigService) CloudMove(ctx context.Context, typ, ref, targetDir, name string) (*cloud.FileEntry, error) { - p, err := s.CloudProvider(ctx, typ) - if err != nil { - return nil, err - } - movable, ok := p.(cloud.MovableProvider) - if !ok { - return nil, fmt.Errorf("%s does not support move", typ) - } - return movable.Move(ctx, ref, targetDir, name) -} - -// cloudLibraryName maps a provider type to a friendly Chinese library name. -func cloudLibraryName(typ string) string { - switch typ { - case cloud.Type115: - return "115 网盘" - case cloud.TypeCloudDrive2: - return "CloudDrive2" - case cloud.TypeOpenList: - return "OpenList" - default: - return typ - } -} - -// ensureCloudLibrary returns (creating if necessary) the per-provider cloud -// library that owns imported 302 media. -func (s *StorageConfigService) ensureCloudLibrary(ctx context.Context, typ string) (*model.Library, error) { - libs, err := s.repo.Library.List(ctx) - if err != nil { - return nil, err - } - path := "cloud://" + typ - for i := range libs { - if libs[i].Path == path { - return &libs[i], nil - } - } - lib := &model.Library{Name: cloudLibraryName(typ), Path: path, Type: "movie", Enabled: true} - if err := s.repo.Library.Create(ctx, lib); err != nil { - return nil, err - } - return lib, nil -} - -// CloudImport creates (or refreshes) a playable media row backed by a cloud -// file. Playback is served entirely via 302 redirect — the host never streams -// the bytes (unless the provider requires proxy mode). -func (s *StorageConfigService) CloudImport(ctx context.Context, typ, fileRef, name string, size int64) (*model.Media, error) { - if !cloud.IsCloudType(typ) { - return nil, fmt.Errorf("not a cloud provider: %q", typ) - } - if strings.TrimSpace(fileRef) == "" { - return nil, errors.New("file reference required") - } - lib, err := s.ensureCloudLibrary(ctx, typ) - if err != nil { - return nil, err - } - title := strings.TrimSpace(name) - container := "" - if i := strings.LastIndex(title, "."); i > 0 { - container = strings.ToLower(strings.TrimPrefix(title[i:], ".")) - title = title[:i] - } - if title == "" { - title = fileRef - } - m := &model.Media{ - LibraryID: lib.ID, - Title: title, - Path: cloudMediaPath(typ, fileRef), - SizeBytes: size, - Container: container, - STRMURL: BuildRelativeCloudPlayURL(typ, fileRef), - ScrapeStatus: "pending", - } - if err := s.repo.Media.Upsert(ctx, m); err != nil { - return nil, err - } - return m, nil -} diff --git a/internal/service/storage_config_logout_test.go b/internal/service/storage_config_logout_test.go deleted file mode 100644 index 2b390a5..0000000 --- a/internal/service/storage_config_logout_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package service - -import ( - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestStorageConfigLogoutClearsCredentialsAndCloudLibraries(t *testing.T) { - repos, storage := newStorageUploadTestService(t) - enabled := true - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": "http://openlist.test", - "url": "http://openlist.test/dav/", - "username": "user", - "password": "pass", - "token": "token", - "timeout_seconds": "120", - "force_302": "true", - }, - Enabled: &enabled, - }); err != nil { - t.Fatalf("save storage: %v", err) - } - cloudLib := model.Library{Name: "OpenList", Path: BuildCloudLibraryPath("openlist", "/TV", "/TV"), Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &cloudLib); err != nil { - t.Fatalf("create cloud library: %v", err) - } - localLib := model.Library{Name: "Local", Path: t.TempDir(), Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &localLib); err != nil { - t.Fatalf("create local library: %v", err) - } - if err := repos.Media.Upsert(t.Context(), &model.Media{LibraryID: cloudLib.ID, Title: "Cloud", Path: "cloud://openlist/TV/Movie.mkv"}); err != nil { - t.Fatalf("create cloud media: %v", err) - } - if err := repos.Media.Upsert(t.Context(), &model.Media{LibraryID: localLib.ID, Title: "Local", Path: localLib.Path + "/Movie.mkv"}); err != nil { - t.Fatalf("create local media: %v", err) - } - - view, err := storage.Logout(t.Context(), "openlist") - if err != nil { - t.Fatalf("logout: %v", err) - } - if view.Enabled { - t.Fatal("storage should be disabled after logout") - } - for _, key := range []string{"username", "password", "token", "force_302", "force_proxy"} { - if _, ok := view.Config[key]; ok { - t.Fatalf("logout should clear %s, config = %#v", key, view.Config) - } - } - if view.Config["server"] != "http://openlist.test" || view.Config["url"] != "http://openlist.test/dav/" || view.Config["timeout_seconds"] != "120" { - t.Fatalf("logout should keep non-secret connection hints, config = %#v", view.Config) - } - if got, err := repos.Library.FindByID(t.Context(), cloudLib.ID); err != nil { - t.Fatalf("find cloud library: %v", err) - } else if got != nil { - t.Fatalf("cloud library should be removed after logout: %#v", got) - } - if got, err := repos.Library.FindByID(t.Context(), localLib.ID); err != nil { - t.Fatalf("find local library: %v", err) - } else if got == nil { - t.Fatal("local library should remain after cloud logout") - } - var cloudMediaCount int64 - if err := repos.DB.Unscoped().Model(&model.Media{}).Where("path = ?", "cloud://openlist/TV/Movie.mkv").Count(&cloudMediaCount).Error; err != nil { - t.Fatalf("count cloud media: %v", err) - } - if cloudMediaCount != 0 { - t.Fatalf("cloud media should be purged after logout, count=%d", cloudMediaCount) - } -} - -func TestStorageConfigListHidesDeprecatedQuarkRows(t *testing.T) { - repos, storage := newStorageUploadTestService(t) - if err := repos.StorageConfig.Upsert(t.Context(), &model.StorageConfig{ - Type: LegacyQuarkProvider, - Config: storage.crypto.Encrypt(`{"cookie":"legacy"}`), - Enabled: true, - }); err != nil { - t.Fatalf("insert legacy quark row: %v", err) - } - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": "http://openlist.test", - "token": "token", - }, - }); err != nil { - t.Fatalf("save openlist row: %v", err) - } - - rows, err := storage.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 1 || rows[0].Type != "openlist" { - t.Fatalf("storage list = %#v, want only supported OpenList row", rows) - } -} diff --git a/internal/service/storage_config_probe.go b/internal/service/storage_config_probe.go deleted file mode 100644 index 8f34d3b..0000000 --- a/internal/service/storage_config_probe.go +++ /dev/null @@ -1,164 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "net/http" - "strconv" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -// Test runs a connection probe against the supplied (un-saved) config. -// The implementation is best-effort: it issues a single HEAD/PROPFIND -// to verify reachability, not full functionality. -func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error { - cfg := in.Config - if cfg == nil { - return errors.New("config required") - } - client := s.clientForConfig(cfg) - switch in.Type { - case "alist": - server := strings.TrimRight(strr(cfg["server"]), "/") - if server == "" { - return errors.New("alist missing server") - } - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, server+"/api/me", nil) - if tok := strr(cfg["token"]); tok != "" { - req.Header.Set("Authorization", tok) - } - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - return validateStorageProbeStatus("alist", resp.StatusCode) - case cloud.TypeOpenList: - p, err := cloud.New(in.Type, cfg, client) - if err != nil { - return err - } - return p.Ping(ctx) - case "webdav": - u := strr(cfg["url"]) - if u == "" { - return errors.New("webdav missing url") - } - req, _ := http.NewRequestWithContext(ctx, "PROPFIND", u, nil) - if user := strr(cfg["username"]); user != "" { - req.SetBasicAuth(user, strr(cfg["password"])) - } - req.Header.Set("Depth", "0") - resp, err := client.Do(req) - if err != nil { - return decorateStorageTransportError("webdav", u, err) - } - defer resp.Body.Close() - if resp.StatusCode >= 400 && resp.StatusCode != http.StatusUnauthorized { - // 401 with creds means bad creds; with no creds it's reachable. - if user := strr(cfg["username"]); user == "" && resp.StatusCode == http.StatusUnauthorized { - return nil - } - return fmt.Errorf("webdav returned %d", resp.StatusCode) - } - return nil - case "s3": - ep := strr(cfg["endpoint"]) - if ep == "" { - return errors.New("s3 missing endpoint") - } - // We only verify endpoint reachability — full SigV4 is a large - // dependency; the upstream Vue project also stops at this level. - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ep, nil) - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - return nil - case cloud.Type115, cloud.TypeCloudDrive2: - p, err := cloud.New(in.Type, cfg, client) - if err != nil { - return err - } - return p.Ping(ctx) - default: - return fmt.Errorf("unsupported storage type %q", in.Type) - } -} - -func (s *StorageConfigService) clientForConfig(cfg map[string]any) *http.Client { - if s == nil || s.client == nil { - return &http.Client{Timeout: 120 * time.Second} - } - timeout := storageTimeoutFromConfig(cfg, s.client.Timeout) - if timeout == s.client.Timeout { - return s.client - } - cp := *s.client - cp.Timeout = timeout - return &cp -} - -func storageTimeoutFromConfig(cfg map[string]any, fallback time.Duration) time.Duration { - if fallback <= 0 { - fallback = 120 * time.Second - } - raw := "" - for _, key := range []string{"timeout_seconds", "webdav_timeout_seconds", "request_timeout_seconds"} { - if value := strr(cfg[key]); value != "" { - raw = value - break - } - } - if raw == "" { - return fallback - } - seconds, err := strconv.Atoi(raw) - if err != nil { - if f, ferr := strconv.ParseFloat(raw, 64); ferr == nil { - seconds = int(f) - } - } - if seconds <= 0 { - return fallback - } - if seconds < 5 { - seconds = 5 - } - if seconds > 600 { - seconds = 600 - } - return time.Duration(seconds) * time.Second -} - -func validateStorageProbeStatus(name string, status int) error { - if status >= 200 && status < 300 { - return nil - } - if status == http.StatusUnauthorized || status == http.StatusForbidden { - return fmt.Errorf("%s authentication failed: http %d;请检查 Token / 用户名密码", name, status) - } - if status >= 300 { - return fmt.Errorf("%s returned %d", name, status) - } - return nil -} - -func decorateStorageTransportError(name, target string, err error) error { - if err == nil { - return nil - } - message := err.Error() - if strings.Contains(message, "server gave HTTP response to HTTPS client") { - return fmt.Errorf("%s: %w;当前地址使用 https://,但服务端返回 HTTP。请改用 http:// 地址;OpenList 默认 WebDAV 通常是 http://host:5244/dav/,管理页面/API 地址通常是 http://host:5244", name, err) - } - if strings.Contains(message, "first record does not look like a TLS handshake") { - return fmt.Errorf("%s: %w;疑似把 HTTP 服务配置成了 https://,请检查 %s 的协议头", name, err, target) - } - return err -} diff --git a/internal/service/storage_config_probe_test.go b/internal/service/storage_config_probe_test.go deleted file mode 100644 index a94c3c4..0000000 --- a/internal/service/storage_config_probe_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestStorageConfigOpenListHTTPSAgainstHTTPHint(t *testing.T) { - openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"code":200}`)) - })) - defer openlist.Close() - - _, storage := newStorageUploadTestService(t) - badHTTPS := "https://" + strings.TrimPrefix(openlist.URL, "http://") - err := storage.Test(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": badHTTPS, - }, - }) - if err == nil { - t.Fatal("want protocol mismatch error") - } - if !strings.Contains(err.Error(), "请改用 http://") || !strings.Contains(err.Error(), "server gave HTTP response to HTTPS client") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestStorageConfigOpenListTestRejectsUnauthorizedList(t *testing.T) { - openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/dav" { - t.Fatalf("unexpected openlist path %s", r.URL.Path) - } - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte("unauthorized")) - })) - defer openlist.Close() - - _, storage := newStorageUploadTestService(t) - err := storage.Test(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": openlist.URL, - }, - }) - if err == nil || !strings.Contains(err.Error(), "http 401") { - t.Fatalf("openlist unauthorized probe error = %v, want http 401", err) - } -} - -func TestStorageConfigOpenListTestUsesAPIListWithToken(t *testing.T) { - var listed bool - openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/fs/list" { - t.Fatalf("unexpected openlist path %s", r.URL.Path) - } - if r.Header.Get("Authorization") != "openlist-token" { - t.Fatalf("authorization = %q", r.Header.Get("Authorization")) - } - listed = true - _, _ = w.Write([]byte(`{"code":200,"data":{"content":[],"total":0}}`)) - })) - defer openlist.Close() - - _, storage := newStorageUploadTestService(t) - if err := storage.Test(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": openlist.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatalf("openlist API probe: %v", err) - } - if !listed { - t.Fatal("openlist test should probe /api/fs/list") - } -} - -func TestStorageConfigAlistTestRejectsUnauthorized(t *testing.T) { - alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/me" { - t.Fatalf("unexpected alist path %s", r.URL.Path) - } - w.WriteHeader(http.StatusUnauthorized) - })) - defer alist.Close() - - _, storage := newStorageUploadTestService(t) - err := storage.Test(t.Context(), StorageInput{ - Type: "alist", - Config: map[string]any{ - "server": alist.URL, - }, - }) - if err == nil || !strings.Contains(err.Error(), "authentication failed") { - t.Fatalf("alist unauthorized probe error = %v, want authentication failed", err) - } -} - -func TestStorageConfigCloudProviderRejectsDisabledConfig(t *testing.T) { - _, storage := newStorageUploadTestService(t) - enabled := false - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": "http://127.0.0.1:5244/dav", - }, - Enabled: &enabled, - }); err != nil { - t.Fatal(err) - } - _, err := storage.CloudProvider(t.Context(), "openlist") - if err == nil || !strings.Contains(err.Error(), "disabled") { - t.Fatalf("disabled provider error = %v, want disabled", err) - } -} - -func TestStorageConfigSavePreservesExistingSecretWhenFormLeavesItBlank(t *testing.T) { - _, storage := newStorageUploadTestService(t) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": "http://openlist.test", - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": "http://openlist.test", - "token": "", - "timeout_seconds": "180", - }, - }); err != nil { - t.Fatal(err) - } - view, err := storage.Get(t.Context(), "openlist") - if err != nil { - t.Fatal(err) - } - if view.Config["token"] != "openlist-token" { - t.Fatalf("token = %#v, want preserved token", view.Config["token"]) - } - if view.Config["timeout_seconds"] != "180" { - t.Fatalf("timeout_seconds = %#v, want updated timeout", view.Config["timeout_seconds"]) - } -} diff --git a/internal/service/storage_config_secrets.go b/internal/service/storage_config_secrets.go deleted file mode 100644 index d327fa9..0000000 --- a/internal/service/storage_config_secrets.go +++ /dev/null @@ -1,88 +0,0 @@ -// Package service — storage configuration secret preservation helpers. -package service - -import ( - "context" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -func shouldPreserveStorageSecretsOnSave(enabled *bool) bool { - return enabled == nil || *enabled -} - -func cloneStorageConfigMap(cfg map[string]any) map[string]any { - out := make(map[string]any, len(cfg)) - for k, v := range cfg { - out[k] = v - } - return out -} - -func (s *StorageConfigService) mergeExistingStorageSecrets(ctx context.Context, typ string, cfg map[string]any) (map[string]any, error) { - view, err := s.Get(ctx, typ) - if err != nil || view == nil { - return cfg, err - } - for _, key := range storagePreservedSecretKeys() { - existing := strings.TrimSpace(strr(view.Config[key])) - if existing == "" { - continue - } - incoming, hasIncoming := cfg[key] - if hasIncoming && !isBlankStorageSecret(incoming) { - continue - } - if storageSecretReplacedByAlternative(typ, key, cfg, view.Config) { - continue - } - cfg[key] = existing - } - return cfg, nil -} - -func storagePreservedSecretKeys() []string { - return []string{"password", "secret_key", "token", "cookie", "access_key"} -} - -func isBlankStorageSecret(value any) bool { - text := strings.TrimSpace(strr(value)) - return text == "" || text == "********" -} - -func storageSecretReplacedByAlternative(typ, key string, cfg, existing map[string]any) bool { - switch typ { - case cloud.TypeOpenList: - switch key { - case "token": - return strings.TrimSpace(strr(cfg["username"])) != "" && strings.TrimSpace(strr(cfg["password"])) != "" - case "password": - if strings.TrimSpace(strr(cfg["token"])) != "" { - return true - } - return storagePlainFieldChanged("username", cfg, existing) - } - case "webdav", cloud.TypeCloudDrive2: - if key == "password" { - if strings.TrimSpace(strr(cfg["token"])) != "" { - return true - } - return storagePlainFieldChanged("username", cfg, existing) - } - case "s3": - if key == "secret_key" { - return storagePlainFieldChanged("access_key", cfg, existing) - } - } - return false -} - -func storagePlainFieldChanged(key string, cfg, existing map[string]any) bool { - incoming := strings.TrimSpace(strr(cfg[key])) - if incoming == "" { - return false - } - current := strings.TrimSpace(strr(existing[key])) - return current != "" && incoming != current -} diff --git a/internal/service/storage_test.go b/internal/service/storage_test.go deleted file mode 100644 index b86f532..0000000 --- a/internal/service/storage_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package service - -import ( - "slices" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" - "go.uber.org/zap" -) - -func TestStorageBreakdownUsesCanonicalLibraryDisplay(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - libs := []model.Library{ - {Name: "外语电影", Path: "/media/电影/外语电影", Type: "movie", Enabled: true}, - {Name: "欧美动漫", Path: "/media/动漫/欧美动漫", Type: "tv", Enabled: true}, - {Name: "9KG", Path: "/media/成人/9KG", Type: "movie", Enabled: true}, - } - for i := range libs { - if err := repos.Library.Create(t.Context(), &libs[i]); err != nil { - t.Fatal(err) - } - if err := repos.Media.Upsert(t.Context(), &model.Media{ - LibraryID: libs[i].ID, - Title: libs[i].Name, - Path: libs[i].Path + "/item.mkv", - SizeBytes: 1024, - }); err != nil { - t.Fatal(err) - } - } - - breakdown, err := NewStorageService(zap.NewNop(), repos).Compute(t.Context()) - if err != nil { - t.Fatal(err) - } - gotNames := make([]string, 0, len(breakdown.ByLibrary)) - gotTypes := make([]string, 0, len(breakdown.ByLibrary)) - for _, row := range breakdown.ByLibrary { - gotNames = append(gotNames, row.Name) - gotTypes = append(gotTypes, row.Type) - } - if want := []string{"欧美电影", "美漫", "成人"}; !slices.Equal(gotNames, want) { - t.Fatalf("library names = %#v, want %#v", gotNames, want) - } - if want := []string{"movie", "anime", "adult"}; !slices.Equal(gotTypes, want) { - t.Fatalf("library types = %#v, want %#v", gotTypes, want) - } -} diff --git a/internal/service/storage_types.go b/internal/service/storage_types.go deleted file mode 100644 index 72ef2a9..0000000 --- a/internal/service/storage_types.go +++ /dev/null @@ -1,31 +0,0 @@ -package service - -import ( - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -const LegacyQuarkProvider = "quark" - -func IsAdminStorageConfigurable(typ string) bool { - switch strings.TrimSpace(typ) { - case cloud.TypeOpenList, "alist", "webdav", cloud.TypeCloudDrive2, cloud.Type115: - return true - default: - return false - } -} - -func IsAdminCloudConfigurable(typ string) bool { - switch strings.TrimSpace(typ) { - case cloud.Type115, cloud.TypeCloudDrive2, cloud.TypeOpenList: - return true - default: - return false - } -} - -func IsDeprecatedNativeCloudProvider(typ string) bool { - return strings.TrimSpace(typ) == LegacyQuarkProvider -} diff --git a/internal/service/storage_types_test.go b/internal/service/storage_types_test.go deleted file mode 100644 index 4a3643e..0000000 --- a/internal/service/storage_types_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package service - -import "testing" - -func TestAdminStorageConfigurableTypes(t *testing.T) { - for _, typ := range []string{"openlist", "alist", "webdav", "clouddrive2", "cloud115"} { - if !IsAdminStorageConfigurable(typ) { - t.Fatalf("%s should be configurable", typ) - } - } - for _, typ := range []string{"quark", "s3", "", "unknown"} { - if IsAdminStorageConfigurable(typ) { - t.Fatalf("%s should not be configurable", typ) - } - } -} - -func TestAdminCloudConfigurableTypes(t *testing.T) { - for _, typ := range []string{"openlist", "clouddrive2", "cloud115"} { - if !IsAdminCloudConfigurable(typ) { - t.Fatalf("%s should be cloud-configurable", typ) - } - } - for _, typ := range []string{"quark", "alist", "webdav", "s3", ""} { - if IsAdminCloudConfigurable(typ) { - t.Fatalf("%s should not be cloud-configurable", typ) - } - } -} diff --git a/internal/service/storage_upload.go b/internal/service/storage_upload.go deleted file mode 100644 index 12bfb42..0000000 --- a/internal/service/storage_upload.go +++ /dev/null @@ -1,278 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "os" - "path" - "path/filepath" - "strings" -) - -const ( - CloudUploadAutoEnabledKey = "cloud.upload_auto_enabled" - CloudUploadProviderKey = "cloud.upload_provider" - CloudUploadSourceDirKey = "cloud.upload_source_dir" - CloudUploadDestPathKey = "cloud.upload_dest_path" - CloudUploadRecursiveKey = "cloud.upload_recursive" - CloudUploadSidecarsKey = "cloud.upload_sidecars" - CloudUploadOverwriteKey = "cloud.upload_overwrite" - CloudUploadTransferModeKey = "cloud.upload_transfer_mode" - CloudUploadIntervalSecondsKey = "cloud.upload_interval_seconds" - CloudUploadUnsupportedProvider = "本地文件直传目前支持 Alist / OpenList / WebDAV / CloudDrive2;115 原生上传需要分片上传私有接口,建议先用 CloudDrive2、OpenList 或 Alist 桥接后转存。" -) - -type CloudUploadInput struct { - Type string `json:"type"` - SourcePath string `json:"source_path"` - DestPath string `json:"dest_path"` - Recursive bool `json:"recursive"` - IncludeSidecars bool `json:"include_sidecars"` - Overwrite bool `json:"overwrite"` - TransferMode string `json:"transfer_mode"` -} - -type CloudUploadResult struct { - SourcePath string `json:"source_path"` - DestPath string `json:"dest_path"` - Uploaded int `json:"uploaded"` - Moved int `json:"moved,omitempty"` - Skipped int `json:"skipped"` - Bytes int64 `json:"bytes"` - Errors []string `json:"errors,omitempty"` - Items []CloudUploadResultItem `json:"items,omitempty"` -} - -type CloudUploadResultItem struct { - Source string `json:"source"` - Target string `json:"target"` - Action string `json:"action"` // upload / move / skip / error - Size int64 `json:"size,omitempty"` - Reason string `json:"reason,omitempty"` -} - -type storageUploader interface { - ensureDir(ctx context.Context, remoteDir string) error - exists(ctx context.Context, remotePath string) (bool, error) - upload(ctx context.Context, localPath, remotePath string, size int64) error -} - -var cloudUploadSidecarExtensions = map[string]struct{}{ - ".nfo": {}, ".jpg": {}, ".jpeg": {}, ".png": {}, ".webp": {}, - ".srt": {}, ".ass": {}, ".ssa": {}, ".vtt": {}, ".sub": {}, ".idx": {}, -} - -// UploadLocal copies or moves local media files into an external storage -// backend. Cloud writes are intentionally gated by the per-storage -// transfer_enabled switch so mounting/scanning a cloud provider does not -// accidentally make it writable. -func (s *StorageConfigService) UploadLocal(ctx context.Context, in CloudUploadInput) (*CloudUploadResult, error) { - in.Type = strings.TrimSpace(in.Type) - in.SourcePath = strings.TrimSpace(in.SourcePath) - in.DestPath = normalizeRemotePath(in.DestPath) - if in.SourcePath == "" { - return nil, errors.New("source_path required") - } - view, err := s.Get(ctx, in.Type) - if err != nil { - return nil, err - } - if view == nil || !view.Enabled { - return nil, fmt.Errorf("%s storage not configured", in.Type) - } - if !parseBoolSetting(strr(view.Config["transfer_enabled"]), false) { - return nil, errors.New("cloud transfer is disabled for this storage; enable transfer in external storage settings before writing to cloud") - } - mode := resolveCloudUploadTransferMode(in.TransferMode, strr(view.Config["transfer_mode"]), s.settingValue(ctx, CloudUploadTransferModeKey)) - if mode != TransferCopy && mode != TransferMove { - return nil, errors.New("transfer_mode must be copy or move") - } - in.TransferMode = string(mode) - uploader, err := s.uploaderForView(in.Type, view) - if err != nil { - return nil, err - } - info, err := os.Stat(in.SourcePath) - if err != nil { - return nil, fmt.Errorf("source path not accessible: %w", err) - } - result := &CloudUploadResult{SourcePath: in.SourcePath, DestPath: in.DestPath} - if !info.IsDir() { - s.uploadOne(ctx, uploader, in, in.SourcePath, filepath.Base(in.SourcePath), info.Size(), result) - return result, firstUploadError(result) - } - root := filepath.Clean(in.SourcePath) - walkFn := func(localPath string, entryInfo os.FileInfo, walkErr error) error { - if walkErr != nil { - addUploadError(result, localPath, "", walkErr) - return nil - } - if entryInfo == nil || entryInfo.IsDir() { - if !in.Recursive && filepath.Clean(localPath) != root { - return filepath.SkipDir - } - return nil - } - if !eligibleCloudUploadFile(localPath, in.IncludeSidecars) { - return nil - } - rel, err := filepath.Rel(root, localPath) - if err != nil { - addUploadError(result, localPath, "", err) - return nil - } - s.uploadOne(ctx, uploader, in, localPath, filepath.ToSlash(rel), entryInfo.Size(), result) - return nil - } - if err := filepath.Walk(in.SourcePath, walkFn); err != nil { - return result, err - } - return result, firstUploadError(result) -} - -func (s *StorageConfigService) uploader(ctx context.Context, typ string) (storageUploader, error) { - view, err := s.Get(ctx, typ) - if err != nil { - return nil, err - } - if view == nil || !view.Enabled { - return nil, fmt.Errorf("%s storage not configured", typ) - } - return s.uploaderForView(typ, view) -} - -func (s *StorageConfigService) uploaderForView(typ string, view *StorageView) (storageUploader, error) { - if view == nil || !view.Enabled { - return nil, fmt.Errorf("%s storage not configured", typ) - } - switch typ { - case "alist": - return newAlistUploader(view.Config), nil - case "openlist": - if strings.TrimSpace(strr(view.Config["server"])) != "" { - return newNamedAlistUploader("openlist", view.Config), nil - } - return newWebDAVUploader(view.Config), nil - case "webdav": - return newWebDAVUploader(view.Config), nil - case "clouddrive2": - return newWebDAVUploader(view.Config), nil - case "s3": - return nil, errors.New("s3 local upload is not implemented yet") - case "cloud115": - return nil, errors.New(CloudUploadUnsupportedProvider) - default: - return nil, fmt.Errorf("unsupported storage type %q", typ) - } -} - -func (s *StorageConfigService) uploadOne(ctx context.Context, uploader storageUploader, in CloudUploadInput, localPath, rel string, size int64, result *CloudUploadResult) { - remotePath := joinRemotePath(in.DestPath, rel) - if err := uploader.ensureDir(ctx, path.Dir(remotePath)); err != nil { - addUploadError(result, localPath, remotePath, err) - return - } - if !in.Overwrite { - exists, err := uploader.exists(ctx, remotePath) - if err != nil { - addUploadError(result, localPath, remotePath, err) - return - } - if exists { - result.Skipped++ - addUploadItem(result, CloudUploadResultItem{Source: localPath, Target: remotePath, Action: "skip", Size: size, Reason: "remote exists"}) - return - } - } - if err := uploader.upload(ctx, localPath, remotePath, size); err != nil { - addUploadError(result, localPath, remotePath, err) - return - } - result.Uploaded++ - result.Bytes += size - action := "upload" - mode := resolveCloudUploadTransferMode(in.TransferMode) - if mode == TransferMove { - if err := os.Remove(localPath); err != nil { - addUploadError(result, localPath, remotePath, fmt.Errorf("uploaded but failed to remove local source: %w", err)) - addUploadItem(result, CloudUploadResultItem{Source: localPath, Target: remotePath, Action: "upload", Size: size, Reason: "source remove failed"}) - return - } - result.Moved++ - action = "move" - } - addUploadItem(result, CloudUploadResultItem{Source: localPath, Target: remotePath, Action: action, Size: size}) -} - -func (s *StorageConfigService) settingValue(ctx context.Context, key string) string { - if s == nil || s.repo == nil || s.repo.Setting == nil { - return "" - } - v, _ := s.repo.Setting.Get(ctx, key) - return strings.TrimSpace(v) -} - -func resolveCloudUploadTransferMode(values ...string) TransferMode { - for _, value := range values { - switch strings.ToLower(strings.TrimSpace(value)) { - case "move", "移动": - return TransferMove - case "copy", "复制": - return TransferCopy - } - } - return TransferCopy -} - -func eligibleCloudUploadFile(localPath string, includeSidecars bool) bool { - ext := strings.ToLower(filepath.Ext(localPath)) - if _, ok := videoExtensions[ext]; ok { - return true - } - if includeSidecars { - _, ok := cloudUploadSidecarExtensions[ext] - return ok - } - return false -} - -func addUploadError(result *CloudUploadResult, source, target string, err error) { - result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", source, err)) - addUploadItem(result, CloudUploadResultItem{Source: source, Target: target, Action: "error", Reason: err.Error()}) -} - -func addUploadItem(result *CloudUploadResult, item CloudUploadResultItem) { - if len(result.Items) < 200 { - result.Items = append(result.Items, item) - } -} - -func firstUploadError(result *CloudUploadResult) error { - if result.Uploaded > 0 || len(result.Errors) == 0 { - return nil - } - return errors.New(result.Errors[0]) -} - -func normalizeRemotePath(p string) string { - p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/") - if p == "" || p == "." { - return "/" - } - if !strings.HasPrefix(p, "/") { - p = "/" + p - } - return path.Clean(p) -} - -func joinRemotePath(base, rel string) string { - parts := []string{normalizeRemotePath(base)} - for _, part := range strings.Split(strings.ReplaceAll(rel, "\\", "/"), "/") { - part = strings.TrimSpace(part) - if part != "" && part != "." { - parts = append(parts, part) - } - } - return path.Clean(path.Join(parts...)) -} diff --git a/internal/service/storage_upload_alist.go b/internal/service/storage_upload_alist.go deleted file mode 100644 index 7e0aebf..0000000 --- a/internal/service/storage_upload_alist.go +++ /dev/null @@ -1,208 +0,0 @@ -package service - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path" - "strings" -) - -type alistUploader struct { - name string - server string - token string - username string - password string - client *http.Client -} - -func newAlistUploader(cfg map[string]any) *alistUploader { - return newNamedAlistUploader("alist", cfg) -} - -func newNamedAlistUploader(name string, cfg map[string]any) *alistUploader { - return &alistUploader{ - name: name, - server: strings.TrimRight(strr(cfg["server"]), "/"), - token: strr(cfg["token"]), - username: strr(cfg["username"]), - password: strr(cfg["password"]), - client: &http.Client{}, - } -} - -func (a *alistUploader) ensureDir(ctx context.Context, remoteDir string) error { - if a.server == "" { - return fmt.Errorf("%s missing server", a.name) - } - if err := a.ensureToken(ctx); err != nil { - return err - } - remoteDir = normalizeRemotePath(remoteDir) - if remoteDir == "/" { - return nil - } - current := "" - for _, part := range strings.Split(strings.Trim(remoteDir, "/"), "/") { - current = normalizeRemotePath(path.Join(current, part)) - payload, _ := json.Marshal(map[string]string{"path": current}) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.server+"/api/fs/mkdir", bytes.NewReader(payload)) - if err != nil { - return err - } - a.auth(req) - req.Header.Set("Content-Type", "application/json") - resp, err := a.client.Do(req) - if err != nil { - return decorateStorageTransportError(a.name, a.server, err) - } - err = a.checkJSON(resp, "alist mkdir") - if err != nil && !isAlreadyExistsMessage(err.Error()) { - return err - } - } - return nil -} - -func (a *alistUploader) exists(ctx context.Context, remotePath string) (bool, error) { - if err := a.ensureToken(ctx); err != nil { - return false, err - } - payload, _ := json.Marshal(map[string]string{"path": normalizeRemotePath(remotePath)}) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.server+"/api/fs/get", bytes.NewReader(payload)) - if err != nil { - return false, err - } - a.auth(req) - req.Header.Set("Content-Type", "application/json") - resp, err := a.client.Do(req) - if err != nil { - return false, decorateStorageTransportError(a.name, a.server, err) - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return false, nil - } - var out struct { - Code int `json:"code"` - Message string `json:"message"` - } - _ = json.NewDecoder(resp.Body).Decode(&out) - return resp.StatusCode >= 200 && resp.StatusCode < 300 && out.Code == 200, nil -} - -func (a *alistUploader) upload(ctx context.Context, localPath, remotePath string, size int64) error { - if err := a.ensureToken(ctx); err != nil { - return err - } - f, err := os.Open(localPath) // #nosec G304 -- localPath is selected from configured local media files before upload. - if err != nil { - return err - } - defer f.Close() - req, err := http.NewRequestWithContext(ctx, http.MethodPut, a.server+"/api/fs/put", f) - if err != nil { - return err - } - a.auth(req) - req.ContentLength = size - req.Header.Set("Content-Type", "application/octet-stream") - req.Header.Set("File-Path", url.PathEscape(normalizeRemotePath(remotePath))) - resp, err := a.client.Do(req) - if err != nil { - return decorateStorageTransportError(a.name, a.server, err) - } - return a.checkJSON(resp, "alist upload") -} - -func (a *alistUploader) ensureToken(ctx context.Context) error { - if strings.TrimSpace(a.token) != "" { - return nil - } - if strings.TrimSpace(a.username) == "" || a.password == "" { - return nil - } - if a.server == "" { - return fmt.Errorf("%s missing server", a.name) - } - payload, _ := json.Marshal(map[string]string{ - "username": a.username, - "password": a.password, - }) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.server+"/api/auth/login", bytes.NewReader(payload)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - resp, err := a.client.Do(req) - if err != nil { - return decorateStorageTransportError(a.name, a.server, err) - } - defer resp.Body.Close() - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("%s login: http %d: %s", a.name, resp.StatusCode, strings.TrimSpace(string(body))) - } - var out struct { - Code int `json:"code"` - Message string `json:"message"` - Data struct { - Token string `json:"token"` - } `json:"data"` - } - if err := json.Unmarshal(body, &out); err != nil { - return fmt.Errorf("%s login: decode response: %w", a.name, err) - } - if out.Code != 0 && out.Code != 200 { - msg := strings.TrimSpace(out.Message) - if msg == "" { - msg = fmt.Sprintf("code %d", out.Code) - } - return fmt.Errorf("%s login: %s", a.name, msg) - } - a.token = strings.TrimSpace(out.Data.Token) - if a.token == "" { - return fmt.Errorf("%s login returned empty token", a.name) - } - return nil -} - -func (a *alistUploader) auth(req *http.Request) { - if a.token != "" { - req.Header.Set("Authorization", a.token) - } -} - -func (a *alistUploader) checkJSON(resp *http.Response, op string) error { - defer resp.Body.Close() - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("%s: http %d: %s", op, resp.StatusCode, strings.TrimSpace(string(body))) - } - var out struct { - Code int `json:"code"` - Message string `json:"message"` - } - if len(bytes.TrimSpace(body)) == 0 { - return nil - } - if err := json.Unmarshal(body, &out); err != nil { - return nil - } - if out.Code != 0 && out.Code != 200 { - return fmt.Errorf("%s: %s", op, out.Message) - } - return nil -} - -func isAlreadyExistsMessage(message string) bool { - message = strings.ToLower(message) - return strings.Contains(message, "exist") || strings.Contains(message, "已存在") -} diff --git a/internal/service/storage_upload_jobs_test.go b/internal/service/storage_upload_jobs_test.go deleted file mode 100644 index 468c765..0000000 --- a/internal/service/storage_upload_jobs_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "strings" - "testing" - - "go.uber.org/zap" -) - -func TestSchedulerCloudUploadUsesConfiguredLocalSource(t *testing.T) { - var uploaded []string - alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/fs/mkdir": - _, _ = w.Write([]byte(`{"code":200}`)) - case "/api/fs/get": - w.WriteHeader(http.StatusNotFound) - case "/api/fs/put": - decoded, _ := url.PathUnescape(r.Header.Get("File-Path")) - uploaded = append(uploaded, decoded) - _, _ = w.Write([]byte(`{"code":200}`)) - default: - t.Fatalf("unexpected alist path %s", r.URL.Path) - } - })) - defer alist.Close() - - repos, storage := newStorageUploadTestService(t) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "alist", - Config: map[string]any{ - "server": alist.URL, - "token": "token", - "transfer_enabled": "true", - }, - }); err != nil { - t.Fatal(err) - } - source := t.TempDir() - if err := os.WriteFile(filepath.Join(source, "Show.S01E01.mkv"), []byte("episode"), 0o644); err != nil { - t.Fatal(err) - } - for key, value := range map[string]string{ - CloudUploadAutoEnabledKey: "true", - CloudUploadProviderKey: "alist", - CloudUploadSourceDirKey: source, - CloudUploadDestPathKey: "/cloud-media", - CloudUploadRecursiveKey: "true", - CloudUploadSidecarsKey: "false", - } { - if err := repos.Setting.Set(t.Context(), key, value); err != nil { - t.Fatal(err) - } - } - scheduler := NewSchedulerService(zap.NewNop(), repos, nil, nil, nil, storage, NewHub(zap.NewNop()), "") - if err := scheduler.jobUploadLocalToCloud(t.Context()); err != nil { - t.Fatalf("cloud upload job: %v", err) - } - if len(uploaded) != 1 || uploaded[0] != "/cloud-media/Show.S01E01.mkv" { - t.Fatalf("uploaded = %#v", uploaded) - } -} - -func TestStorageConfigUploadLocalToCloudDrive2(t *testing.T) { - var uploaded []string - dav := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "MKCOL": - w.WriteHeader(http.StatusCreated) - case http.MethodHead: - w.WriteHeader(http.StatusNotFound) - case http.MethodPut: - uploaded = append(uploaded, r.URL.Path) - w.WriteHeader(http.StatusCreated) - default: - t.Fatalf("unexpected method %s %s", r.Method, r.URL.Path) - } - })) - defer dav.Close() - - _, storage := newStorageUploadTestService(t) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "clouddrive2", - Config: map[string]any{ - "url": dav.URL + "/dav", - "username": "user", - "password": "pass", - "transfer_enabled": "true", - }, - }); err != nil { - t.Fatal(err) - } - source := t.TempDir() - if err := os.WriteFile(filepath.Join(source, "Movie.mkv"), []byte("movie"), 0o644); err != nil { - t.Fatal(err) - } - res, err := storage.UploadLocal(t.Context(), CloudUploadInput{ - Type: "clouddrive2", - SourcePath: source, - DestPath: "/MediaStationGo", - Recursive: true, - }) - if err != nil { - t.Fatalf("upload local: %v", err) - } - if res.Uploaded != 1 || len(uploaded) != 1 || uploaded[0] != "/dav/MediaStationGo/Movie.mkv" { - t.Fatalf("result = %+v uploaded=%#v", res, uploaded) - } -} - -func TestStorageConfigUploadLocalRequiresTransferEnabled(t *testing.T) { - _, storage := newStorageUploadTestService(t) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "alist", - Config: map[string]any{ - "server": "http://alist.test", - "token": "token", - }, - }); err != nil { - t.Fatal(err) - } - source := t.TempDir() - if err := os.WriteFile(filepath.Join(source, "Movie.mkv"), []byte("movie"), 0o644); err != nil { - t.Fatal(err) - } - _, err := storage.UploadLocal(t.Context(), CloudUploadInput{ - Type: "alist", - SourcePath: source, - DestPath: "/MediaStationGo", - Recursive: true, - }) - if err == nil || !strings.Contains(err.Error(), "transfer is disabled") { - t.Fatalf("upload error = %v, want transfer disabled", err) - } -} - -func TestStorageConfigUploadLocalMoveDeletesSourceAfterUpload(t *testing.T) { - var uploaded []string - alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/fs/mkdir": - _, _ = w.Write([]byte(`{"code":200}`)) - case "/api/fs/get": - w.WriteHeader(http.StatusNotFound) - case "/api/fs/put": - decoded, _ := url.PathUnescape(r.Header.Get("File-Path")) - uploaded = append(uploaded, decoded) - _, _ = w.Write([]byte(`{"code":200}`)) - default: - t.Fatalf("unexpected alist path %s", r.URL.Path) - } - })) - defer alist.Close() - - _, storage := newStorageUploadTestService(t) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "alist", - Config: map[string]any{ - "server": alist.URL, - "token": "token", - "transfer_enabled": "true", - "transfer_mode": "move", - }, - }); err != nil { - t.Fatal(err) - } - source := t.TempDir() - file := filepath.Join(source, "Movie.mkv") - if err := os.WriteFile(file, []byte("movie"), 0o644); err != nil { - t.Fatal(err) - } - res, err := storage.UploadLocal(t.Context(), CloudUploadInput{ - Type: "alist", - SourcePath: source, - DestPath: "/MediaStationGo", - Recursive: true, - }) - if err != nil { - t.Fatalf("upload local move: %v", err) - } - if res.Uploaded != 1 || res.Moved != 1 || len(uploaded) != 1 { - t.Fatalf("result = %+v uploaded=%#v", res, uploaded) - } - if _, err := os.Stat(file); !os.IsNotExist(err) { - t.Fatalf("source should be removed after move upload, stat err=%v", err) - } -} diff --git a/internal/service/storage_upload_test.go b/internal/service/storage_upload_test.go deleted file mode 100644 index d39036f..0000000 --- a/internal/service/storage_upload_test.go +++ /dev/null @@ -1,233 +0,0 @@ -package service - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "sort" - "strings" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestStorageConfigUploadLocalToAlist(t *testing.T) { - var uploaded []string - var authHeaders []string - alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/fs/mkdir": - _ = json.NewDecoder(r.Body).Decode(&map[string]string{}) - _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) - case "/api/fs/get": - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"code":404,"message":"not found"}`)) - case "/api/fs/put": - authHeaders = append(authHeaders, r.Header.Get("Authorization")) - decoded, err := url.PathUnescape(r.Header.Get("File-Path")) - if err != nil { - t.Fatalf("decode file path: %v", err) - } - uploaded = append(uploaded, decoded) - _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) - default: - t.Fatalf("unexpected alist path %s", r.URL.Path) - } - })) - defer alist.Close() - - repos, storage := newStorageUploadTestService(t) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "alist", - Config: map[string]any{ - "server": alist.URL, - "token": "alist-token", - "transfer_enabled": "true", - }, - }); err != nil { - t.Fatal(err) - } - source := t.TempDir() - if err := os.WriteFile(filepath.Join(source, "Movie.2026.mkv"), []byte("movie"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(source, "Movie.2026.nfo"), []byte("nfo"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(source, "ignore.txt"), []byte("txt"), 0o644); err != nil { - t.Fatal(err) - } - - res, err := storage.UploadLocal(t.Context(), CloudUploadInput{ - Type: "alist", - SourcePath: source, - DestPath: "/backup", - Recursive: true, - IncludeSidecars: true, - }) - if err != nil { - t.Fatalf("upload local: %v", err) - } - if res.Uploaded != 2 || res.Skipped != 0 || len(res.Errors) != 0 { - t.Fatalf("result = %+v", res) - } - sort.Strings(uploaded) - want := []string{"/backup/Movie.2026.mkv", "/backup/Movie.2026.nfo"} - if strings.Join(uploaded, "\n") != strings.Join(want, "\n") { - t.Fatalf("uploaded = %#v, want %#v", uploaded, want) - } - for _, header := range authHeaders { - if header != "alist-token" { - t.Fatalf("authorization header = %q", header) - } - } - if got, _ := repos.StorageConfig.Get(t.Context(), "alist"); got == nil { - t.Fatalf("storage config should remain saved") - } -} - -func TestStorageConfigUploadLocalToOpenListAPI(t *testing.T) { - var uploaded []string - openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/fs/mkdir": - _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) - case "/api/fs/get": - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"code":404,"message":"not found"}`)) - case "/api/fs/put": - if r.Header.Get("Authorization") != "openlist-token" { - t.Fatalf("authorization = %q", r.Header.Get("Authorization")) - } - decoded, err := url.PathUnescape(r.Header.Get("File-Path")) - if err != nil { - t.Fatalf("decode file path: %v", err) - } - uploaded = append(uploaded, decoded) - _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) - default: - t.Fatalf("unexpected openlist path %s", r.URL.Path) - } - })) - defer openlist.Close() - - _, storage := newStorageUploadTestService(t) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": openlist.URL, - "token": "openlist-token", - "transfer_enabled": "true", - }, - }); err != nil { - t.Fatal(err) - } - source := t.TempDir() - if err := os.WriteFile(filepath.Join(source, "Movie.2026.mkv"), []byte("movie"), 0o644); err != nil { - t.Fatal(err) - } - res, err := storage.UploadLocal(t.Context(), CloudUploadInput{ - Type: "openlist", - SourcePath: source, - DestPath: "/OpenList", - Recursive: true, - }) - if err != nil { - t.Fatalf("upload local: %v", err) - } - if res.Uploaded != 1 || len(uploaded) != 1 || uploaded[0] != "/OpenList/Movie.2026.mkv" { - t.Fatalf("result = %+v uploaded=%#v", res, uploaded) - } -} - -func TestStorageConfigUploadLocalToOpenListAPIWithUsernamePassword(t *testing.T) { - var loginSeen bool - var uploaded []string - openlist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/auth/login": - loginSeen = true - var body map[string]string - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode login body: %v", err) - } - if body["username"] != "alice" || body["password"] != "secret" { - t.Fatalf("login body = %#v", body) - } - _, _ = w.Write([]byte(`{"code":200,"data":{"token":"openlist-session-token"}}`)) - case "/api/fs/mkdir": - if r.Header.Get("Authorization") != "openlist-session-token" { - t.Fatalf("mkdir authorization = %q", r.Header.Get("Authorization")) - } - _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) - case "/api/fs/get": - if r.Header.Get("Authorization") != "openlist-session-token" { - t.Fatalf("get authorization = %q", r.Header.Get("Authorization")) - } - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"code":404,"message":"not found"}`)) - case "/api/fs/put": - if r.Header.Get("Authorization") != "openlist-session-token" { - t.Fatalf("put authorization = %q", r.Header.Get("Authorization")) - } - decoded, err := url.PathUnescape(r.Header.Get("File-Path")) - if err != nil { - t.Fatalf("decode file path: %v", err) - } - uploaded = append(uploaded, decoded) - _, _ = w.Write([]byte(`{"code":200,"message":"success"}`)) - case "/dav": - t.Fatal("OpenList username/password upload should use API, not WebDAV") - default: - t.Fatalf("unexpected openlist path %s", r.URL.Path) - } - })) - defer openlist.Close() - - _, storage := newStorageUploadTestService(t) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "server": openlist.URL, - "username": "alice", - "password": "secret", - "transfer_enabled": "true", - }, - }); err != nil { - t.Fatal(err) - } - source := t.TempDir() - if err := os.WriteFile(filepath.Join(source, "Movie.2026.mkv"), []byte("movie"), 0o644); err != nil { - t.Fatal(err) - } - res, err := storage.UploadLocal(t.Context(), CloudUploadInput{ - Type: "openlist", - SourcePath: source, - DestPath: "/OpenList", - Recursive: true, - }) - if err != nil { - t.Fatalf("upload local: %v", err) - } - if !loginSeen { - t.Fatal("expected OpenList API login") - } - if res.Uploaded != 1 || len(uploaded) != 1 || uploaded[0] != "/OpenList/Movie.2026.mkv" { - t.Fatalf("result = %+v uploaded=%#v", res, uploaded) - } -} - -func newStorageUploadTestService(t *testing.T) (*repository.Container, *StorageConfigService) { - t.Helper() - db := newServiceTestDB(t, &model.StorageConfig{}, &model.Setting{}, &model.Library{}, &model.Media{}) - repos := repository.New(db) - log := zap.NewNop() - return repos, NewStorageConfigService(log, repos, NewCryptoService("", log)) -} diff --git a/internal/service/storage_upload_webdav.go b/internal/service/storage_upload_webdav.go deleted file mode 100644 index 53817c6..0000000 --- a/internal/service/storage_upload_webdav.go +++ /dev/null @@ -1,128 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path" - "strings" -) - -type webDAVUploader struct { - base *url.URL - username string - password string - client *http.Client -} - -func newWebDAVUploader(cfg map[string]any) *webDAVUploader { - u, _ := url.Parse(strings.TrimRight(strr(cfg["url"]), "/")) - return &webDAVUploader{ - base: u, - username: strr(cfg["username"]), - password: strr(cfg["password"]), - client: &http.Client{}, - } -} - -func (w *webDAVUploader) ensureDir(ctx context.Context, remoteDir string) error { - if w.base == nil || w.base.Scheme == "" || w.base.Host == "" { - return errors.New("webdav missing url") - } - remoteDir = normalizeRemotePath(remoteDir) - if remoteDir == "/" { - return nil - } - current := "" - for _, part := range strings.Split(strings.Trim(remoteDir, "/"), "/") { - current = normalizeRemotePath(path.Join(current, part)) - req, err := http.NewRequestWithContext(ctx, "MKCOL", w.urlFor(current), nil) - if err != nil { - return err - } - w.auth(req) - resp, err := w.client.Do(req) - if err != nil { - return decorateStorageTransportError("webdav", w.urlFor(current), err) - } - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - continue - } - if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusConflict { - continue - } - return fmt.Errorf("webdav mkdir %s: http %d", current, resp.StatusCode) - } - return nil -} - -func (w *webDAVUploader) exists(ctx context.Context, remotePath string) (bool, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodHead, w.urlFor(remotePath), nil) - if err != nil { - return false, err - } - w.auth(req) - resp, err := w.client.Do(req) - if err != nil { - return false, decorateStorageTransportError("webdav", w.urlFor(remotePath), err) - } - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return false, nil - } - return resp.StatusCode >= 200 && resp.StatusCode < 300, nil -} - -func (w *webDAVUploader) upload(ctx context.Context, localPath, remotePath string, size int64) error { - f, err := os.Open(localPath) // #nosec G304 -- localPath is selected from configured local media files before upload. - if err != nil { - return err - } - defer f.Close() - req, err := http.NewRequestWithContext(ctx, http.MethodPut, w.urlFor(remotePath), f) - if err != nil { - return err - } - w.auth(req) - req.ContentLength = size - resp, err := w.client.Do(req) - if err != nil { - return decorateStorageTransportError("webdav", w.urlFor(remotePath), err) - } - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("webdav upload %s: http %d", remotePath, resp.StatusCode) - } - return nil -} - -func (w *webDAVUploader) auth(req *http.Request) { - if w.username != "" { - req.SetBasicAuth(w.username, w.password) - } -} - -func (w *webDAVUploader) urlFor(remotePath string) string { - u := *w.base - basePath := strings.TrimRight(u.EscapedPath(), "/") - segments := make([]string, 0) - if basePath != "" && basePath != "/" { - segments = append(segments, strings.Trim(basePath, "/")) - } - for _, part := range strings.Split(strings.Trim(normalizeRemotePath(remotePath), "/"), "/") { - if part != "" { - segments = append(segments, url.PathEscape(part)) - } - } - u.RawPath = "" - u.Path = "/" + strings.Join(segments, "/") - return u.String() -} diff --git a/internal/service/strm_generate.go b/internal/service/strm_generate.go deleted file mode 100644 index 0d2628e..0000000 --- a/internal/service/strm_generate.go +++ /dev/null @@ -1,309 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -type GenerateSTRMOptions struct { - LibraryID string `json:"library_id"` - OutputDir string `json:"output_dir"` - BaseURL string `json:"base_url,omitempty"` - Enabled bool `json:"enabled"` - Overwrite bool `json:"overwrite"` - IncludeLocal bool `json:"include_local"` - PreserveTree bool `json:"preserve_tree"` - PlaybackToken string `json:"-"` - SkipSettingsSave bool `json:"-"` -} - -const strmTreeIgnoredItemSampleLimit = 20 - -type GenerateSTRMResult struct { - LibraryID string `json:"library_id"` - OutputDir string `json:"output_dir"` - Generated int `json:"generated"` - Updated int `json:"updated"` - Skipped int `json:"skipped"` - Cleaned int `json:"cleaned"` - Total int `json:"total,omitempty"` - Remaining int `json:"remaining,omitempty"` - BatchLimited bool `json:"batch_limited,omitempty"` - Ignored int `json:"ignored,omitempty"` - Previewed int `json:"previewed,omitempty"` - Errors []string `json:"errors,omitempty"` - IgnoredItems []string `json:"ignored_items,omitempty"` - Items []GenerateSTRMItem `json:"items,omitempty"` - Refresh *STRMRefreshResult `json:"refresh,omitempty"` -} - -type GenerateSTRMItem struct { - MediaID string `json:"media_id"` - Title string `json:"title"` - FilePath string `json:"file_path"` - URL string `json:"url,omitempty"` - Action string `json:"action"` - Reason string `json:"reason,omitempty"` -} - -func (s *STRMService) GenerateForLibrary(ctx context.Context, opts GenerateSTRMOptions) (*GenerateSTRMResult, error) { - if s == nil || s.repo == nil || s.repo.DB == nil { - return nil, errors.New("strm service unavailable") - } - libraryID := strings.TrimSpace(opts.LibraryID) - if libraryID == "" { - return nil, errors.New("library_id required") - } - lib, err := s.repo.Library.FindByID(ctx, libraryID) - if err != nil { - return nil, err - } - if lib == nil { - return nil, errors.New("library not found") - } - outputDir := s.resolveSTRMOutputDir(ctx, lib, opts) - if outputDir == "" || outputDir == "." { - return nil, errors.New("output_dir required") - } - s.saveSTRMGenerationSettings(ctx, outputDir, opts) - if err := os.MkdirAll(outputDir, 0o755); err != nil { // #nosec G301 -- STRM output directories must stay readable by NAS/player users. - return nil, err - } - - rows, err := s.librarySTRMMedia(ctx, libraryID) - if err != nil { - return nil, err - } - res := &GenerateSTRMResult{LibraryID: libraryID, OutputDir: outputDir} - expectedFiles := map[string]struct{}{} - for _, media := range rows { - select { - case <-ctx.Done(): - return res, ctx.Err() - default: - } - item := s.generateOne(ctx, *lib, media, outputDir, opts) - res.addItem(item) - if item.FilePath != "" && item.Action != "error" { - expectedFiles[filepath.Clean(item.FilePath)] = struct{}{} - } - } - if opts.Overwrite { - cleaned, err := s.cleanupStaleGeneratedSTRM(ctx, outputDir, expectedFiles) - if err != nil { - res.Errors = append(res.Errors, err.Error()) - } - res.Cleaned += cleaned - } - return res, nil -} - -func (s *STRMService) GenerateForAllLibraries(ctx context.Context, opts GenerateSTRMOptions) (*GenerateSTRMResult, error) { - if s == nil || s.repo == nil || s.repo.Library == nil { - return nil, errors.New("strm service unavailable") - } - libraries, err := s.repo.Library.List(ctx) - if err != nil { - return nil, err - } - baseOutputDir := s.remapLegacySTRMOutputDir(resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir))) - result := &GenerateSTRMResult{LibraryID: "*", OutputDir: baseOutputDir} - for _, lib := range libraries { - select { - case <-ctx.Done(): - return result, ctx.Err() - default: - } - next := opts - next.LibraryID = lib.ID - next.SkipSettingsSave = true - if baseOutputDir != "" && baseOutputDir != "." { - next.OutputDir = filepath.Join(baseOutputDir, strmLibraryOutputSubdir(lib)) - } - part, err := s.GenerateForLibrary(ctx, next) - if err != nil { - result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", lib.Name, err)) - continue - } - result.merge(part) - } - if baseOutputDir != "" && baseOutputDir != "." && s.repo.Setting != nil { - _ = s.repo.Setting.Set(ctx, "strm.output_dir", baseOutputDir) - _ = s.repo.Setting.Set(ctx, "strm.output_scope", "all") - _ = s.repo.Setting.Set(ctx, "strm.preserve_tree", strconv.FormatBool(opts.PreserveTree)) - result.OutputDir = baseOutputDir - } - return result, nil -} - -func (s *STRMService) resolveSTRMOutputDir(ctx context.Context, lib *model.Library, opts GenerateSTRMOptions) string { - outputDir := s.remapLegacySTRMOutputDir(resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir))) - if (outputDir == "" || outputDir == ".") && s.repo.Setting != nil { - if saved, err := s.repo.Setting.Get(ctx, "strm.output_dir"); err == nil { - outputDir = s.remapLegacySTRMOutputDir(resolveMappedDestinationPath(strings.TrimSpace(saved))) - } - } - if outputDir == "" || outputDir == "." { - outputDir = s.defaultOutputDir(lib) - } - return strmLibrarySpecificOutputDir(outputDir, lib) -} - -func (s *STRMService) remapLegacySTRMOutputDir(outputDir string) string { - outputDir = filepath.Clean(strings.TrimSpace(outputDir)) - if outputDir == "" || outputDir == "." || s == nil || s.cfg == nil { - return outputDir - } - dataDir := filepath.Clean(strings.TrimSpace(s.cfg.App.DataDir)) - if dataDir == "" || dataDir == "." || sameLibraryPath(dataDir, "/app/data") { - return outputDir - } - legacy := "/app/data" - normalized := strings.TrimRight(cleanPathForVolumeMapping(outputDir), "/") - if sameLibraryPath(normalized, legacy) { - return dataDir - } - if strings.HasPrefix(strings.ToLower(normalized), legacy+"/") { - rel := strings.TrimPrefix(normalized[len(legacy):], "/") - if rel != "" { - return filepath.Join(dataDir, filepath.FromSlash(rel)) - } - } - return outputDir -} - -func (s *STRMService) saveSTRMGenerationSettings(ctx context.Context, outputDir string, opts GenerateSTRMOptions) { - if opts.SkipSettingsSave { - return - } - if strings.TrimSpace(opts.BaseURL) != "" && s.repo.Setting != nil { - baseURL := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/") - _ = s.repo.Setting.Set(ctx, "app.server_url", baseURL) - _ = s.repo.Setting.Set(ctx, "strm.base_url", baseURL) - } - if s.repo.Setting == nil { - return - } - _ = s.repo.Setting.Set(ctx, "strm.auto_generate_enabled", strconv.FormatBool(opts.Enabled)) - _ = s.repo.Setting.Set(ctx, "strm.output_dir", outputDir) - _ = s.repo.Setting.Set(ctx, "strm.output_scope", "library") - _ = s.repo.Setting.Set(ctx, "strm.preserve_tree", strconv.FormatBool(opts.PreserveTree)) -} - -func (s *STRMService) librarySTRMMedia(ctx context.Context, libraryID string) ([]model.Media, error) { - var rows []model.Media - err := s.repo.DB.WithContext(ctx). - Where("library_id = ?", libraryID). - Order("title asc, season_num asc, episode_num asc, created_at asc"). - Find(&rows).Error - return rows, err -} - -func (s *STRMService) defaultOutputDir(lib *model.Library) string { - subdir := strmLibraryOutputSubdir(*lib) - if s != nil && s.cfg != nil && strings.TrimSpace(s.cfg.App.DataDir) != "" { - return filepath.Join(s.cfg.App.DataDir, "strm", subdir) - } - return filepath.Join("data", "strm", subdir) -} - -func (s *STRMService) generateOne(ctx context.Context, lib model.Library, media model.Media, outputDir string, opts GenerateSTRMOptions) GenerateSTRMItem { - item := GenerateSTRMItem{MediaID: media.ID, Title: media.Title} - playURL := s.strmPlaybackURL(ctx, media, opts.BaseURL, opts.PlaybackToken) - if playURL == "" { - item.Action = "skipped" - item.Reason = "no playable strm target" - return item - } - if strings.TrimSpace(media.STRMURL) == "" && !opts.IncludeLocal { - item.Action = "skipped" - item.Reason = "local media skipped" - return item - } - rel := s.strmRelativePath(lib, media) - if opts.PreserveTree { - if treeRel := s.strmTreeRelativePath(media); treeRel != "" { - rel = treeRel - } - } - if rel == "" { - item.Action = "skipped" - item.Reason = "cannot build file name" - return item - } - filePath := filepath.Join(outputDir, rel) - item.FilePath = filePath - item.URL = playURL - if _, err := os.Stat(filePath); err == nil && !opts.Overwrite { - item.Action = "skipped" - item.Reason = "target exists" - return item - } - action := "generated" - if _, err := os.Stat(filePath); err == nil { - action = "updated" - } - if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { // #nosec G301 -- STRM output directories must stay readable by NAS/player users. - item.Action = "error" - item.Reason = err.Error() - return item - } - if err := os.WriteFile(filePath, []byte(playURL+"\n"), 0o644); err != nil { // #nosec G306 -- STRM files are media sidecars intended to be readable by players. - item.Action = "error" - item.Reason = err.Error() - return item - } - if err := s.upsertGeneratedRecord(ctx, media, filePath, playURL, lib.Type); err != nil { - item.Action = "error" - item.Reason = err.Error() - return item - } - item.Action = action - return item -} - -func (r *GenerateSTRMResult) addItem(item GenerateSTRMItem) { - r.Items = append(r.Items, item) - switch item.Action { - case "generated": - r.Generated++ - case "updated": - r.Updated++ - case "skipped": - r.Skipped++ - case "preview": - r.Previewed++ - case "error": - r.Errors = append(r.Errors, fmt.Sprintf("%s: %s", item.Title, item.Reason)) - } -} - -func (r *GenerateSTRMResult) merge(part *GenerateSTRMResult) { - if part == nil { - return - } - if r.OutputDir == "" || r.OutputDir == "." { - r.OutputDir = filepath.Dir(part.OutputDir) - } - r.Generated += part.Generated - r.Updated += part.Updated - r.Skipped += part.Skipped - r.Cleaned += part.Cleaned - r.Previewed += part.Previewed - r.Ignored += part.Ignored - r.Errors = append(r.Errors, part.Errors...) - for _, item := range part.IgnoredItems { - if len(r.IgnoredItems) >= strmTreeIgnoredItemSampleLimit { - break - } - r.IgnoredItems = append(r.IgnoredItems, item) - } - r.Items = append(r.Items, part.Items...) -} diff --git a/internal/service/strm_generate_cleanup.go b/internal/service/strm_generate_cleanup.go deleted file mode 100644 index 9cca145..0000000 --- a/internal/service/strm_generate_cleanup.go +++ /dev/null @@ -1,116 +0,0 @@ -package service - -import ( - "context" - "errors" - "io/fs" - "net/url" - "os" - "path/filepath" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *STRMService) upsertGeneratedRecord(ctx context.Context, media model.Media, filePath, playURL, mediaType string) error { - protocol := "" - if u, err := url.Parse(playURL); err == nil { - protocol = strings.ToLower(u.Scheme) - } - if protocol == "" { - protocol = "http" - } - record := model.STRMRecord{ - Title: media.Title, - URL: playURL, - FilePath: filePath, - Protocol: protocol, - MediaID: media.ID, - MediaType: mediaType, - SeasonNum: media.SeasonNum, - EpisodeNum: media.EpisodeNum, - } - var existing model.STRMRecord - err := s.repo.DB.WithContext(ctx).Where("media_id = ? AND file_path = ?", media.ID, filePath).First(&existing).Error - if err == nil { - existing.Title = record.Title - existing.URL = record.URL - existing.Protocol = record.Protocol - existing.MediaType = record.MediaType - existing.SeasonNum = record.SeasonNum - existing.EpisodeNum = record.EpisodeNum - return s.repo.DB.WithContext(ctx).Save(&existing).Error - } - return s.repo.DB.WithContext(ctx).Create(&record).Error -} - -func (s *STRMService) cleanupStaleGeneratedSTRM(ctx context.Context, outputDir string, expected map[string]struct{}) (int, error) { - outputDir = filepath.Clean(strings.TrimSpace(outputDir)) - if outputDir == "" || outputDir == "." { - return 0, nil - } - cleaned, err := removeStaleSTRMFiles(outputDir, expected) - if err != nil { - return cleaned, err - } - recordsCleaned, err := s.removeStaleSTRMRecords(ctx, outputDir, expected) - return cleaned + recordsCleaned, err -} - -func removeStaleSTRMFiles(outputDir string, expected map[string]struct{}) (int, error) { - cleaned := 0 - err := filepath.WalkDir(outputDir, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return nil - } - if entry.IsDir() || strings.ToLower(filepath.Ext(path)) != ".strm" { - return nil - } - cleanPath := filepath.Clean(path) - if _, ok := expected[cleanPath]; ok { - return nil - } - if err := os.Remove(cleanPath); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - cleaned++ - return nil - }) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return cleaned, err - } - return cleaned, nil -} - -func (s *STRMService) removeStaleSTRMRecords(ctx context.Context, outputDir string, expected map[string]struct{}) (int, error) { - if s == nil || s.repo == nil || s.repo.DB == nil { - return 0, nil - } - var records []model.STRMRecord - if err := s.repo.DB.WithContext(ctx).Find(&records).Error; err != nil { - return 0, err - } - rootAbs, err := filepath.Abs(outputDir) - if err != nil { - return 0, nil - } - cleaned := 0 - for i := range records { - filePath := filepath.Clean(strings.TrimSpace(records[i].FilePath)) - if filePath == "" { - continue - } - fileAbs, err := filepath.Abs(filePath) - if err != nil || !pathWithin(fileAbs, rootAbs) { - continue - } - if _, ok := expected[filePath]; ok { - continue - } - if err := s.repo.DB.WithContext(ctx).Delete(&records[i]).Error; err != nil { - return cleaned, err - } - cleaned++ - } - return cleaned, nil -} diff --git a/internal/service/strm_output_dir.go b/internal/service/strm_output_dir.go deleted file mode 100644 index 81ec8ac..0000000 --- a/internal/service/strm_output_dir.go +++ /dev/null @@ -1,195 +0,0 @@ -package service - -import ( - "path/filepath" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func strmLibraryOutputSubdir(lib model.Library) string { - parts := strmLibraryCategoryParts(lib) - if len(parts) == 0 { - return sanitizeFilename(lib.Name) - } - clean := make([]string, 0, len(parts)) - for _, part := range parts { - if safe := sanitizeFilename(part); safe != "" { - clean = append(clean, safe) - } - } - if len(clean) == 0 { - return sanitizeFilename(lib.Name) - } - return filepath.Join(clean...) -} - -func strmLibrarySpecificOutputDir(base string, lib *model.Library) string { - outputDir := filepath.Clean(strings.TrimSpace(base)) - if outputDir == "" || outputDir == "." || lib == nil { - return outputDir - } - subdir := strmLibraryOutputSubdir(*lib) - if subdir == "" || strmPathHasSuffix(outputDir, subdir) || pathAlreadyEndsWith(outputDir, filepath.Base(subdir)) { - return outputDir - } - if tail := strmMissingCategoryTail(outputDir, subdir); tail != "" { - return filepath.Join(outputDir, tail) - } - return filepath.Join(outputDir, subdir) -} - -func strmMissingCategoryTail(outputDir, subdir string) string { - parts := strmSlashParts(subdir) - if len(parts) < 2 || !pathAlreadyEndsWith(outputDir, parts[0]) { - return "" - } - return filepath.Join(parts[1:]...) -} - -func strmPathHasSuffix(pathValue, suffix string) bool { - pathValue = strings.Trim(filepath.ToSlash(filepath.Clean(strings.TrimSpace(pathValue))), "/") - suffix = strings.Trim(filepath.ToSlash(filepath.Clean(strings.TrimSpace(suffix))), "/") - if pathValue == "" || suffix == "" || suffix == "." { - return false - } - if strings.EqualFold(pathValue, suffix) { - return true - } - return strings.HasSuffix(strings.ToLower(pathValue), "/"+strings.ToLower(suffix)) -} - -func strmLibraryCategoryParts(lib model.Library) []string { - if parts := strmCategoryPartsFromPath(strmLibraryPathParts(lib.Path)); len(parts) > 0 { - return parts - } - if parts := strmCategoryPartsFromPath(strmNameParts(lib.Name)); len(parts) > 0 { - return parts - } - if root := mediaTypeRootDir(lib.Type); root != "" { - return []string{root} - } - return nil -} - -func strmLibraryPathParts(raw string) []string { - if info, ok := ParseCloudLibraryMount(raw); ok { - return strmSlashParts(info.DisplayDir) - } - clean := cleanPathForVolumeMapping(raw) - clean = strings.Trim(pathAfterWindowsDrivePrefix(clean), "/") - return strmSlashParts(clean) -} - -func strmNameParts(name string) []string { - name = strings.NewReplacer("·", "/", ">", "/", "|", "/", "|", "/", "\\", "/").Replace(name) - return strmSlashParts(name) -} - -func strmSlashParts(raw string) []string { - raw = strings.Trim(strings.TrimSpace(strings.ReplaceAll(raw, "\\", "/")), "/") - if raw == "" || raw == "." { - return nil - } - fields := strings.Split(raw, "/") - parts := make([]string, 0, len(fields)) - for _, part := range fields { - part = strings.TrimSpace(part) - if part != "" && part != "." { - parts = append(parts, part) - } - } - return parts -} - -func strmCategoryPartsFromPath(parts []string) []string { - for i, part := range parts { - if root := strmCanonicalRoot(part); root != "" { - return append([]string{root}, strmSanitizedTail(parts[i+1:])...) - } - if root := strmCategoryRoot(part); root != "" { - return []string{root, strmCanonicalCategory(part)} - } - } - return nil -} - -func strmSanitizedTail(parts []string) []string { - out := make([]string, 0, len(parts)) - for _, part := range parts { - if strings.TrimSpace(part) != "" { - out = append(out, part) - } - } - return out -} - -func strmCanonicalRoot(part string) string { - key := strings.ToLower(strings.TrimSpace(part)) - switch key { - case "电影", "movie", "movies", "film", "films": - return "电影" - case "电视剧", "剧集", "tv", "tvs", "series", "show", "shows": - return "电视剧" - case "动漫", "动画", "anime", "bangumi": - return "动漫" - case "成人", "adult", "adults", "jav", "nsfw", "9kg": - return "成人" - default: - return "" - } -} - -func strmCategoryRoot(part string) string { - key := strings.ToLower(strings.TrimSpace(part)) - switch key { - case "演唱会", "音乐会", "纪录片", "纪录", "动画电影", "动漫电影", "华语电影", "国产电影", "外语电影", "外国电影", "欧美电影", "日韩电影", "日本电影", "韩国电影": - return "电影" - case "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "综艺", "真人秀", "儿童", "少儿", "未分类": - return "电视剧" - case "国漫", "国产动漫", "日番", "番剧", "日漫", "日本动漫", "日本动画", "韩漫", "韩国动漫", "韩国动画", "美漫", "欧美动漫", "欧美动画", "西方动画", "其他", "其他动漫", "其它动漫": - return "动漫" - case "番号": - return "成人" - default: - return "" - } -} - -func strmCanonicalCategory(part string) string { - key := strings.ToLower(strings.TrimSpace(part)) - switch key { - case "音乐会": - return "演唱会" - case "纪录": - return "纪录片" - case "动漫电影": - return "动画电影" - case "国产电影": - return "华语电影" - case "外语电影", "外国电影": - return "欧美电影" - case "日本电影", "韩国电影": - return "日韩电影" - case "日剧", "韩剧": - return "日韩剧" - case "真人秀": - return "综艺" - case "少儿": - return "儿童" - case "国产动漫": - return "国漫" - case "番剧", "日漫", "日本动漫", "日本动画": - return "日番" - case "韩国动漫", "韩国动画": - return "韩漫" - case "欧美动漫", "欧美动画", "西方动画": - return "美漫" - case "其他动漫", "其它动漫": - return "其他" - case "番号": - return "成人" - default: - return strings.TrimSpace(part) - } -} diff --git a/internal/service/strm_output_presets.go b/internal/service/strm_output_presets.go deleted file mode 100644 index 79394e9..0000000 --- a/internal/service/strm_output_presets.go +++ /dev/null @@ -1,79 +0,0 @@ -package service - -import ( - "context" - "path/filepath" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -type STRMOutputPreset struct { - Label string `json:"label"` - Path string `json:"path"` - Kind string `json:"kind"` -} - -func STRMOutputPresets(ctx context.Context, repo *repository.Container) ([]STRMOutputPreset, error) { - presets := defaultSTRMOutputPresets() - seen := make(map[string]struct{}, len(presets)) - for _, preset := range presets { - seen[strmOutputPresetKey(preset.Path)] = struct{}{} - } - if repo == nil || repo.Library == nil { - return presets, nil - } - libraries, err := repo.Library.List(ctx) - if err != nil { - return nil, err - } - for i := range libraries { - lib := libraries[i] - if !lib.Enabled { - continue - } - roots, err := repo.Library.ListRoots(ctx, lib.ID) - if err != nil { - return nil, err - } - if len(roots) == 0 { - roots = []model.LibraryRoot{{Path: lib.Path, Enabled: lib.Enabled}} - } - for j := range roots { - root := roots[j] - if !root.Enabled || strings.TrimSpace(root.Path) == "" { - continue - } - if _, ok := ParseCloudLibraryMount(root.Path); ok { - continue - } - pathValue := filepath.Clean(resolveMappedDestinationPath(root.Path)) - if pathValue == "" || pathValue == "." { - continue - } - key := strmOutputPresetKey(pathValue) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - presets = append(presets, STRMOutputPreset{ - Label: lib.Name, - Path: pathValue, - Kind: "library", - }) - } - } - return presets, nil -} - -func defaultSTRMOutputPresets() []STRMOutputPreset { - return []STRMOutputPreset{ - {Label: "STRM 根目录", Path: filepath.Clean("data/strm"), Kind: "default"}, - {Label: "目录树 STRM", Path: filepath.Clean("data/strm/tree"), Kind: "default"}, - } -} - -func strmOutputPresetKey(pathValue string) string { - return strings.ToLower(filepath.Clean(strings.TrimSpace(pathValue))) -} diff --git a/internal/service/strm_output_presets_test.go b/internal/service/strm_output_presets_test.go deleted file mode 100644 index 3648bd1..0000000 --- a/internal/service/strm_output_presets_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package service - -import ( - "path/filepath" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSTRMOutputPresetsIncludesDefaultsAndLocalLibraries(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}) - repos := repository.New(db) - base := t.TempDir() - movieRoot := filepath.Join(base, "strm", "电影") - tvRoot := filepath.Join(base, "strm", "电视剧") - cloudRoot := BuildCloudLibraryPath("openlist", "/电影", "/电影") - disabledRoot := filepath.Join(base, "strm", "动漫") - - libraries := []*model.Library{ - {Name: "电影 STRM", Path: movieRoot, Type: "movie", Enabled: true}, - {Name: "电视剧 STRM", Path: tvRoot, Type: "tv", Enabled: true}, - {Name: "云盘", Path: cloudRoot, Type: "movie", Enabled: true}, - {Name: "停用", Path: disabledRoot, Type: "tv", Enabled: false}, - } - for _, lib := range libraries { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - if err := repos.DB.Model(&model.Library{}).Where("id = ?", libraries[3].ID).Update("enabled", false).Error; err != nil { - t.Fatal(err) - } - - presets, err := STRMOutputPresets(t.Context(), repos) - if err != nil { - t.Fatal(err) - } - got := map[string]STRMOutputPreset{} - for _, preset := range presets { - got[preset.Path] = preset - } - - if got[filepath.Clean("data/strm")].Kind != "default" || got[filepath.Clean("data/strm/tree")].Kind != "default" { - t.Fatalf("defaults missing from presets: %#v", presets) - } - if got[movieRoot].Label != "电影 STRM" || got[movieRoot].Kind != "library" { - t.Fatalf("movie preset = %#v, want local library preset", got[movieRoot]) - } - if got[tvRoot].Label != "电视剧 STRM" || got[tvRoot].Kind != "library" { - t.Fatalf("tv preset = %#v, want local library preset", got[tvRoot]) - } - if _, ok := got[cloudRoot]; ok { - t.Fatalf("cloud library should not be an output preset: %#v", presets) - } - if _, ok := got[disabledRoot]; ok { - t.Fatalf("disabled library should not be an output preset: %#v", presets) - } -} - -func TestSTRMOutputPresetsDeduplicatesLibraryRoots(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}) - repos := repository.New(db) - root := filepath.Join(t.TempDir(), "strm") - lib := model.Library{Name: "STRM", Path: root, Type: "movie", Enabled: true} - if err := repos.Library.CreateWithRoots(t.Context(), &lib, []model.LibraryRoot{ - {Path: root, Enabled: true}, - {Path: root, Enabled: true}, - }); err != nil { - t.Fatal(err) - } - - presets, err := STRMOutputPresets(t.Context(), repos) - if err != nil { - t.Fatal(err) - } - count := 0 - for _, preset := range presets { - if preset.Path == root { - count++ - } - } - if count != 1 { - t.Fatalf("root preset count = %d, presets=%#v", count, presets) - } -} diff --git a/internal/service/strm_proxy.go b/internal/service/strm_proxy.go deleted file mode 100644 index 8d62677..0000000 --- a/internal/service/strm_proxy.go +++ /dev/null @@ -1,89 +0,0 @@ -package service - -import ( - "context" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// ProxySTRM proxies a STRM target and preserves Range requests for players. -func (s *STRMService) ProxySTRM(ctx context.Context, id string, req *http.Request, w http.ResponseWriter) error { - record, err := s.repo.STRM.FindByID(ctx, id) - if err != nil { - return err - } - if record == nil { - return ErrSTRMNotFound - } - if !model.IsAllowedProtocol(record.Protocol) { - return ErrSTRMProtocolInvalid - } - - targetURL, err := validateSTRMProxyURL(record.URL) - if err != nil { - return err - } - proxyReq, err := http.NewRequestWithContext(ctx, req.Method, targetURL.String(), nil) - if err != nil { - return fmt.Errorf("create proxy request: %w", err) - } - copySTRMRequestHeaders(req, proxyReq) - - client := &http.Client{Timeout: 60 * time.Second} - resp, err := client.Do(proxyReq) // #nosec G107,G704 -- STRM proxy target is validated by validateSTRMProxyURL before request creation. - if err != nil { - return fmt.Errorf("proxy request failed: %w", err) - } - defer resp.Body.Close() - - copySTRMResponseHeaders(resp, w) - w.WriteHeader(resp.StatusCode) - _, err = io.Copy(w, resp.Body) - return err -} - -func validateSTRMProxyURL(raw string) (*url.URL, error) { - u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || u.Scheme == "" || u.Host == "" { - return nil, ErrSTRMURLInvalid - } - switch strings.ToLower(u.Scheme) { - case "http", "https": - default: - return nil, ErrSTRMProtocolInvalid - } - if isPrivateHost(u.Hostname()) { - return nil, ErrSTRMURLInvalid - } - return u, nil -} - -func copySTRMRequestHeaders(src *http.Request, dst *http.Request) { - for _, header := range []string{ - "Range", "If-Range", "If-Match", "If-None-Match", - "If-Modified-Since", "If-Unmodified-Since", - "Accept", "Accept-Encoding", "Accept-Language", - } { - if v := src.Header.Get(header); v != "" { - dst.Header.Set(header, v) - } - } -} - -func copySTRMResponseHeaders(src *http.Response, dst http.ResponseWriter) { - for _, header := range []string{ - "Content-Type", "Content-Length", "Content-Range", - "Accept-Ranges", "Last-Modified", "ETag", - "Cache-Control", "Content-Disposition", - } { - if v := src.Header.Get(header); v != "" { - dst.Header().Set(header, v) - } - } -} diff --git a/internal/service/strm_refresh.go b/internal/service/strm_refresh.go deleted file mode 100644 index 8c06e0d..0000000 --- a/internal/service/strm_refresh.go +++ /dev/null @@ -1,89 +0,0 @@ -package service - -import ( - "context" - "path/filepath" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -type STRMRefreshResult struct { - Requested bool `json:"requested"` - Queued bool `json:"queued"` - Reason string `json:"reason,omitempty"` - ScrapeRequested bool `json:"scrape_requested,omitempty"` - ScrapeQueued bool `json:"scrape_queued,omitempty"` - ScrapeReason string `json:"scrape_reason,omitempty"` - Targets []STRMRefreshTarget `json:"targets,omitempty"` -} - -type STRMRefreshTarget struct { - LibraryID string `json:"library_id"` - RootID string `json:"root_id,omitempty"` - Name string `json:"name"` - Path string `json:"path"` -} - -func FindSTRMRefreshTargets(ctx context.Context, repo *repository.Container, outputDir string) ([]STRMRefreshTarget, error) { - if repo == nil || repo.Library == nil { - return nil, nil - } - outputDir = resolveMappedDestinationPath(strings.TrimSpace(outputDir)) - if outputDir == "" || outputDir == "." { - return nil, nil - } - libraries, err := repo.Library.List(ctx) - if err != nil { - return nil, err - } - targets := make([]STRMRefreshTarget, 0) - seen := map[string]struct{}{} - for i := range libraries { - lib := libraries[i] - if !lib.Enabled { - continue - } - roots, err := repo.Library.ListRoots(ctx, lib.ID) - if err != nil { - return nil, err - } - if len(roots) == 0 && strings.TrimSpace(lib.Path) != "" { - roots = []model.LibraryRoot{{LibraryID: lib.ID, Path: lib.Path, Enabled: lib.Enabled}} - } - for j := range roots { - root := roots[j] - if !root.Enabled || strings.TrimSpace(root.Path) == "" { - continue - } - if _, ok := ParseCloudLibraryMount(root.Path); ok { - continue - } - if !strmRefreshPathMatches(outputDir, root.Path) { - continue - } - key := lib.ID + "\x00" + root.ID + "\x00" + strings.ToLower(filepath.Clean(root.Path)) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - targets = append(targets, STRMRefreshTarget{ - LibraryID: lib.ID, - RootID: root.ID, - Name: lib.Name, - Path: filepath.Clean(root.Path), - }) - } - } - return targets, nil -} - -func strmRefreshPathMatches(outputDir, libraryRoot string) bool { - outputDir = filepath.Clean(strings.TrimSpace(outputDir)) - libraryRoot = filepath.Clean(strings.TrimSpace(libraryRoot)) - if outputDir == "" || outputDir == "." || libraryRoot == "" || libraryRoot == "." { - return false - } - return sameLibraryPath(outputDir, libraryRoot) || pathWithin(outputDir, libraryRoot) || pathWithin(libraryRoot, outputDir) -} diff --git a/internal/service/strm_refresh_test.go b/internal/service/strm_refresh_test.go deleted file mode 100644 index 9f80c76..0000000 --- a/internal/service/strm_refresh_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package service - -import ( - "path/filepath" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestFindSTRMRefreshTargetsMatchesNestedLocalRoots(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}) - repos := repository.New(db) - base := t.TempDir() - movieRoot := filepath.Join(base, "strm", "电影") - tvRoot := filepath.Join(base, "strm", "电视剧") - otherRoot := filepath.Join(base, "other") - cloudRoot := "cloud://openlist/电影" - - movie := model.Library{Name: "电影 STRM", Path: movieRoot, Type: "movie", Enabled: true} - tv := model.Library{Name: "电视剧 STRM", Path: tvRoot, Type: "tv", Enabled: true} - other := model.Library{Name: "其他", Path: otherRoot, Type: "movie", Enabled: true} - cloud := model.Library{Name: "云盘", Path: cloudRoot, Type: "movie", Enabled: true} - disabled := model.Library{Name: "停用", Path: filepath.Join(base, "strm", "动漫"), Type: "tv", Enabled: false} - for _, lib := range []*model.Library{&movie, &tv, &other, &cloud, &disabled} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - if err := repos.DB.Model(&model.Library{}).Where("id = ?", disabled.ID).Update("enabled", false).Error; err != nil { - t.Fatal(err) - } - - targets, err := FindSTRMRefreshTargets(t.Context(), repos, filepath.Join(base, "strm")) - if err != nil { - t.Fatal(err) - } - if len(targets) != 2 { - t.Fatalf("targets = %#v, want movie and tv only", targets) - } - got := map[string]bool{} - for _, target := range targets { - got[target.LibraryID] = true - } - if !got[movie.ID] || !got[tv.ID] || got[other.ID] || got[cloud.ID] || got[disabled.ID] { - t.Fatalf("target libraries = %#v", targets) - } -} - -func TestFindSTRMRefreshTargetsDoesNotFallbackToAllLibraries(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}) - repos := repository.New(db) - lib := model.Library{Name: "电影", Path: filepath.Join(t.TempDir(), "movies"), Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - - targets, err := FindSTRMRefreshTargets(t.Context(), repos, filepath.Join(t.TempDir(), "strm")) - if err != nil { - t.Fatal(err) - } - if len(targets) != 0 { - t.Fatalf("targets = %#v, want no fallback target", targets) - } -} diff --git a/internal/service/strm_repair.go b/internal/service/strm_repair.go deleted file mode 100644 index a5088fe..0000000 --- a/internal/service/strm_repair.go +++ /dev/null @@ -1,145 +0,0 @@ -package service - -import ( - "context" - "errors" - "io/fs" - "net/url" - "os" - "path/filepath" - "strings" -) - -type RepairSTRMOptions struct { - OutputDir string `json:"output_dir"` - BaseURL string `json:"base_url,omitempty"` - DryRun bool `json:"dry_run,omitempty"` -} - -type RepairSTRMResult struct { - OutputDir string `json:"output_dir"` - Repaired int `json:"repaired"` - Previewed int `json:"previewed,omitempty"` - Skipped int `json:"skipped"` - Errors []string `json:"errors,omitempty"` - Items []RepairSTRMItem `json:"items,omitempty"` - Refresh *STRMRefreshResult `json:"refresh,omitempty"` -} - -type RepairSTRMItem struct { - FilePath string `json:"file_path"` - Before string `json:"before,omitempty"` - After string `json:"after,omitempty"` - Action string `json:"action"` - Reason string `json:"reason,omitempty"` -} - -func (s *STRMService) RepairFiles(ctx context.Context, opts RepairSTRMOptions) (*RepairSTRMResult, error) { - outputDir := resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir)) - if outputDir == "" || outputDir == "." { - return nil, errors.New("output_dir required") - } - info, err := os.Stat(outputDir) - if err != nil { - return nil, err - } - if !info.IsDir() { - return nil, errors.New("output_dir must be a directory") - } - result := &RepairSTRMResult{OutputDir: outputDir} - baseURL := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/") - err = filepath.WalkDir(outputDir, func(filePath string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - result.Errors = append(result.Errors, filePath+": "+walkErr.Error()) - return nil - } - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".strm") { - return nil - } - result.addRepairItem(repairSTRMFile(filePath, baseURL, opts.DryRun)) - return nil - }) - if err != nil { - return result, err - } - return result, nil -} - -func repairSTRMFile(filePath, baseURL string, dryRun bool) RepairSTRMItem { - item := RepairSTRMItem{FilePath: filePath} - body, err := os.ReadFile(filePath) // #nosec G304 -- admin-selected STRM repair output directory. - if err != nil { - item.Action = "error" - item.Reason = err.Error() - return item - } - before := strings.TrimSpace(string(body)) - after, ok := repairedSTRMPlaybackURL(before, baseURL) - if !ok { - item.Action = "skipped" - item.Reason = "unsupported strm target" - return item - } - if after == before { - item.Action = "skipped" - item.Reason = "already current" - return item - } - item.Before = before - item.After = after - if dryRun { - item.Action = "preview" - item.Reason = "repaired" - return item - } - if err := os.WriteFile(filePath, []byte(after+"\n"), 0o644); err != nil { // #nosec G306 -- STRM files are player-readable sidecars. - item.Action = "error" - item.Reason = err.Error() - return item - } - item.Action = "repaired" - return item -} - -func repairedSTRMPlaybackURL(raw, baseURL string) (string, bool) { - raw = strings.TrimSpace(raw) - if raw == "" || strings.HasPrefix(raw, "//") { - return "", false - } - parsed, err := url.Parse(raw) - if err != nil || !strmRepairOwnsAPIPath(parsed.Path) { - return "", false - } - apiPath := parsed.EscapedPath() - if apiPath == "" { - apiPath = parsed.Path - } - if parsed.RawQuery != "" { - apiPath += "?" + parsed.RawQuery - } - return buildAbsoluteSTRMAPIURL(baseURL, apiPath, nil), true -} - -func strmRepairOwnsAPIPath(apiPath string) bool { - value := strings.ToLower(strings.TrimSpace(apiPath)) - return strings.HasPrefix(value, "/api/stream/") || strings.HasPrefix(value, "/api/cloud/play/") -} - -func (r *RepairSTRMResult) addRepairItem(item RepairSTRMItem) { - r.Items = append(r.Items, item) - switch item.Action { - case "repaired": - r.Repaired++ - case "preview": - r.Previewed++ - case "skipped": - r.Skipped++ - case "error": - r.Errors = append(r.Errors, item.FilePath+": "+item.Reason) - } -} diff --git a/internal/service/strm_repair_test.go b/internal/service/strm_repair_test.go deleted file mode 100644 index b2daab4..0000000 --- a/internal/service/strm_repair_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package service - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "go.uber.org/zap" -) - -func TestRepairSTRMFilesRewritesOwnedPlaybackURLs(t *testing.T) { - outDir := t.TempDir() - cloudPath := filepath.Join(outDir, "Movies", "A.strm") - streamPath := filepath.Join(outDir, "Shows", "S01E01.strm") - externalPath := filepath.Join(outDir, "External.strm") - for _, path := range []string{cloudPath, streamPath, externalPath} { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - } - if err := os.WriteFile(cloudPath, []byte("http://old.local/api/cloud/play/openlist?ref=%2FMovies%2FA.mkv\n"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(streamPath, []byte("/api/stream/media-1?token=old\n"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(externalPath, []byte("https://cdn.example.com/video.m3u8\n"), 0o644); err != nil { - t.Fatal(err) - } - - svc := NewSTRMService(zap.NewNop(), nil, nil) - res, err := svc.RepairFiles(t.Context(), RepairSTRMOptions{ - OutputDir: outDir, - BaseURL: "https://media.example.com", - }) - if err != nil { - t.Fatal(err) - } - if res.Repaired != 2 || res.Skipped != 1 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want two repaired and one skipped", res) - } - if got := readSTRM(t, cloudPath); got != "https://media.example.com/api/cloud/play/openlist?ref=%2FMovies%2FA.mkv" { - t.Fatalf("cloud strm = %q", got) - } - if got := readSTRM(t, streamPath); got != "https://media.example.com/api/stream/media-1?token=old" { - t.Fatalf("stream strm = %q", got) - } - if got := readSTRM(t, externalPath); got != "https://cdn.example.com/video.m3u8" { - t.Fatalf("external strm should not change: %q", got) - } -} - -func TestRepairSTRMFilesDryRunDoesNotWrite(t *testing.T) { - outDir := t.TempDir() - filePath := filepath.Join(outDir, "Movie.strm") - original := "http://old.local/api/cloud/play/openlist?ref=%2FMovie.mkv\n" - if err := os.WriteFile(filePath, []byte(original), 0o644); err != nil { - t.Fatal(err) - } - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.RepairFiles(t.Context(), RepairSTRMOptions{ - OutputDir: outDir, - BaseURL: "https://media.example.com", - DryRun: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Previewed != 1 || res.Repaired != 0 || len(res.Items) != 1 { - t.Fatalf("result = %#v, want one repair preview", res) - } - if got := readSTRM(t, filePath); got != strings.TrimSpace(original) { - t.Fatalf("dry run changed file: %q", got) - } -} diff --git a/internal/service/strm_svc.go b/internal/service/strm_svc.go deleted file mode 100644 index ebf0d05..0000000 --- a/internal/service/strm_svc.go +++ /dev/null @@ -1,171 +0,0 @@ -// Package service — STRM 文件管理服务。 -package service - -import ( - "context" - "errors" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// STRM 错误定义。 -var ( - ErrSTRMNotFound = errors.New("strm record not found") - ErrSTRMProtocolInvalid = errors.New("invalid strm protocol") - ErrSTRMURLInvalid = errors.New("invalid strm url") -) - -// STRMService STRM 文件管理服务。 -type STRMService struct { - log *zap.Logger - repo *repository.Container - cfg *config.Config -} - -// NewSTRMService 创建 STRM 服务。 -func NewSTRMService(log *zap.Logger, repo *repository.Container, cfg *config.Config) *STRMService { - return &STRMService{log: log, repo: repo, cfg: cfg} -} - -// Create 创建 STRM 记录。 -func (s *STRMService) Create(ctx context.Context, record *model.STRMRecord) (*model.STRMRecord, error) { - if err := s.validateSTRM(record); err != nil { - return nil, err - } - - if err := s.repo.STRM.Create(ctx, record); err != nil { - s.log.Error("create strm failed", zap.Error(err)) - return nil, err - } - - return record, nil -} - -// CreateBatch 批量创建 STRM 记录。 -func (s *STRMService) CreateBatch(ctx context.Context, records []model.STRMRecord) (int, error) { - created := 0 - for i := range records { - if err := s.validateSTRM(&records[i]); err != nil { - s.log.Warn("skip invalid strm record", - zap.String("title", records[i].Title), - zap.Error(err), - ) - continue - } - created++ - } - - validRecords := make([]model.STRMRecord, 0, created) - for _, r := range records { - if model.IsAllowedProtocol(r.Protocol) && r.URL != "" { - validRecords = append(validRecords, r) - } - } - - if len(validRecords) == 0 { - return 0, nil - } - - if err := s.repo.STRM.CreateBatch(ctx, validRecords); err != nil { - s.log.Error("batch create strm failed", zap.Error(err)) - return 0, err - } - - return len(validRecords), nil -} - -// GetByID 获取 STRM 记录。 -func (s *STRMService) GetByID(ctx context.Context, id string) (*model.STRMRecord, error) { - record, err := s.repo.STRM.FindByID(ctx, id) - if err != nil { - return nil, err - } - if record == nil { - return nil, ErrSTRMNotFound - } - return record, nil -} - -// List 列出 STRM 记录(支持筛选和分页)。 -func (s *STRMService) List(ctx context.Context, filters map[string]string, page, pageSize int) ([]model.STRMRecord, int64, error) { - offset := (page - 1) * pageSize - if offset < 0 { - offset = 0 - } - - records, total, err := s.repo.STRM.List(ctx, filters, offset, pageSize) - if err != nil { - return nil, 0, err - } - - return records, total, nil -} - -// Update 更新 STRM 记录。 -func (s *STRMService) Update(ctx context.Context, record *model.STRMRecord) (*model.STRMRecord, error) { - existing, err := s.repo.STRM.FindByID(ctx, record.ID) - if err != nil { - return nil, err - } - if existing == nil { - return nil, ErrSTRMNotFound - } - - if record.Protocol != "" { - if !model.IsAllowedProtocol(record.Protocol) { - return nil, ErrSTRMProtocolInvalid - } - } - - if err := s.repo.STRM.Update(ctx, record); err != nil { - s.log.Error("update strm failed", zap.Error(err)) - return nil, err - } - - return record, nil -} - -// Delete 删除 STRM 记录。 -func (s *STRMService) Delete(ctx context.Context, id string) error { - existing, err := s.repo.STRM.FindByID(ctx, id) - if err != nil { - return err - } - if existing == nil { - return ErrSTRMNotFound - } - return s.repo.STRM.Delete(ctx, id) -} - -// GetProtocols 获取支持的协议列表。 -func (s *STRMService) GetProtocols() []string { - return model.AllowedSTRMProtocols -} - -// validateSTRM 验证 STRM 记录。 -func (s *STRMService) validateSTRM(record *model.STRMRecord) error { - if record.Title == "" { - return errors.New("title is required") - } - if record.URL == "" { - return ErrSTRMURLInvalid - } - if !model.IsAllowedProtocol(record.Protocol) { - return ErrSTRMProtocolInvalid - } - - // 标准化协议名 - record.Protocol = strings.ToLower(record.Protocol) - - return nil -} - -// ListByMediaID 获取关联到指定媒体的 STRM 记录。 -func (s *STRMService) ListByMediaID(ctx context.Context, mediaID string) ([]model.STRMRecord, error) { - return s.repo.STRM.FindByMediaID(ctx, mediaID) -} diff --git a/internal/service/strm_svc_test.go b/internal/service/strm_svc_test.go deleted file mode 100644 index 825b51d..0000000 --- a/internal/service/strm_svc_test.go +++ /dev/null @@ -1,504 +0,0 @@ -package service - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/golang-jwt/jwt/v5" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestGenerateSTRMForLibraryWritesFilesAndRecords(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) - repos := repository.New(db) - lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - rows := []model.Media{ - {Base: model.Base{ID: "cloud-media"}, LibraryID: lib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"}, - {Base: model.Base{ID: "local-media"}, LibraryID: lib.ID, Title: "本地电影", Year: 2025, Path: filepath.Join(t.TempDir(), "本地电影.mkv")}, - } - for i := range rows { - if err := repos.DB.Create(&rows[i]).Error; err != nil { - t.Fatal(err) - } - } - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), repos, &config.Config{}) - - res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ - LibraryID: lib.ID, - OutputDir: outDir, - BaseURL: "http://nas.example:18080", - IncludeLocal: true, - PlaybackToken: "strm-token", - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 2 || res.Skipped != 0 { - t.Fatalf("result = %#v, want generated=2 skipped=0", res) - } - libraryOutDir := filepath.Join(outDir, "电影") - if res.OutputDir != libraryOutDir { - t.Fatalf("output dir = %q, want %q", res.OutputDir, libraryOutDir) - } - cloudSTRM := filepath.Join(libraryOutDir, "云盘电影 (2026)", "云盘电影 (2026).strm") - localSTRM := filepath.Join(libraryOutDir, "本地电影 (2025)", "本地电影 (2025).strm") - assertFileContains(t, cloudSTRM, "http://nas.example:18080/api/stream/cloud-media?token=strm-token") - assertFileContains(t, localSTRM, "http://nas.example:18080/api/stream/local-media?token=strm-token") - if got, err := repos.Setting.Get(t.Context(), "app.server_url"); err != nil || got != "http://nas.example:18080" { - t.Fatalf("app.server_url = %q, %v; want generated base url", got, err) - } - if got, err := repos.Setting.Get(t.Context(), "strm.base_url"); err != nil || got != "http://nas.example:18080" { - t.Fatalf("strm.base_url = %q, %v; want generated base url", got, err) - } - - var count int64 - if err := repos.DB.Model(&model.STRMRecord{}).Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 2 { - t.Fatalf("strm record count = %d, want 2", count) - } - - res, err = svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ - LibraryID: lib.ID, - OutputDir: outDir, - BaseURL: "http://nas.example:18080", - IncludeLocal: true, - PlaybackToken: "strm-token", - }) - if err != nil { - t.Fatal(err) - } - if res.Skipped != 2 { - t.Fatalf("second run skipped = %d, want 2", res.Skipped) - } -} - -func TestGenerateSTRMForLibrarySignsDefaultPlaybackToken(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}, &model.User{}) - repos := repository.New(db) - admin := model.User{Username: "admin", PasswordHash: "x", Role: "admin", Tier: "plus", IsActive: true} - if err := repos.User.Create(t.Context(), &admin); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - media := model.Media{Base: model.Base{ID: "cloud-media"}, LibraryID: lib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"} - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - - outDir := filepath.Join(t.TempDir(), "strm") - const secret = "test-secret" - svc := NewSTRMService(zap.NewNop(), repos, &config.Config{Secrets: config.SecretsConfig{JWTSecret: secret}}) - res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ - LibraryID: lib.ID, - OutputDir: outDir, - BaseURL: "http://nas.example:18080", - IncludeLocal: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want generated=1 with no errors", res) - } - cloudSTRM := filepath.Join(outDir, "电影", "云盘电影 (2026)", "云盘电影 (2026).strm") - got := readSTRM(t, cloudSTRM) - if !strings.HasPrefix(got, "http://nas.example:18080/api/stream/cloud-media?token=") { - t.Fatalf("generated url = %q, want tokenized /api/stream url", got) - } - token := strings.TrimPrefix(got, "http://nas.example:18080/api/stream/cloud-media?token=") - claims := &Claims{} - parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) { - return []byte(secret), nil - }) - if err != nil || !parsed.Valid { - t.Fatalf("generated token did not validate: %v", err) - } - if claims.UserID != admin.ID || claims.Role != "admin" || claims.Tier != "plus" { - t.Fatalf("claims = %#v, want admin identity", claims) - } - if ttl := time.Until(claims.ExpiresAt.Time); ttl < EmbyTokenDuration-time.Minute { - t.Fatalf("token ttl = %v, want close to %v", ttl, EmbyTokenDuration) - } -} - -func TestGenerateSTRMForLibraryCleanupStaleFilesAndRecords(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) - repos := repository.New(db) - lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - media := model.Media{Base: model.Base{ID: "cloud-media"}, LibraryID: lib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"} - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - outDir := filepath.Join(t.TempDir(), "strm") - libraryOutDir := filepath.Join(outDir, "电影") - stalePath := filepath.Join(libraryOutDir, "旧电影", "旧电影.strm") - if err := os.MkdirAll(filepath.Dir(stalePath), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(stalePath, []byte("http://old.example/stream\n"), 0o644); err != nil { - t.Fatal(err) - } - staleRecord := model.STRMRecord{Title: "旧电影", URL: "http://old.example/stream", FilePath: stalePath, Protocol: "http", MediaID: "missing-media"} - if err := repos.DB.Create(&staleRecord).Error; err != nil { - t.Fatal(err) - } - - svc := NewSTRMService(zap.NewNop(), repos, &config.Config{}) - res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ - LibraryID: lib.ID, - OutputDir: outDir, - BaseURL: "http://nas.example:18080", - IncludeLocal: true, - Overwrite: true, - PlaybackToken: "strm-token", - }) - if err != nil { - t.Fatal(err) - } - if res.Cleaned == 0 { - t.Fatalf("cleaned = %d, want stale file/record cleaned", res.Cleaned) - } - if _, err := os.Stat(stalePath); !os.IsNotExist(err) { - t.Fatalf("stale strm file should be removed, stat err=%v", err) - } - var count int64 - if err := repos.DB.Model(&model.STRMRecord{}).Where("media_id = ?", "missing-media").Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 0 { - t.Fatalf("stale strm record count = %d, want 0", count) - } - if err := repos.DB.Model(&model.STRMRecord{}).Where("media_id = ?", media.ID).Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("current strm record count = %d, want 1", count) - } - assertFileContains(t, filepath.Join(libraryOutDir, "云盘电影 (2026)", "云盘电影 (2026).strm"), "http://nas.example:18080/api/stream/cloud-media?token=strm-token") -} - -func TestSTRMLibraryOutputSubdirUsesLibraryCategoryPath(t *testing.T) { - tests := []struct { - name string - lib model.Library - want string - }{ - { - name: "cloud nested tv category", - lib: model.Library{Name: "OpenList · 欧美剧", Path: BuildCloudLibraryPath("openlist", "/电视剧/欧美剧", "/电视剧/欧美剧"), Type: "tv"}, - want: filepath.Join("电视剧", "欧美剧"), - }, - { - name: "cloud second-level category without root", - lib: model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), Type: "tv"}, - want: filepath.Join("电视剧", "国产剧"), - }, - { - name: "local nested tv category", - lib: model.Library{Name: "欧美剧", Path: `F:\media\电视剧\欧美剧`, Type: "tv"}, - want: filepath.Join("电视剧", "欧美剧"), - }, - { - name: "uncategorized tv category stays uncategorized", - lib: model.Library{Name: "OpenList · 未分类", Path: BuildCloudLibraryPath("openlist", "/电视剧/未分类", "/电视剧/未分类"), Type: "tv"}, - want: filepath.Join("电视剧", "未分类"), - }, - { - name: "fallback to type root", - lib: model.Library{Name: "Archive", Path: `F:\archive`, Type: "movie"}, - want: "电影", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := strmLibraryOutputSubdir(tt.lib); got != tt.want { - t.Fatalf("strmLibraryOutputSubdir() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestSTRMLibrarySpecificOutputDirPreservesExplicitCategoryRoot(t *testing.T) { - base := filepath.Join(t.TempDir(), "strm", "电视剧") - lib := model.Library{ - Name: "国产剧", - Path: BuildCloudLibraryPath("openlist", "/电视剧/国产剧", "/电视剧/国产剧"), - Type: "tv", - } - - got := strmLibrarySpecificOutputDir(base, &lib) - want := filepath.Join(base, "国产剧") - if got != want { - t.Fatalf("strmLibrarySpecificOutputDir() = %q, want %q", got, want) - } -} - -func TestGenerateSTRMForLibraryUsesCategoryDefaultOutputDir(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) - repos := repository.New(db) - dataDir := t.TempDir() - lib := model.Library{Name: "OpenList · 欧美剧", Path: BuildCloudLibraryPath("openlist", "/电视剧/欧美剧", "/电视剧/欧美剧"), Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - media := model.Media{Base: model.Base{ID: "show-1"}, LibraryID: lib.ID, Title: "第一集", Path: "cloud://openlist/电视剧/欧美剧/Show/S01E01.mkv", STRMURL: "/api/cloud/play/openlist?ref=show", SeasonNum: 1, EpisodeNum: 1} - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - svc := NewSTRMService(zap.NewNop(), repos, &config.Config{App: config.AppConfig{DataDir: dataDir}}) - - res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ - LibraryID: lib.ID, - BaseURL: "http://nas.example:18080", - IncludeLocal: true, - PlaybackToken: "strm-token", - }) - if err != nil { - t.Fatal(err) - } - wantDir := filepath.Join(dataDir, "strm", "电视剧", "欧美剧") - if res.OutputDir != wantDir { - t.Fatalf("output dir = %q, want %q", res.OutputDir, wantDir) - } - assertFileContains(t, filepath.Join(wantDir, "Show", "Season 01", "Show - S01E01.strm"), "http://nas.example:18080/api/stream/show-1?token=strm-token") -} - -func TestGenerateSTRMRemapsLegacyAppDataOutputDir(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) - repos := repository.New(db) - dataDir := t.TempDir() - lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - media := model.Media{ - Base: model.Base{ID: "cloud-media"}, - LibraryID: lib.ID, - Title: "云盘电影", - Year: 2026, - Path: "cloud://openlist/电影/云盘电影.mkv", - STRMURL: "/api/cloud/play/openlist?ref=movie", - } - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "strm.output_dir", "/app/data/strm"); err != nil { - t.Fatal(err) - } - svc := NewSTRMService(zap.NewNop(), repos, &config.Config{App: config.AppConfig{DataDir: dataDir}}) - - res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ - LibraryID: lib.ID, - BaseURL: "http://nas.example:18080", - PlaybackToken: "strm-token", - }) - if err != nil { - t.Fatal(err) - } - wantDir := filepath.Join(dataDir, "strm", "电影") - if res.OutputDir != wantDir { - t.Fatalf("output dir = %q, want %q", res.OutputDir, wantDir) - } - if got, err := repos.Setting.Get(t.Context(), "strm.output_dir"); err != nil || got != wantDir { - t.Fatalf("saved strm.output_dir = %q, %v; want %q", got, err, wantDir) - } - assertFileContains(t, filepath.Join(wantDir, "云盘电影 (2026)", "云盘电影 (2026).strm"), "http://nas.example:18080/api/stream/cloud-media?token=strm-token") -} - -func TestGenerateSTRMForLibraryUsesPathEpisodeFallback(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) - repos := repository.New(db) - lib := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/电视剧/国产剧", "/电视剧/国产剧"), Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - rows := []model.Media{ - {Base: model.Base{ID: "ep-1"}, LibraryID: lib.ID, Title: "南部档案", Path: "cloud://openlist/电视剧/国产剧/南部档案/Season 01/Archives.The.Nanyang.Mystery.S01E01.mkv", STRMURL: "/api/cloud/play/openlist?ref=ep1"}, - {Base: model.Base{ID: "ep-2"}, LibraryID: lib.ID, Title: "南部档案", Path: "cloud://openlist/电视剧/国产剧/南部档案/Season 01/Archives.The.Nanyang.Mystery.S01E02.mkv", STRMURL: "/api/cloud/play/openlist?ref=ep2"}, - } - for i := range rows { - if err := repos.DB.Create(&rows[i]).Error; err != nil { - t.Fatal(err) - } - } - - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), repos, &config.Config{}) - res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ - LibraryID: lib.ID, - OutputDir: outDir, - BaseURL: "http://nas.example:18080", - IncludeLocal: true, - PlaybackToken: "strm-token", - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 2 || res.Skipped != 0 { - t.Fatalf("result = %#v, want generated=2 skipped=0", res) - } - libraryOutDir := filepath.Join(outDir, "电视剧", "国产剧") - assertFileContains(t, filepath.Join(libraryOutDir, "南部档案", "Season 01", "南部档案 - S01E01.strm"), "http://nas.example:18080/api/stream/ep-1?token=strm-token") - assertFileContains(t, filepath.Join(libraryOutDir, "南部档案", "Season 01", "南部档案 - S01E02.strm"), "http://nas.example:18080/api/stream/ep-2?token=strm-token") -} - -func TestGenerateSTRMForLibraryPreservesSourceTree(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) - repos := repository.New(db) - lib := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/电视剧/国产剧", "/电视剧/国产剧"), Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - media := model.Media{ - Base: model.Base{ID: "ep-1"}, - LibraryID: lib.ID, - Title: "南部档案", - Path: "cloud://openlist/电视剧/国产剧/南部档案/Season 01/Archives.The.Nanyang.Mystery.S01E01.mkv", - STRMURL: "/api/cloud/play/openlist?ref=ep1", - } - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), repos, &config.Config{}) - res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ - LibraryID: lib.ID, - OutputDir: outDir, - BaseURL: "http://nas.example:18080", - IncludeLocal: true, - PreserveTree: true, - PlaybackToken: "strm-token", - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || res.Skipped != 0 { - t.Fatalf("result = %#v, want generated=1 skipped=0", res) - } - wantPath := filepath.Join(outDir, "电视剧", "国产剧", "南部档案", "Season 01", "Archives.The.Nanyang.Mystery.S01E01.strm") - assertFileContains(t, wantPath, "http://nas.example:18080/api/stream/ep-1?token=strm-token") - if got, err := repos.Setting.Get(t.Context(), "strm.preserve_tree"); err != nil || got != "true" { - t.Fatalf("strm.preserve_tree = %q, %v; want true", got, err) - } -} - -func TestGenerateSTRMForLibraryCanSkipLocalMedia(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) - repos := repository.New(db) - lib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - rows := []model.Media{ - {Base: model.Base{ID: "cloud-media"}, LibraryID: lib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"}, - {Base: model.Base{ID: "local-media"}, LibraryID: lib.ID, Title: "本地电影", Year: 2025, Path: filepath.Join(t.TempDir(), "本地电影.mkv")}, - } - for i := range rows { - if err := repos.DB.Create(&rows[i]).Error; err != nil { - t.Fatal(err) - } - } - - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), repos, &config.Config{}) - res, err := svc.GenerateForLibrary(t.Context(), GenerateSTRMOptions{ - LibraryID: lib.ID, - OutputDir: outDir, - BaseURL: "http://nas.example:18080", - IncludeLocal: false, - PlaybackToken: "strm-token", - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || res.Skipped != 1 { - t.Fatalf("result = %#v, want generated=1 skipped=1", res) - } - assertFileContains(t, filepath.Join(outDir, "电影", "云盘电影 (2026)", "云盘电影 (2026).strm"), "http://nas.example:18080/api/stream/cloud-media?token=strm-token") - if _, err := os.Stat(filepath.Join(outDir, "电影", "本地电影 (2025)", "本地电影 (2025).strm")); !os.IsNotExist(err) { - t.Fatalf("local media strm should not exist, stat err=%v", err) - } -} - -func TestGenerateSTRMForAllLibrariesWritesPerLibraryFolders(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.STRMRecord{}, &model.Setting{}) - repos := repository.New(db) - movieLib := model.Library{Name: "电影", Path: "cloud://openlist/电影", Type: "movie", Enabled: true} - tvLib := model.Library{Name: "欧美剧", Path: BuildCloudLibraryPath("openlist", "/电视剧/欧美剧", "/电视剧/欧美剧"), Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &movieLib); err != nil { - t.Fatal(err) - } - if err := repos.Library.Create(t.Context(), &tvLib); err != nil { - t.Fatal(err) - } - rows := []model.Media{ - {Base: model.Base{ID: "movie-1"}, LibraryID: movieLib.ID, Title: "云盘电影", Year: 2026, Path: "cloud://openlist/电影/云盘电影.mkv", STRMURL: "/api/cloud/play/openlist?ref=movie"}, - {Base: model.Base{ID: "show-1"}, LibraryID: tvLib.ID, Title: "第一集", Path: "cloud://openlist/电视剧/欧美剧/Show/S01E01.mkv", STRMURL: "/api/cloud/play/openlist?ref=show", SeasonNum: 1, EpisodeNum: 1}, - } - for i := range rows { - if err := repos.DB.Create(&rows[i]).Error; err != nil { - t.Fatal(err) - } - } - - outDir := filepath.Join(t.TempDir(), "strm-all") - svc := NewSTRMService(zap.NewNop(), repos, &config.Config{}) - res, err := svc.GenerateForAllLibraries(t.Context(), GenerateSTRMOptions{ - OutputDir: outDir, - BaseURL: "http://nas.example:18080", - IncludeLocal: true, - Overwrite: true, - PlaybackToken: "strm-token", - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 2 { - t.Fatalf("generated = %d, want 2", res.Generated) - } - assertFileContains(t, filepath.Join(outDir, "电影", "云盘电影 (2026)", "云盘电影 (2026).strm"), "http://nas.example:18080/api/stream/movie-1?token=strm-token") - assertFileContains(t, filepath.Join(outDir, "电视剧", "欧美剧", "Show", "Season 01", "Show - S01E01.strm"), "http://nas.example:18080/api/stream/show-1?token=strm-token") - var count int64 - if err := repos.DB.Model(&model.STRMRecord{}).Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 2 { - t.Fatalf("active strm record count = %d, want 2", count) - } -} - -func assertFileContains(t *testing.T, path, want string) { - t.Helper() - if got := readSTRM(t, path); got != want { - t.Fatalf("%s = %q, want %q", path, got, want) - } -} - -func readSTRM(t *testing.T, path string) string { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - return strings.TrimSpace(string(data)) -} diff --git a/internal/service/strm_tree_cleanup_test.go b/internal/service/strm_tree_cleanup_test.go deleted file mode 100644 index da23821..0000000 --- a/internal/service/strm_tree_cleanup_test.go +++ /dev/null @@ -1,150 +0,0 @@ -package service - -import ( - "os" - "path/filepath" - "testing" - - "go.uber.org/zap" -) - -func TestGenerateSTRMFromTreeOverwriteAndTraversal(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - existing := filepath.Join(outDir, "Movies", "Movie.strm") - if err := os.MkdirAll(filepath.Dir(existing), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(existing, []byte("old\n"), 0o644); err != nil { - t.Fatal(err) - } - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"Movies/Movie.mkv", "../escape.mkv"}, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 0 || res.Skipped != 1 || len(res.Errors) != 1 { - t.Fatalf("result = %#v, want existing skipped and traversal rejected", res) - } - if got := readSTRM(t, existing); got != "old" { - t.Fatalf("existing strm = %q, want unchanged", got) - } - - res, err = svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"Movies/Movie.mkv"}, - OutputDir: outDir, - Overwrite: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Updated != 1 { - t.Fatalf("updated = %d, want 1", res.Updated) - } - if got := readSTRM(t, existing); got == "old" { - t.Fatalf("existing strm should be overwritten, got %q", got) - } -} - -func TestGenerateSTRMFromTreeCleanupStaleFiles(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - current := filepath.Join(outDir, "Shows", "Show.S01E01.strm") - stale := filepath.Join(outDir, "Shows", "Show.S01E02.strm") - for _, file := range []string{current, stale} { - if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(file, []byte("old\n"), 0o644); err != nil { - t.Fatal(err) - } - } - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"Shows/Show.S01E01.mkv"}, - OutputDir: outDir, - Cleanup: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Skipped != 1 || res.Cleaned != 1 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want current skipped and one stale file cleaned", res) - } - if _, err := os.Stat(current); err != nil { - t.Fatalf("current strm should remain: %v", err) - } - if _, err := os.Stat(stale); !os.IsNotExist(err) { - t.Fatalf("stale strm should be removed, stat err=%v", err) - } -} - -func TestGenerateSTRMFromTreeCleanupWithOutputPrefixStaysInPrefix(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - current := filepath.Join(outDir, "电影", "欧美电影", "Movie.strm") - staleInPrefix := filepath.Join(outDir, "电影", "欧美电影", "Old.strm") - otherCategory := filepath.Join(outDir, "电视剧", "国产剧", "Show.strm") - for _, file := range []string{current, staleInPrefix, otherCategory} { - if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(file, []byte("old\n"), 0o644); err != nil { - t.Fatal(err) - } - } - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"Movie.mkv"}, - OutputPrefix: "电影/欧美电影", - OutputDir: outDir, - Cleanup: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Skipped != 1 || res.Cleaned != 1 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want one stale file cleaned inside prefix only", res) - } - if _, err := os.Stat(staleInPrefix); !os.IsNotExist(err) { - t.Fatalf("stale prefixed strm should be removed, stat err=%v", err) - } - if _, err := os.Stat(otherCategory); err != nil { - t.Fatalf("other category strm should remain: %v", err) - } -} - -func TestGenerateSTRMFromTreeCleanupSkipsWhenNoValidSources(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - existing := filepath.Join(outDir, "Movies", "Movie.strm") - if err := os.MkdirAll(filepath.Dir(existing), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(existing, []byte("keep\n"), 0o644); err != nil { - t.Fatal(err) - } - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"Movies/poster.jpg"}, - OutputDir: outDir, - Cleanup: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Cleaned != 0 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want cleanup skipped without valid media sources", res) - } - if got := readSTRM(t, existing); got != "keep" { - t.Fatalf("existing strm = %q, want kept", got) - } -} diff --git a/internal/service/strm_tree_import.go b/internal/service/strm_tree_import.go deleted file mode 100644 index c285c83..0000000 --- a/internal/service/strm_tree_import.go +++ /dev/null @@ -1,283 +0,0 @@ -package service - -import ( - "context" - "errors" - "os" - "path" - "path/filepath" - "strings" -) - -type GenerateSTRMTreeOptions struct { - Provider string `json:"provider"` - TreeText string `json:"tree_text,omitempty"` - Paths []string `json:"paths,omitempty"` - SourceRoot string `json:"source_root,omitempty"` - OutputPrefix string `json:"output_prefix,omitempty"` - OutputDir string `json:"output_dir"` - BaseURL string `json:"base_url,omitempty"` - Overwrite bool `json:"overwrite"` - Cleanup bool `json:"cleanup"` - DryRun bool `json:"dry_run"` - BatchLimit int `json:"batch_limit,omitempty"` - RecognizeRename bool `json:"recognize_rename,omitempty"` - TransferSubtitles bool `json:"transfer_subtitles,omitempty"` - MissingOnly bool `json:"missing_only,omitempty"` -} - -type strmTreeSource struct { - Provider string - Path string - RefPath string - Kind string -} - -const ( - strmTreeSourceKindVideo = "video" - strmTreeSourceKindSubtitle = "subtitle" -) - -type strmTreeSourceCollection struct { - sources []strmTreeSource - ignored []string - ignoredCount int -} - -type strmTreeSourceCollector struct { - fallbackProvider string - transferSubtitles bool - sources []strmTreeSource - subtitles []strmTreeSource - ignored []string - ignoredCount int - seen map[string]struct{} - seenIgnored map[string]struct{} -} - -func (s *STRMService) GenerateFromTree(ctx context.Context, opts GenerateSTRMTreeOptions) (*GenerateSTRMResult, error) { - provider := normalizeSTRMTreeProvider(opts.Provider) - if provider == "" { - return nil, errors.New("provider required") - } - outputDir := resolveMappedDestinationPath(strings.TrimSpace(opts.OutputDir)) - if outputDir == "" || outputDir == "." { - return nil, errors.New("output_dir required") - } - if !opts.DryRun { - if err := os.MkdirAll(outputDir, 0o755); err != nil { // #nosec G301 -- STRM output directories must be readable by media players. - return nil, err - } - } - result := &GenerateSTRMResult{LibraryID: provider, OutputDir: outputDir} - collection := collectSTRMTreeSources(opts) - sources := collection.sources - result.Total = len(sources) - result.Ignored = collection.ignoredCount - result.IgnoredItems = collection.ignored - existingRefs, err := s.strmTreeExistingCloudRefs(ctx, opts) - if err != nil { - return nil, err - } - expectedFiles := make(map[string]struct{}) - for i, source := range sources { - select { - case <-ctx.Done(): - return result, ctx.Err() - default: - } - item := s.generateTreeSTRMItem(ctx, outputDir, source, opts, existingRefs) - if item.FilePath != "" && item.Action != "error" { - expectedFiles[filepath.Clean(item.FilePath)] = struct{}{} - } - result.addItem(item) - if strmTreeBatchLimitReached(result, opts.BatchLimit) { - result.Remaining = len(sources) - i - 1 - result.BatchLimited = result.Remaining > 0 - break - } - } - if opts.Cleanup && !opts.DryRun && opts.BatchLimit <= 0 && len(expectedFiles) > 0 { - cleanupDir := outputDir - if prefix, err := strmTreeOutputPrefixPath(opts.OutputPrefix); err == nil && prefix != "" { - cleanupDir = filepath.Join(outputDir, prefix) - } - cleaned, err := removeStaleSTRMFiles(cleanupDir, expectedFiles) - result.Cleaned += cleaned - if err != nil { - result.Errors = append(result.Errors, err.Error()) - } - } - return result, nil -} - -func strmTreeBatchLimitReached(result *GenerateSTRMResult, limit int) bool { - if result == nil || limit <= 0 { - return false - } - return result.Generated+result.Updated+result.Previewed >= limit -} - -func (s *STRMService) generateTreeSTRMItem(ctx context.Context, outputDir string, source strmTreeSource, opts GenerateSTRMTreeOptions, existingRefs map[string]struct{}) GenerateSTRMItem { - relSource := strmTreeRelativeSource(source.Path, opts.SourceRoot) - relPath, err := strmTreeOutputRelativePath(relSource) - if opts.RecognizeRename && source.Kind != strmTreeSourceKindSubtitle { - if renamed, renameErr := s.strmTreeRecognizedOutputRelativePath(ctx, source.Path, opts.SourceRoot); renameErr == nil && renamed != "" { - relPath = renamed - err = nil - } - } - if source.Kind == strmTreeSourceKindSubtitle { - relPath, err = strmTreeOutputSubtitleLinkRelativePath(relSource) - } - item := GenerateSTRMItem{Title: strings.TrimSuffix(path.Base(source.Path), path.Ext(source.Path))} - if err != nil { - item.Action = "error" - item.Reason = err.Error() - return item - } - prefix, err := strmTreeOutputPrefixPath(opts.OutputPrefix) - if err != nil { - item.Action = "error" - item.Reason = err.Error() - return item - } - filePath := filepath.Join(outputDir, prefix, relPath) - item.FilePath = filePath - item.URL = absolutizeSTRMURL(BuildRelativeCloudPlayURL(source.Provider, strmTreeCloudRef(source.cloudRefPath(), opts.SourceRoot)), opts.BaseURL) - if strmTreeSourceAlreadyInLibrary(source, opts, existingRefs) { - item.Action = "skipped" - item.Reason = "already in media library" - return item - } - if _, err := os.Stat(filePath); err == nil && !opts.Overwrite { - item.Action = "skipped" - item.Reason = "target exists" - return item - } - action := "generated" - if _, err := os.Stat(filePath); err == nil { - action = "updated" - } - if opts.DryRun { - item.Action = "preview" - item.Reason = action - return item - } - if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { // #nosec G301 -- STRM output directories must be readable by media players. - item.Action = "error" - item.Reason = err.Error() - return item - } - if err := os.WriteFile(filePath, []byte(item.URL+"\n"), 0o644); err != nil { // #nosec G306 -- STRM files are media sidecars intended to be readable by players. - item.Action = "error" - item.Reason = err.Error() - return item - } - item.Action = action - return item -} - -func collectSTRMTreeSources(opts GenerateSTRMTreeOptions) strmTreeSourceCollection { - collector := newSTRMTreeSourceCollector(opts) - for _, value := range opts.Paths { - collector.add(value) - } - treeSources, treeIgnored := parseSTRMTreeTextWithIgnored(opts.TreeText) - for _, value := range treeSources { - collector.add(value) - } - for _, value := range treeIgnored { - collector.addIgnoredOrSubtitle(value) - } - collector.finalizeSubtitles() - return collector.collection() -} - -func newSTRMTreeSourceCollector(opts GenerateSTRMTreeOptions) *strmTreeSourceCollector { - return &strmTreeSourceCollector{ - fallbackProvider: normalizeSTRMTreeProvider(opts.Provider), - transferSubtitles: opts.TransferSubtitles, - sources: make([]strmTreeSource, 0, len(opts.Paths)), - subtitles: make([]strmTreeSource, 0), - ignored: make([]string, 0), - seen: map[string]struct{}{}, - seenIgnored: map[string]struct{}{}, - } -} - -func (c *strmTreeSourceCollector) add(value string) { - source := normalizeSTRMTreeSourceWithProvider(value, c.fallbackProvider) - if source.Provider == "" || source.Path == "" || !strmTreeSourceIsVideo(source.Path) { - c.addIgnoredOrSubtitle(value) - return - } - source.Kind = strmTreeSourceKindVideo - c.addSource(source) -} - -func (c *strmTreeSourceCollector) addIgnoredOrSubtitle(value string) { - if c.transferSubtitles && c.addSubtitleCandidate(value) { - return - } - c.addIgnored(value) -} - -func (c *strmTreeSourceCollector) addSource(source strmTreeSource) { - if source.Kind == "" { - source.Kind = strmTreeSourceKindVideo - } - key := strings.ToLower(source.Provider) + "\x00" + strings.ToLower(source.Kind) + "\x00" + strings.ToLower(source.Path) + "\x00" + strings.ToLower(source.cloudRefPath()) - if _, ok := c.seen[key]; ok { - return - } - c.seen[key] = struct{}{} - c.sources = append(c.sources, source) -} - -func (c *strmTreeSourceCollector) addSubtitleCandidate(value string) bool { - source := normalizeSTRMTreeSubtitleSourceWithProvider(value, c.fallbackProvider) - if source.Provider == "" || source.Path == "" { - return false - } - c.subtitles = append(c.subtitles, source) - return true -} - -func (c *strmTreeSourceCollector) addIgnored(value string) { - if ignoredPath, ok := strmTreeIgnoredFileLikeSource(value); ok { - key := strings.ToLower(ignoredPath) - if _, exists := c.seenIgnored[key]; exists { - return - } - c.seenIgnored[key] = struct{}{} - c.ignoredCount++ - if len(c.ignored) < strmTreeIgnoredItemSampleLimit { - c.ignored = append(c.ignored, ignoredPath) - } - } -} - -func (c *strmTreeSourceCollector) finalizeSubtitles() { - if !c.transferSubtitles { - return - } - for _, source := range c.subtitles { - if strmTreeSubtitleMatchesVideo(source, c.sources) { - c.addSource(source) - continue - } - c.addIgnored(source.Path) - } -} - -func (c *strmTreeSourceCollector) collection() strmTreeSourceCollection { - return strmTreeSourceCollection{sources: c.sources, ignored: c.ignored, ignoredCount: c.ignoredCount} -} - -func (s strmTreeSource) cloudRefPath() string { - if strings.TrimSpace(s.RefPath) != "" { - return s.RefPath - } - return s.Path -} diff --git a/internal/service/strm_tree_import_test.go b/internal/service/strm_tree_import_test.go deleted file mode 100644 index baf4f07..0000000 --- a/internal/service/strm_tree_import_test.go +++ /dev/null @@ -1,340 +0,0 @@ -package service - -import ( - "os" - "path/filepath" - "strconv" - "strings" - "testing" - - "go.uber.org/zap" -) - -func TestGenerateSTRMFromTreePaths(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "115", - Paths: []string{"/电视剧/国产剧/南部档案/Season 01/Archives.S01E01.mkv", "/电视剧/国产剧/南部档案/poster.jpg", "/电视剧/国产剧/南部档案/Existing.strm"}, - TreeText: "电视剧\n└── 国产剧\n └── 南部档案\n └── Existing.Tree.strm", - SourceRoot: "/电视剧", - OutputDir: outDir, - BaseURL: "https://media.example.com", - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || res.Skipped != 0 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want one generated video and ignored sidecar", res) - } - path := filepath.Join(outDir, "国产剧", "南部档案", "Season 01", "Archives.S01E01.strm") - got := readSTRM(t, path) - if !strings.HasPrefix(got, "https://media.example.com/api/cloud/play/cloud115?") { - t.Fatalf("strm url = %q, want cloud115 play url", got) - } - if !strings.Contains(got, "ref=%2F%E7%94%B5%E8%A7%86%E5%89%A7%2F%E5%9B%BD%E4%BA%A7%E5%89%A7%2F%E5%8D%97%E9%83%A8%E6%A1%A3%E6%A1%88%2FSeason+01%2FArchives.S01E01.mkv") { - t.Fatalf("strm url = %q, missing encoded source ref", got) - } - if _, err := os.Stat(filepath.Join(outDir, "国产剧", "南部档案", "Existing.strm")); !os.IsNotExist(err) { - t.Fatalf("existing .strm source should be ignored by tree generator, stat err=%v", err) - } - if _, err := os.Stat(filepath.Join(outDir, "电视剧", "国产剧", "南部档案", "Existing.Tree.strm")); !os.IsNotExist(err) { - t.Fatalf("tree .strm source should be ignored by tree generator, stat err=%v", err) - } -} - -func TestGenerateSTRMFromTreeRecognizeRenameOutputPaths(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"/电视剧/国产剧/南部档案/Season 01/Archives.The.Nanyang.Mystery.S01E02.2160p.WEB-DL.mkv", "/电影/Dune.Part.Two.2024.2160p.WEB-DL.mkv"}, - SourceRoot: "/电视剧", - OutputDir: outDir, - RecognizeRename: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 2 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want two generated renamed STRM files", res) - } - episode := readSTRM(t, filepath.Join(outDir, "南部档案", "Season 01", "南部档案 S01E02.strm")) - if !strings.Contains(episode, "ref=%2F%E7%94%B5%E8%A7%86%E5%89%A7%2F%E5%9B%BD%E4%BA%A7%E5%89%A7%2F%E5%8D%97%E9%83%A8%E6%A1%A3%E6%A1%88%2FSeason+01%2FArchives.The.Nanyang.Mystery.S01E02.2160p.WEB-DL.mkv") { - t.Fatalf("episode strm URL = %q, want original cloud ref preserved", episode) - } - movie := readSTRM(t, filepath.Join(outDir, "Dune Part Two (2024)", "Dune Part Two (2024).strm")) - if !strings.Contains(movie, "ref=%2F%E7%94%B5%E5%BD%B1%2FDune.Part.Two.2024.2160p.WEB-DL.mkv") { - t.Fatalf("movie strm URL = %q, want original cloud ref preserved", movie) - } - if _, err := os.Stat(filepath.Join(outDir, "国产剧", "南部档案", "Season 01", "Archives.The.Nanyang.Mystery.S01E02.2160p.WEB-DL.strm")); !os.IsNotExist(err) { - t.Fatalf("recognize rename should not leave original episode output path, stat err=%v", err) - } -} - -func TestGenerateSTRMFromTreeSupportsCommonVideoExtensions(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{ - "/Movies/BluRay.Stream.2026.m2ts", - "/Movies/Camera.Source.2026.MTS", - "/Movies/DVD.Feature.2026.vob", - "/Movies/Legacy.Video.2026.wmv", - "/Movies/Web.Legacy.2026.flv", - "/Movies/Disc.Image.2026.iso", - }, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 6 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want six common video sources generated", res) - } - for _, name := range []string{ - "BluRay.Stream.2026", - "Camera.Source.2026", - "DVD.Feature.2026", - "Legacy.Video.2026", - "Web.Legacy.2026", - "Disc.Image.2026", - } { - got := readSTRM(t, filepath.Join(outDir, "Movies", name+".strm")) - if !strings.Contains(got, "/api/cloud/play/openlist?") { - t.Fatalf("%s strm url = %q, want cloud play url", name, got) - } - } -} - -func TestGenerateSTRMFromTreeReportsIgnoredFileLikeRows(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{ - "/Movies/A.mkv", - "/Movies/poster.jpg", - "/Movies/fanart.jpg (cover image)", - "/Movies/Disc.Image.2026.iso", - "/Movies/Existing.strm", - }, - TreeText: strings.Join([]string{ - "电视剧", - "└── Show.Name.2026", - " ├── Show.S01E01.mp4", - " └── Show.S01E01.nfo", - " └── Show.S01E01.srt 72 KB", - }, "\n"), - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 3 || res.Ignored != 5 || len(res.IgnoredItems) != 5 { - t.Fatalf("result = %#v, want three generated videos and five ignored sidecars", res) - } - for _, item := range res.IgnoredItems { - if item == "Show.Name.2026" || item == "电视剧/Show.Name.2026" { - t.Fatalf("directory-like dotted title should not be reported as ignored: %#v", res.IgnoredItems) - } - } - if !strings.Contains(strings.Join(res.IgnoredItems, "\n"), "Show.S01E01.nfo") { - t.Fatalf("directory-like dotted title should not be reported as ignored: %#v", res.IgnoredItems) - } -} - -func TestGenerateSTRMFromTreeTransfersMatchingSubtitleLinks(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{ - "/Movies/A.mkv", - "/Movies/A.zh.srt", - "/Movies/Orphan.srt", - }, - TreeText: strings.Join([]string{ - "电视剧", - "└── Show.Name.2026", - " ├── Show.S01E01.mp4", - " ├── Show.S01E01.ass", - " └── Show.S01E02.srt", - }, "\n"), - OutputDir: outDir, - BaseURL: "https://media.example.com", - TransferSubtitles: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 4 || res.Ignored != 2 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want two video links, two matching subtitle links, and two orphan subtitles ignored", res) - } - movieSubtitle := readSTRM(t, filepath.Join(outDir, "Movies", "A.zh.srt.strm")) - if !strings.HasPrefix(movieSubtitle, "https://media.example.com/api/cloud/play/openlist?") || - !strings.Contains(movieSubtitle, "ref=%2FMovies%2FA.zh.srt") { - t.Fatalf("subtitle link = %q, want cloud play URL for subtitle source", movieSubtitle) - } - showSubtitle := readSTRM(t, filepath.Join(outDir, "电视剧", "Show.Name.2026", "Show.S01E01.ass.strm")) - if !strings.Contains(showSubtitle, "ref=%2F%E7%94%B5%E8%A7%86%E5%89%A7%2FShow.Name.2026%2FShow.S01E01.ass") { - t.Fatalf("tree subtitle link = %q, want full tree subtitle ref", showSubtitle) - } - if _, err := os.Stat(filepath.Join(outDir, "Movies", "Orphan.srt.strm")); !os.IsNotExist(err) { - t.Fatalf("orphan subtitle should not generate a link, stat err=%v", err) - } - if _, err := os.Stat(filepath.Join(outDir, "电视剧", "Show.Name.2026", "Show.S01E02.srt.strm")); !os.IsNotExist(err) { - t.Fatalf("subtitle without matching episode should not generate a link, stat err=%v", err) - } -} - -func TestGenerateSTRMFromTreeLimitsIgnoredItemSamples(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - paths := make([]string, 0, 25) - for i := 0; i < 25; i++ { - paths = append(paths, filepath.ToSlash(filepath.Join("/Movies", "sidecar-"+strconv.Itoa(i)+".nfo"))) - } - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: paths, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Ignored != 25 || len(res.IgnoredItems) != strmTreeIgnoredItemSampleLimit { - t.Fatalf("ignored = %d samples = %d, want 25/%d", res.Ignored, len(res.IgnoredItems), strmTreeIgnoredItemSampleLimit) - } -} - -func TestGenerateSTRMFromTreeDryRunDoesNotWriteOrCleanup(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - stale := filepath.Join(outDir, "Movies", "Old.Movie.strm") - if err := os.MkdirAll(filepath.Dir(stale), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(stale, []byte("old\n"), 0o644); err != nil { - t.Fatal(err) - } - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"/Movies/New.Movie.2026.mkv"}, - OutputDir: outDir, - Cleanup: true, - DryRun: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Previewed != 1 || res.Generated != 0 || res.Updated != 0 || res.Cleaned != 0 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want one preview and no writes", res) - } - if len(res.Items) != 1 || res.Items[0].Action != "preview" || res.Items[0].Reason != "generated" { - t.Fatalf("preview item = %#v, want generated preview", res.Items) - } - if _, err := os.Stat(filepath.Join(outDir, "Movies", "New.Movie.2026.strm")); !os.IsNotExist(err) { - t.Fatalf("dry run should not write new strm, stat err=%v", err) - } - if got := readSTRM(t, stale); got != "old" { - t.Fatalf("dry run cleanup touched stale file: %q", got) - } -} - -func TestGenerateSTRMFromTreeDryRunDoesNotCreateOutputDir(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "missing-strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"/Movies/New.Movie.2026.mkv"}, - OutputDir: outDir, - DryRun: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Previewed != 1 { - t.Fatalf("previewed = %d, want 1", res.Previewed) - } - if _, err := os.Stat(outDir); !os.IsNotExist(err) { - t.Fatalf("dry run should not create output dir, stat err=%v", err) - } -} - -func TestGenerateSTRMFromTreeBatchLimitContinuesAfterExistingFiles(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - opts := GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"/Movies/A.mkv", "/Movies/B.mkv", "/Movies/C.mkv"}, - OutputDir: outDir, - BatchLimit: 2, - } - - first, err := svc.GenerateFromTree(t.Context(), opts) - if err != nil { - t.Fatal(err) - } - if first.Generated != 2 || first.Skipped != 0 { - t.Fatalf("first batch = %#v, want two generated", first) - } - if first.Total != 3 || first.Remaining != 1 || !first.BatchLimited { - t.Fatalf("first batch progress = total %d remaining %d limited %v, want 3/1/true", first.Total, first.Remaining, first.BatchLimited) - } - if _, err := os.Stat(filepath.Join(outDir, "Movies", "C.strm")); !os.IsNotExist(err) { - t.Fatalf("first batch should not write third item, stat err=%v", err) - } - - second, err := svc.GenerateFromTree(t.Context(), opts) - if err != nil { - t.Fatal(err) - } - if second.Generated != 1 || second.Skipped != 2 { - t.Fatalf("second batch = %#v, want two existing skips then next generated", second) - } - if second.Total != 3 || second.Remaining != 0 || second.BatchLimited { - t.Fatalf("second batch progress = total %d remaining %d limited %v, want 3/0/false", second.Total, second.Remaining, second.BatchLimited) - } - if got := readSTRM(t, filepath.Join(outDir, "Movies", "C.strm")); !strings.Contains(got, "C.mkv") { - t.Fatalf("second batch C.strm = %q, want generated third item", got) - } -} - -func TestGenerateSTRMFromTreeBatchLimitSkipsCleanup(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - stale := filepath.Join(outDir, "Movies", "stale.strm") - if err := os.MkdirAll(filepath.Dir(stale), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(stale, []byte("keep\n"), 0o644); err != nil { - t.Fatal(err) - } - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"/Movies/A.mkv", "/Movies/B.mkv"}, - OutputDir: outDir, - BatchLimit: 1, - Cleanup: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || res.Cleaned != 0 { - t.Fatalf("batch result = %#v, want one generated and no cleanup", res) - } - if got := readSTRM(t, stale); got != "keep" { - t.Fatalf("batch cleanup should not touch stale file: %q", got) - } -} diff --git a/internal/service/strm_tree_missing.go b/internal/service/strm_tree_missing.go deleted file mode 100644 index a7265af..0000000 --- a/internal/service/strm_tree_missing.go +++ /dev/null @@ -1,73 +0,0 @@ -package service - -import ( - "context" - "errors" - "net/url" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *STRMService) strmTreeExistingCloudRefs(ctx context.Context, opts GenerateSTRMTreeOptions) (map[string]struct{}, error) { - if !opts.MissingOnly { - return nil, nil - } - if s == nil || s.repo == nil || s.repo.DB == nil { - return nil, errors.New("media library unavailable") - } - var rows []model.Media - if err := s.repo.DB.WithContext(ctx). - Select("path", "strm_url"). - Where("strm_url <> '' OR path LIKE ?", "cloud://%"). - Find(&rows).Error; err != nil { - return nil, err - } - refs := make(map[string]struct{}, len(rows)) - for _, row := range rows { - if typ, ref, ok := parseCloudMediaPlaybackURL(row.STRMURL); ok { - refs[strmTreeCloudRefKey(typ, ref)] = struct{}{} - } - if typ, ref, ok := strmTreeCloudPathTarget(row.Path); ok { - refs[strmTreeCloudRefKey(typ, ref)] = struct{}{} - } - } - return refs, nil -} - -func strmTreeSourceAlreadyInLibrary(source strmTreeSource, opts GenerateSTRMTreeOptions, existingRefs map[string]struct{}) bool { - if len(existingRefs) == 0 || source.Provider == "" || source.Path == "" || source.Kind == strmTreeSourceKindSubtitle { - return false - } - ref := strmTreeCloudRef(source.cloudRefPath(), opts.SourceRoot) - _, ok := existingRefs[strmTreeCloudRefKey(source.Provider, ref)] - return ok -} - -func strmTreeCloudPathTarget(raw string) (string, string, bool) { - raw = strings.TrimSpace(raw) - if !strings.HasPrefix(strings.ToLower(raw), "cloud://") { - return "", "", false - } - rest := strings.TrimPrefix(raw, "cloud://") - parts := strings.SplitN(rest, "/", 2) - if len(parts) != 2 { - return "", "", false - } - typ := strings.TrimSpace(parts[0]) - ref := strings.TrimSpace(parts[1]) - return typ, ref, typ != "" && ref != "" -} - -func strmTreeCloudRefKey(provider, ref string) string { - return strings.ToLower(normalizeSTRMTreeProvider(provider)) + "\x00" + strmTreeNormalizeCloudRef(ref) -} - -func strmTreeNormalizeCloudRef(ref string) string { - ref = strings.TrimSpace(ref) - if decoded, err := url.PathUnescape(ref); err == nil { - ref = decoded - } - ref = strings.TrimSpace(strings.ReplaceAll(ref, "\\", "/")) - return strings.ToLower(strings.TrimLeft(ref, "/")) -} diff --git a/internal/service/strm_tree_normalize.go b/internal/service/strm_tree_normalize.go deleted file mode 100644 index 2702ae8..0000000 --- a/internal/service/strm_tree_normalize.go +++ /dev/null @@ -1,380 +0,0 @@ -package service - -import ( - "net/url" - "path" - "regexp" - "strings" -) - -var strmTreeListPrefixRE = regexp.MustCompile(`^\s*(?:[-*+•·]\s+|\d{1,4}[\.)、]\s+)`) - -var strmTreeURLPathQueryKeys = []string{"path", "ref", "file", "filepath", "filename", "name"} - -var strmTreeKnownNonVideoExtensions = map[string]struct{}{ - ".ass": {}, - ".bmp": {}, - ".gif": {}, - ".idx": {}, - ".jpeg": {}, - ".jpg": {}, - ".nfo": {}, - ".png": {}, - ".srt": {}, - ".ssa": {}, - ".strm": {}, - ".sub": {}, - ".txt": {}, - ".url": {}, - ".vtt": {}, - ".webp": {}, -} - -var strmTreeSubtitleExtensions = map[string]struct{}{ - ".ass": {}, - ".idx": {}, - ".srt": {}, - ".ssa": {}, - ".sub": {}, - ".vtt": {}, -} - -func normalizeSTRMTreeProvider(raw string) string { - switch strings.ToLower(strings.TrimSpace(raw)) { - case "115", "115pan", "pan115", "cloud115": - return "cloud115" - case "alist", "openlist", "webdav", "clouddrive2", "s3": - return strings.ToLower(strings.TrimSpace(raw)) - default: - return strings.ToLower(strings.TrimSpace(raw)) - } -} - -func normalizeSTRMTreeSource(raw string) string { - value := strings.TrimSpace(strings.Trim(raw, `"'`)) - if value == "" { - return "" - } - if strings.HasPrefix(strings.ToLower(value), "cloud://") { - parts := strmLibraryPathParts(value) - if len(parts) == 0 { - return "" - } - return "/" + path.Join(parts...) - } - if parsed, err := url.Parse(value); err == nil && parsed.Scheme != "" && parsed.Scheme != "file" { - if ref := strmTreeSourceFromURL(parsed); ref != "" { - value = ref - } - } - value = trimSTRMTreeMediaMetadata(value) - value = strings.ReplaceAll(value, "\\", "/") - value = pathAfterWindowsDrivePrefix(value) - value = cleanSTRMTreePathDecorations(value) - value = strings.TrimSpace(value) - if value == "" { - return "" - } - if strmTreeHasUnsafeSegment(value) { - return strings.Trim(value, "/") - } - if strings.HasPrefix(value, "/") { - return path.Clean(value) - } - return strings.TrimPrefix(path.Clean("/"+value), "/") -} - -func normalizeSTRMTreeSourceWithProvider(raw, fallbackProvider string) strmTreeSource { - provider := normalizeSTRMTreeProvider(fallbackProvider) - value := strings.TrimSpace(strings.Trim(raw, `"'`)) - if value == "" { - return strmTreeSource{} - } - if info, ok := ParseCloudLibraryMount(value); ok { - source := normalizeSTRMTreeSource(info.DisplayDir) - if source == "" { - return strmTreeSource{} - } - ref := normalizeSTRMTreeSource(info.ScanDir) - if ref == "" { - ref = source - } - return strmTreeSource{Provider: normalizeSTRMTreeProvider(info.Provider), Path: source, RefPath: ref} - } - if parsed, err := url.Parse(value); err == nil { - if cloudProvider, ref := strmTreeCloudPlaySourceFromURL(parsed); cloudProvider != "" && ref != "" { - source := normalizeSTRMTreeSource(ref) - if source == "" { - return strmTreeSource{} - } - return strmTreeSource{Provider: normalizeSTRMTreeProvider(cloudProvider), Path: source} - } - } - source := normalizeSTRMTreeSource(value) - if source == "" { - return strmTreeSource{} - } - return strmTreeSource{Provider: provider, Path: source} -} - -func normalizeSTRMTreeSubtitleSourceWithProvider(raw, fallbackProvider string) strmTreeSource { - provider := normalizeSTRMTreeProvider(fallbackProvider) - value := strings.TrimSpace(strings.Trim(raw, `"'`)) - if value == "" { - return strmTreeSource{} - } - if info, ok := ParseCloudLibraryMount(value); ok { - source, sourceOK := strmTreeSubtitleFileLikeSource(info.DisplayDir) - if !sourceOK { - return strmTreeSource{} - } - ref, refOK := strmTreeSubtitleFileLikeSource(info.ScanDir) - if !refOK { - ref = source - } - return strmTreeSource{Provider: normalizeSTRMTreeProvider(info.Provider), Path: source, RefPath: ref, Kind: strmTreeSourceKindSubtitle} - } - source, ok := strmTreeSubtitleFileLikeSource(value) - if !ok { - return strmTreeSource{} - } - return strmTreeSource{Provider: provider, Path: source, Kind: strmTreeSourceKindSubtitle} -} - -func strmTreeCloudPlaySourceFromURL(parsed *url.URL) (string, string) { - if parsed == nil { - return "", "" - } - parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") - if len(parts) < 4 || !strings.EqualFold(parts[0], "api") || !strings.EqualFold(parts[1], "cloud") || !strings.EqualFold(parts[2], "play") { - return "", "" - } - provider := strings.TrimSpace(parts[3]) - if decoded, err := url.PathUnescape(provider); err == nil { - provider = decoded - } - // /api/cloud/play is generated by this app with url.Values, where '+' - // represents a space. Decode the standard query view first so re-importing - // existing STRM links does not turn "My Movie.mkv" into "My+Movie.mkv". - for _, value := range parsed.Query()["ref"] { - if ref := strmTreeURLQueryMediaSource(value); ref != "" { - return provider, ref - } - } - for _, value := range strmTreeRawQueryValues(parsed.RawQuery, "ref") { - if ref := strmTreeURLQueryMediaSource(value); ref != "" { - return provider, ref - } - } - return "", "" -} - -func strmTreeSourceFromURL(parsed *url.URL) string { - if parsed == nil { - return "" - } - for _, key := range strmTreeURLPathQueryKeys { - for _, value := range strmTreeRawQueryValues(parsed.RawQuery, key) { - if ref := strmTreeURLQueryMediaSource(value); ref != "" { - return ref - } - } - for _, value := range parsed.Query()[key] { - if ref := strmTreeURLQueryMediaSource(value); ref != "" { - return ref - } - } - } - if parsed.Path != "" && strmTreeURLQueryMediaSource(parsed.Path) != "" { - return parsed.Path - } - return "" -} - -func strmTreeRawQueryValues(rawQuery, key string) []string { - key = strings.ToLower(strings.TrimSpace(key)) - if rawQuery == "" || key == "" { - return nil - } - values := make([]string, 0, 1) - for _, pair := range strings.Split(rawQuery, "&") { - if pair == "" { - continue - } - rawKey, rawValue, _ := strings.Cut(pair, "=") - decodedKey, err := url.QueryUnescape(rawKey) - if err != nil { - decodedKey = rawKey - } - if strings.ToLower(strings.TrimSpace(decodedKey)) != key { - continue - } - decodedValue, err := url.PathUnescape(rawValue) - if err != nil { - decodedValue = rawValue - } - values = append(values, decodedValue) - } - return values -} - -func strmTreeURLQueryMediaSource(raw string) string { - value := strings.TrimSpace(strings.Trim(raw, `"'`)) - if value == "" { - return "" - } - if parsed, err := url.Parse(value); err == nil && parsed.Scheme != "" && parsed.Scheme != "file" { - value = parsed.Path - } - value = trimSTRMTreeMediaMetadata(value) - if strmTreeSourceIsVideo(value) { - return value - } - return "" -} - -func strmTreeHasUnsafeSegment(value string) bool { - for _, part := range strings.Split(strings.ReplaceAll(value, "\\", "/"), "/") { - if strings.TrimSpace(part) == ".." { - return true - } - } - return false -} - -func strmTreeSourceIsVideo(source string) bool { - ext := strings.ToLower(path.Ext(trimSTRMTreeMediaMetadata(cleanSTRMTreeItemName(source)))) - if ext == ".strm" { - return false - } - _, ok := videoExtensions[ext] - return ok -} - -func strmTreeIgnoredFileLikeSource(raw string) (string, bool) { - source := normalizeSTRMTreeSource(raw) - if source == "" { - return "", false - } - source = trimSTRMTreeKnownFileMetadata(cleanSTRMTreeItemName(source)) - ext := strings.ToLower(path.Ext(source)) - if ext == "" { - return "", false - } - if ext == ".strm" { - return source, true - } - if _, ok := videoExtensions[ext]; ok { - return "", false - } - if _, ok := strmTreeKnownNonVideoExtensions[ext]; ok { - return source, true - } - return "", false -} - -func strmTreeSubtitleFileLikeSource(raw string) (string, bool) { - source, ok := strmTreeIgnoredFileLikeSource(raw) - if !ok { - return "", false - } - ext := strings.ToLower(path.Ext(source)) - if _, ok := strmTreeSubtitleExtensions[ext]; !ok { - return "", false - } - return source, true -} - -func cleanSTRMTreePathDecorations(value string) string { - if value == "" { - return "" - } - leadingSlash := strings.HasPrefix(value, "/") - parts := strings.Split(value, "/") - for i, part := range parts { - if part == "" { - continue - } - parts[i] = cleanSTRMTreeItemName(part) - } - cleaned := strings.Join(parts, "/") - if leadingSlash && !strings.HasPrefix(cleaned, "/") { - cleaned = "/" + cleaned - } - return cleaned -} - -func cleanSTRMTreeItemName(value string) string { - value = strings.TrimSpace(strings.Trim(value, `"'`)) - for { - before := value - value = strings.TrimSpace(strmTreeListPrefixRE.ReplaceAllString(value, "")) - value = strings.TrimSpace(strings.TrimLeft(value, "\ufeff📁📂📄📃🎬🎞🎥📺🗂️")) - lower := strings.ToLower(value) - for _, marker := range []string{ - "[file]", "[f]", "[文件]", "[檔案]", "[档案]", - "[dir]", "[d]", "[folder]", "[目录]", "[目錄]", "[文件夹]", "[資料夾]", - "(file)", "(f)", "(文件)", "(檔案)", "(档案)", - "(dir)", "(d)", "(folder)", "(目录)", "(目錄)", "(文件夹)", "(資料夾)", - } { - if strings.HasPrefix(lower, marker) { - value = strings.TrimLeft(strings.TrimSpace(value[len(marker):]), "-_:: ") - break - } - } - if value == before { - return value - } - } -} - -func trimSTRMTreeMediaMetadata(value string) string { - return trimSTRMTreeMetadataByExtensions(value, videoExtensions) -} - -func trimSTRMTreeKnownFileMetadata(value string) string { - return trimSTRMTreeMetadataByExtensions(value, videoExtensions, strmTreeKnownNonVideoExtensions) -} - -func trimSTRMTreeMetadataByExtensions(value string, extensionSets ...map[string]struct{}) string { - value = strings.TrimSpace(value) - lower := strings.ToLower(value) - bestEnd := -1 - for _, extensions := range extensionSets { - for ext := range extensions { - from := 0 - for { - idx := strings.Index(lower[from:], ext) - if idx < 0 { - break - } - idx += from - end := idx + len(ext) - if strmTreeMediaExtBoundary(value, end) && end > bestEnd { - bestEnd = end - } - from = idx + 1 - if from >= len(lower) { - break - } - } - } - } - if bestEnd > 0 && bestEnd < len(value) { - return strings.TrimSpace(value[:bestEnd]) - } - return value -} - -func strmTreeMediaExtBoundary(value string, end int) bool { - if end >= len(value) { - return true - } - for _, r := range value[end:] { - if r == '(' || r == '[' || r == '{' || r == '<' || r == '(' || r == '【' || r == ',' || r == ',' { - return true - } - return r == ' ' || r == '\t' || r == '\r' || r == '\n' - } - return true -} diff --git a/internal/service/strm_tree_parse.go b/internal/service/strm_tree_parse.go deleted file mode 100644 index 5f8c5ef..0000000 --- a/internal/service/strm_tree_parse.go +++ /dev/null @@ -1,196 +0,0 @@ -package service - -import ( - "path" - "strings" -) - -func parseSTRMTreeText(raw string) []string { - out, _ := parseSTRMTreeTextWithIgnored(raw) - return out -} - -func parseSTRMTreeTextWithIgnored(raw string) ([]string, []string) { - var out []string - var ignored []string - stack := make([]string, 0, 8) - plainIndents := make([]int, 0, 8) - rootOffset := 0 - for _, line := range strings.Split(raw, "\n") { - line = strings.TrimRight(line, " \t\r") - if strings.TrimSpace(line) == "" { - continue - } - if item, level, ok := parseSTRMTreeLine(line); ok { - plainIndents = plainIndents[:0] - level += rootOffset - stack = stack[:min(level, len(stack))] - if strmTreeItemIsVideoSource(item) { - out = append(out, strmTreeJoinedSource(stack, item)) - continue - } - if strmTreeItemIsIgnoredFileSource(item) { - ignored = append(ignored, strmTreeJoinedSource(stack, item)) - continue - } - stack = append(stack, item) - continue - } - if item, level, ok := parseSTRMTreeContinuationLine(line, len(plainIndents) > 0); ok { - plainIndents = plainIndents[:0] - level += rootOffset - stack = stack[:min(level, len(stack))] - if strmTreeItemIsVideoSource(item) { - out = append(out, path.Join(append(append([]string{}, stack...), item)...)) - } else if strmTreeItemIsIgnoredFileSource(item) { - ignored = append(ignored, path.Join(append(append([]string{}, stack...), item)...)) - } - continue - } - item := cleanSTRMTreeItemName(line) - if indent := strmTreePlainIndent(line); indent > 0 && !strings.ContainsAny(item, `/\`) { - for len(plainIndents) > 0 && indent <= plainIndents[len(plainIndents)-1] { - plainIndents = plainIndents[:len(plainIndents)-1] - } - level := rootOffset + len(plainIndents) - stack = stack[:min(level, len(stack))] - if strmTreeItemIsVideoSource(item) { - out = append(out, strmTreeJoinedSource(stack, item)) - continue - } - if strmTreeItemIsIgnoredFileSource(item) { - ignored = append(ignored, strmTreeJoinedSource(stack, item)) - continue - } - stack = append(stack, item) - plainIndents = append(plainIndents, indent) - continue - } - if strmTreeItemIsVideoSource(item) || strings.ContainsAny(item, `/\`) { - plainIndents = plainIndents[:0] - if strmTreeItemIsVideoSource(item) { - out = append(out, item) - } else if strmTreeItemIsIgnoredFileSource(item) { - ignored = append(ignored, item) - } - continue - } - if strmTreeItemIsIgnoredFileSource(item) { - ignored = append(ignored, item) - continue - } - stack = []string{item} - plainIndents = plainIndents[:0] - rootOffset = 1 - } - return out, ignored -} - -func parseSTRMTreeLine(line string) (string, int, bool) { - if idx := strings.Index(line, "──"); idx >= 0 { - prefix := line[:idx] - level := strmTreeIndentLevelWithWidth(prefix, 4) - item := cleanSTRMTreeItemName(strings.Trim(strings.TrimSpace(line[idx+len("──"):]), "─- ")) - return item, level, item != "" - } - if idx := strings.Index(line, "─"); idx >= 0 { - prefix := line[:idx] - level := strmTreeIndentLevelWithWidth(prefix, 3) - item := cleanSTRMTreeItemName(strings.Trim(strings.TrimSpace(line[idx+len("─"):]), "─- ")) - return item, level, item != "" - } - for _, marker := range []string{"|--", "+--", "`--"} { - if idx := strings.Index(line, marker); idx >= 0 { - item := cleanSTRMTreeItemName(line[idx+len(marker):]) - return item, strmTreeIndentLevel(line[:idx]), item != "" - } - } - return "", 0, false -} - -func parseSTRMTreeContinuationLine(line string, plainTreeActive bool) (string, int, bool) { - prefixLen := 0 - hasGuide := false - for _, r := range line { - switch r { - case ' ', '\t', '│', '|': - if r == '│' || r == '|' { - hasGuide = true - } - prefixLen += len(string(r)) - default: - if plainTreeActive && !hasGuide { - return "", 0, false - } - item := cleanSTRMTreeItemName(line[prefixLen:]) - if item == "" || (!strmTreeItemIsVideoSource(item) && !strmTreeItemIsIgnoredFileSource(item)) { - return "", 0, false - } - return item, strmTreeIndentLevel(line[:prefixLen]), true - } - } - return "", 0, false -} - -func strmTreeIndentLevel(prefix string) int { - return strmTreeIndentLevelWithWidth(prefix, 4) -} - -func strmTreeIndentLevelWithWidth(prefix string, width int) int { - if prefix == "" { - return 0 - } - if width <= 0 { - width = 4 - } - verticals := strings.Count(prefix, "│") + strings.Count(prefix, "|") - runeLen := len([]rune(strings.ReplaceAll(strings.ReplaceAll(prefix, "│", " "), "|", " "))) - byWidth := 0 - if runeLen > 0 { - byWidth = (runeLen - 1) / width - } - if verticals > byWidth { - return verticals - } - return byWidth -} - -func strmTreePlainIndent(line string) int { - indent := 0 - for _, r := range line { - switch r { - case ' ': - indent++ - case '\t': - indent += 4 - default: - return indent - } - } - return indent -} - -func strmTreeItemIsVideoSource(item string) bool { - if strmTreeSourceIsVideo(item) { - return true - } - source := normalizeSTRMTreeSourceWithProvider(item, "openlist") - return source.Path != "" && strmTreeSourceIsVideo(source.Path) -} - -func strmTreeItemIsIgnoredFileSource(item string) bool { - _, ok := strmTreeIgnoredFileLikeSource(item) - return ok -} - -func strmTreeJoinedSource(stack []string, item string) string { - if strmTreeItemIsAbsoluteSource(item) { - return item - } - return path.Join(append(append([]string{}, stack...), item)...) -} - -func strmTreeItemIsAbsoluteSource(item string) bool { - value := strings.ToLower(strings.TrimSpace(item)) - return strings.Contains(value, "://") || strings.HasPrefix(value, "/api/") -} diff --git a/internal/service/strm_tree_parse_test.go b/internal/service/strm_tree_parse_test.go deleted file mode 100644 index 36235f8..0000000 --- a/internal/service/strm_tree_parse_test.go +++ /dev/null @@ -1,234 +0,0 @@ -package service - -import ( - "path/filepath" - "strings" - "testing" - - "go.uber.org/zap" -) - -func TestGenerateSTRMFromTreeTextPreservesRootTree(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - tree := strings.Join([]string{ - "电视剧", - "├── 国产剧", - "│ └── 南部档案", - "│ ├── Archives.S01E01.mkv", - "│ └── Archives.S01E01.nfo", - }, "\n") - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - TreeText: tree, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want one generated video", res) - } - got := readSTRM(t, filepath.Join(outDir, "电视剧", "国产剧", "南部档案", "Archives.S01E01.strm")) - if got != "/api/cloud/play/openlist?ref=%2F%E7%94%B5%E8%A7%86%E5%89%A7%2F%E5%9B%BD%E4%BA%A7%E5%89%A7%2F%E5%8D%97%E9%83%A8%E6%A1%A3%E6%A1%88%2FArchives.S01E01.mkv" { - t.Fatalf("strm url = %q", got) - } -} - -func TestGenerateSTRMFromTreeTextSupportsSingleLineTreeMarkers(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - tree := strings.Join([]string{ - "动漫", - "├─ 国漫", - "│ └─ 凡人修仙传", - "│ └─ Season 01", - "│ └─ Mortal.Journey.S01E01.mp4", - }, "\n") - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "115", - TreeText: tree, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want one generated video", res) - } - got := readSTRM(t, filepath.Join(outDir, "动漫", "国漫", "凡人修仙传", "Season 01", "Mortal.Journey.S01E01.strm")) - if !strings.Contains(got, "/api/cloud/play/cloud115?") { - t.Fatalf("strm url = %q, want cloud115 play url", got) - } -} - -func TestGenerateSTRMFromTreeTextSupportsConnectorURLSources(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - tree := strings.Join([]string{ - "电影", - "├── https://media.example.com/api/cloud/play/openlist?ref=%2FMovies%2FLinked.Movie.2026.mkv", - "└── cloud://openlist/%E7%94%B5%E5%BD%B1/%E5%88%AB%E5%90%8D/Cloud.Query.2026.mkv?dir=%2Factual%2Fcloud%2FCloud.Query.2026.mkv", - }, "\n") - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "115", - TreeText: tree, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 2 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want two generated videos from connector URL rows", res) - } - linked := readSTRM(t, filepath.Join(outDir, "Movies", "Linked.Movie.2026.strm")) - if !strings.Contains(linked, "/api/cloud/play/openlist?") || !strings.Contains(linked, "ref=%2FMovies%2FLinked.Movie.2026.mkv") { - t.Fatalf("cloud play connector url = %q, want preserved provider/ref", linked) - } - cloud := readSTRM(t, filepath.Join(outDir, "电影", "别名", "Cloud.Query.2026.strm")) - if !strings.Contains(cloud, "/api/cloud/play/openlist?") || !strings.Contains(cloud, "ref=%2Factual%2Fcloud%2FCloud.Query.2026.mkv") { - t.Fatalf("cloud mount connector url = %q, want display path output and scan dir ref", cloud) - } -} - -func TestGenerateSTRMFromTreeTextSupportsPlainIndentedTree(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - tree := strings.Join([]string{ - "电视剧", - " 欧美剧", - " House of the Dragon", - " Season 03", - " House.of.the.Dragon.S03E01.mkv", - " House.of.the.Dragon.S03E02.mkv", - " The Last of Us", - " Season 02", - " The.Last.of.Us.S02E01.mkv", - }, "\n") - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - TreeText: tree, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 3 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want three generated videos from plain indented tree", res) - } - got := readSTRM(t, filepath.Join(outDir, "电视剧", "欧美剧", "House of the Dragon", "Season 03", "House.of.the.Dragon.S03E01.strm")) - if !strings.Contains(got, "House.of.the.Dragon.S03E01.mkv") { - t.Fatalf("strm url = %q, want first plain-indented source ref", got) - } - got = readSTRM(t, filepath.Join(outDir, "电视剧", "欧美剧", "The Last of Us", "Season 02", "The.Last.of.Us.S02E01.strm")) - if !strings.Contains(got, "The.Last.of.Us.S02E01.mkv") { - t.Fatalf("strm url = %q, want sibling folder source ref", got) - } -} - -func TestGenerateSTRMFromTreeTextSupportsWindowsTreeFileRows(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - tree := strings.Join([]string{ - "电视剧", - "├─欧美剧", - "│ ├─House of the Dragon", - "│ │ House.of.the.Dragon.S03E01.mkv", - "│ │ House.of.the.Dragon.S03E02.mkv", - "│ └─The Last of Us", - "│ The.Last.of.Us.S02E01.mkv", - }, "\n") - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - TreeText: tree, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 3 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want three generated videos from Windows tree rows", res) - } - got := readSTRM(t, filepath.Join(outDir, "电视剧", "欧美剧", "House of the Dragon", "House.of.the.Dragon.S03E02.strm")) - if !strings.Contains(got, "House.of.the.Dragon.S03E02.mkv") { - t.Fatalf("strm url = %q, want vertical-prefix file row ref", got) - } - got = readSTRM(t, filepath.Join(outDir, "电视剧", "欧美剧", "The Last of Us", "The.Last.of.Us.S02E01.strm")) - if !strings.Contains(got, "The.Last.of.Us.S02E01.mkv") { - t.Fatalf("strm url = %q, want blank-prefix sibling file row ref", got) - } -} - -func TestGenerateSTRMFromTreeStripsDecoratedTreeNames(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - tree := strings.Join([]string{ - "📁 电视剧", - "├── [目录] 欧美剧", - "│ └── (folder) House of the Dragon", - "│ │ 📄 House.of.the.Dragon.S03E01.mkv", - }, "\n") - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - TreeText: tree, - Paths: []string{"/[目录] 动漫/[folder] 日番/[文件] Frieren.S01E01.mp4"}, - SourceRoot: "/动漫", - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 2 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want two generated videos with decorations stripped", res) - } - showPath := filepath.Join(outDir, "电视剧", "欧美剧", "House of the Dragon", "House.of.the.Dragon.S03E01.strm") - show := readSTRM(t, showPath) - if strings.Contains(showPath, "目录") || strings.Contains(showPath, "folder") || strings.Contains(showPath, "📄") { - t.Fatalf("decorated local path was not cleaned: %q", showPath) - } - if !strings.Contains(show, "House.of.the.Dragon.S03E01.mkv") || strings.Contains(show, "%5B") || strings.Contains(show, "%F0%9F") { - t.Fatalf("decorated tree ref was not cleaned: %q", show) - } - episode := readSTRM(t, filepath.Join(outDir, "日番", "Frieren.S01E01.strm")) - if !strings.Contains(episode, "ref=%2F%E5%8A%A8%E6%BC%AB%2F%E6%97%A5%E7%95%AA%2FFrieren.S01E01.mp4") { - t.Fatalf("decorated direct path ref was not cleaned: %q", episode) - } -} - -func TestGenerateSTRMFromTreeStripsExportedFileMetadata(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - tree := strings.Join([]string{ - "电影", - "└── 欧美电影", - " └── Dune.Part.Two.2024.2160p.WEB-DL.mkv 18.6 GB 2024-04-01 12:30", - }, "\n") - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - TreeText: tree, - Paths: []string{"/电视剧/欧美剧/Show/Season 01/Show.S01E01.mp4 (2.1 GB)"}, - SourceRoot: "/电视剧", - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 2 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want two generated videos with metadata suffix stripped", res) - } - movie := readSTRM(t, filepath.Join(outDir, "电影", "欧美电影", "Dune.Part.Two.2024.2160p.WEB-DL.strm")) - if !strings.Contains(movie, "Dune.Part.Two.2024.2160p.WEB-DL.mkv") || strings.Contains(movie, "18.6") { - t.Fatalf("movie strm url = %q, want clean media ref without size metadata", movie) - } - episode := readSTRM(t, filepath.Join(outDir, "欧美剧", "Show", "Season 01", "Show.S01E01.strm")) - if !strings.Contains(episode, "Show.S01E01.mp4") || strings.Contains(episode, "2.1") { - t.Fatalf("episode strm url = %q, want clean media ref without size metadata", episode) - } -} diff --git a/internal/service/strm_tree_paths.go b/internal/service/strm_tree_paths.go deleted file mode 100644 index 1df4e5c..0000000 --- a/internal/service/strm_tree_paths.go +++ /dev/null @@ -1,187 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "path" - "path/filepath" - "strconv" - "strings" -) - -func strmTreeRelativeSource(source, sourceRoot string) string { - source = normalizeSTRMTreeSource(source) - root := normalizeRemotePath(sourceRoot) - if root != "/" && strings.HasPrefix(source, root+"/") { - return strings.TrimPrefix(strings.TrimPrefix(source, root), "/") - } - return strings.TrimPrefix(source, "/") -} - -func strmTreeCloudRef(source, sourceRoot string) string { - source = normalizeSTRMTreeSource(source) - if strings.HasPrefix(source, "/") { - return source - } - if strings.TrimSpace(sourceRoot) != "" { - return joinRemotePath(sourceRoot, source) - } - return normalizeRemotePath(source) -} - -func strmTreeOutputRelativePath(source string) (string, error) { - return strmTreeOutputRelativePathWithLinkExtension(source, videoExtensions, ".strm", false) -} - -func (s *STRMService) strmTreeRecognizedOutputRelativePath(ctx context.Context, source, sourceRoot string) (string, error) { - source = normalizeSTRMTreeSource(source) - season, episode := ParseEpisode(source) - title, year := s.strmTreeCleanQuery(ctx, source) - if season > 0 || episode > 0 { - show := s.strmTreeRecognizedSeriesTitle(ctx, source, sourceRoot, title) - if show == "" { - return "", errors.New("empty recognized series title") - } - if season <= 0 { - season = 1 - } - if episode <= 0 { - return "", errors.New("missing recognized episode number") - } - name := fmt.Sprintf("%s S%02dE%02d.strm", show, season, episode) - return filepath.Join(sanitizeFilename(show), fmt.Sprintf("Season %02d", season), sanitizeFilename(name)), nil - } - if title == "" { - return "", errors.New("empty recognized movie title") - } - movie := sanitizeFilename(titleCaseWords(title)) - if movie == "" { - return "", errors.New("empty recognized movie filename") - } - folder := movie - if year > 0 && !strings.Contains(folder, strconv.Itoa(year)) { - folder = fmt.Sprintf("%s (%d)", movie, year) - } - return filepath.Join(sanitizeFilename(folder), sanitizeFilename(folder)+".strm"), nil -} - -func (s *STRMService) strmTreeRecognizedSeriesTitle(ctx context.Context, source, sourceRoot, fallback string) string { - rel := strmTreeRelativeSource(source, sourceRoot) - parts := strings.Split(strings.Trim(strings.ReplaceAll(rel, "\\", "/"), "/"), "/") - if len(parts) >= 2 { - dir := parts[len(parts)-2] - if strings.TrimSpace(dir) != "" && !seriesSeasonDirRE.MatchString(dir) { - if title, _ := s.strmTreeCleanQuery(ctx, dir); title != "" { - return sanitizeFilename(titleCaseWords(title)) - } - return sanitizeFilename(strings.TrimSpace(dir)) - } - } - if len(parts) >= 3 { - dir := parts[len(parts)-3] - if title, _ := s.strmTreeCleanQuery(ctx, dir); title != "" { - return sanitizeFilename(titleCaseWords(title)) - } - return sanitizeFilename(strings.TrimSpace(dir)) - } - if fallback != "" { - return sanitizeFilename(titleCaseWords(fallback)) - } - return "" -} - -func (s *STRMService) strmTreeCleanQuery(ctx context.Context, raw string) (string, int) { - if s == nil { - return CleanQuery(raw) - } - return CleanQueryWithRecognition(ctx, s.repo, raw) -} - -func strmTreeOutputSubtitleLinkRelativePath(source string) (string, error) { - return strmTreeOutputRelativePathWithLinkExtension(source, strmTreeSubtitleExtensions, ".strm", true) -} - -func strmTreeOutputRelativePathWithLinkExtension(source string, allowedExtensions map[string]struct{}, linkExtension string, appendLinkExtension bool) (string, error) { - parts := strings.Split(strings.Trim(strings.ReplaceAll(source, "\\", "/"), "/"), "/") - if len(parts) == 0 { - return "", errors.New("empty source path") - } - out := make([]string, 0, len(parts)) - for i, part := range parts { - part = strings.TrimSpace(part) - if part == "" || part == "." || part == ".." { - return "", fmt.Errorf("unsafe path segment %q", part) - } - if i == len(parts)-1 { - ext := strings.ToLower(path.Ext(part)) - if _, ok := allowedExtensions[ext]; !ok { - return "", fmt.Errorf("unsupported media extension %q", ext) - } - if linkExtension != "" { - if appendLinkExtension { - part += linkExtension - } else { - part = strings.TrimSuffix(part, path.Ext(part)) + linkExtension - } - } - } - safe := sanitizeFilename(part) - if safe == "" { - return "", errors.New("empty sanitized path segment") - } - out = append(out, safe) - } - return filepath.Join(out...), nil -} - -func strmTreeSubtitleMatchesVideo(subtitle strmTreeSource, videos []strmTreeSource) bool { - subDir, subBase := strmTreeDirAndBase(subtitle.Path) - if subBase == "" { - return false - } - for _, video := range videos { - if video.Kind != "" && video.Kind != strmTreeSourceKindVideo { - continue - } - if !strings.EqualFold(subtitle.Provider, video.Provider) { - continue - } - videoDir, videoBase := strmTreeDirAndBase(video.Path) - if !strings.EqualFold(subDir, videoDir) || videoBase == "" { - continue - } - if strings.EqualFold(subBase, videoBase) || strings.HasPrefix(strings.ToLower(subBase), strings.ToLower(videoBase)+".") { - return true - } - } - return false -} - -func strmTreeDirAndBase(source string) (string, string) { - source = normalizeSTRMTreeSource(source) - dir := path.Dir(source) - name := path.Base(source) - base := strings.TrimSuffix(name, path.Ext(name)) - return strings.ToLower(strings.Trim(dir, "/")), strings.ToLower(base) -} - -func strmTreeOutputPrefixPath(prefix string) (string, error) { - parts := strings.Split(strings.Trim(strings.ReplaceAll(prefix, "\\", "/"), "/"), "/") - out := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - if part == "." || part == ".." { - return "", fmt.Errorf("unsafe output prefix segment %q", part) - } - safe := sanitizeFilename(part) - if safe == "" { - return "", errors.New("empty sanitized output prefix segment") - } - out = append(out, safe) - } - return filepath.Join(out...), nil -} diff --git a/internal/service/strm_tree_url_test.go b/internal/service/strm_tree_url_test.go deleted file mode 100644 index 2690e97..0000000 --- a/internal/service/strm_tree_url_test.go +++ /dev/null @@ -1,312 +0,0 @@ -package service - -import ( - "net/url" - "os" - "path/filepath" - "strings" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestGenerateSTRMFromTreeUsesMediaPathFromURLQuery(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{ - "https://openlist.example.com/api/fs/get?path=%2FMovies%2FDune.Part.Two.2024.mkv", - "https://openlist.example.com/api/fs/get?path=%2FMovies%2FA%2BB.2026.mkv", - "https://openlist.example.com/api/fs/get?path=/Movies/A+B.Raw.2026.mkv", - "https://openlist.example.com/api/raw?ref=/Shows/Some.Show/S01E01.mp4", - "https://cdn.example.com/media/Movies/Direct.Movie.2026.mkv?token=secret", - "https://cdn.example.com/media/Movies/A+B.Direct.2026.mkv?token=secret", - "https://openlist.example.com/api/fs/get?id=12345", - }, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 6 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want six generated videos and non-media API URL ignored", res) - } - movie := readSTRM(t, filepath.Join(outDir, "Movies", "Dune.Part.Two.2024.strm")) - if !strings.Contains(movie, "ref=%2FMovies%2FDune.Part.Two.2024.mkv") || strings.Contains(movie, "api%2Ffs%2Fget") { - t.Fatalf("movie strm url = %q, want query media path as cloud ref", movie) - } - plusMovie := readSTRM(t, filepath.Join(outDir, "Movies", "A+B.2026.strm")) - if !strings.Contains(plusMovie, "ref=%2FMovies%2FA%2BB.2026.mkv") || strings.Contains(plusMovie, "A+B.2026.mkv") { - t.Fatalf("plus movie strm url = %q, want literal plus preserved and encoded in ref", plusMovie) - } - rawPlusMovie := readSTRM(t, filepath.Join(outDir, "Movies", "A+B.Raw.2026.strm")) - if !strings.Contains(rawPlusMovie, "ref=%2FMovies%2FA%2BB.Raw.2026.mkv") || strings.Contains(rawPlusMovie, "A+B.Raw.2026.mkv") { - t.Fatalf("raw plus movie strm url = %q, want raw query plus preserved and encoded in ref", rawPlusMovie) - } - show := readSTRM(t, filepath.Join(outDir, "Shows", "Some.Show", "S01E01.strm")) - if !strings.Contains(show, "ref=%2FShows%2FSome.Show%2FS01E01.mp4") { - t.Fatalf("show strm url = %q, want ref query media path", show) - } - direct := readSTRM(t, filepath.Join(outDir, "media", "Movies", "Direct.Movie.2026.strm")) - if !strings.Contains(direct, "ref=%2Fmedia%2FMovies%2FDirect.Movie.2026.mkv") { - t.Fatalf("direct url strm = %q, want normal URL path media ref", direct) - } - plusDirect := readSTRM(t, filepath.Join(outDir, "media", "Movies", "A+B.Direct.2026.strm")) - if !strings.Contains(plusDirect, "ref=%2Fmedia%2FMovies%2FA%2BB.Direct.2026.mkv") { - t.Fatalf("plus direct url strm = %q, want URL path plus preserved", plusDirect) - } - if _, err := os.Stat(filepath.Join(outDir, "api", "fs", "get.strm")); !os.IsNotExist(err) { - t.Fatalf("non-media API URL should not generate STRM, stat err=%v", err) - } -} - -func TestGenerateSTRMFromTreePreservesSourceProvider(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "115", - Paths: []string{ - "cloud://openlist/Movies/OpenList.Movie.2026.mkv", - "/api/cloud/play/cloud115?ref=%2FShows%2FCloud115.Show.S01E01.mkv", - "/api/cloud/play/openlist?ref=%2FMovies%2FMy+Space.Movie.2026.mkv", - "https://media.example.com/api/cloud/play/openlist?ref=%2FMovies%2FRemote.OpenList.Movie.2026.mkv", - "/Movies/Fallback.115.Movie.2026.mkv", - }, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 5 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want five generated videos", res) - } - openlist := readSTRM(t, filepath.Join(outDir, "Movies", "OpenList.Movie.2026.strm")) - if !strings.Contains(openlist, "/api/cloud/play/openlist?") || !strings.Contains(openlist, "ref=%2FMovies%2FOpenList.Movie.2026.mkv") { - t.Fatalf("cloud:// source url = %q, want openlist provider and original ref", openlist) - } - cloud115 := readSTRM(t, filepath.Join(outDir, "Shows", "Cloud115.Show.S01E01.strm")) - if !strings.Contains(cloud115, "/api/cloud/play/cloud115?") || !strings.Contains(cloud115, "ref=%2FShows%2FCloud115.Show.S01E01.mkv") { - t.Fatalf("cloud play source url = %q, want cloud115 provider preserved", cloud115) - } - spaceMoviePath := filepath.Join(outDir, "Movies", "My Space.Movie.2026.strm") - spaceMovie := readSTRM(t, spaceMoviePath) - if _, err := os.Stat(filepath.Join(outDir, "Movies", "My+Space.Movie.2026.strm")); !os.IsNotExist(err) { - t.Fatalf("cloud play source should not create literal-plus local path, stat err=%v", err) - } - spaceURL, err := url.Parse(spaceMovie) - if err != nil { - t.Fatalf("parse cloud play source url %q: %v", spaceMovie, err) - } - if got := spaceURL.Query().Get("ref"); got != "/Movies/My Space.Movie.2026.mkv" { - t.Fatalf("cloud play source ref = %q, want decoded space path", got) - } - remoteOpenlist := readSTRM(t, filepath.Join(outDir, "Movies", "Remote.OpenList.Movie.2026.strm")) - if !strings.Contains(remoteOpenlist, "/api/cloud/play/openlist?") || !strings.Contains(remoteOpenlist, "ref=%2FMovies%2FRemote.OpenList.Movie.2026.mkv") { - t.Fatalf("absolute cloud play source url = %q, want openlist provider preserved", remoteOpenlist) - } - fallback := readSTRM(t, filepath.Join(outDir, "Movies", "Fallback.115.Movie.2026.strm")) - if !strings.Contains(fallback, "/api/cloud/play/cloud115?") || !strings.Contains(fallback, "ref=%2FMovies%2FFallback.115.Movie.2026.mkv") { - t.Fatalf("plain source url = %q, want fallback cloud115 provider", fallback) - } -} - -func TestGenerateSTRMFromTreeCloudMountUsesScanDirForRef(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "115", - Paths: []string{ - "cloud://openlist/%E7%94%B5%E5%BD%B1/%E5%88%AB%E5%90%8D%E7%9B%AE%E5%BD%95/Alias.Movie.2026.mkv?dir=%2Factual%2Fcloud%2Fmovies%2FAlias.Movie.2026.mkv", - }, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want one generated video", res) - } - got := readSTRM(t, filepath.Join(outDir, "电影", "别名目录", "Alias.Movie.2026.strm")) - if !strings.Contains(got, "/api/cloud/play/openlist?") { - t.Fatalf("strm url = %q, want provider from cloud mount", got) - } - if !strings.Contains(got, "ref=%2Factual%2Fcloud%2Fmovies%2FAlias.Movie.2026.mkv") { - t.Fatalf("strm url = %q, want dir scan path as playable ref", got) - } - if strings.Contains(got, "%E5%88%AB%E5%90%8D%E7%9B%AE%E5%BD%95") { - t.Fatalf("strm url = %q, display path leaked into playable ref", got) - } -} - -func TestGenerateSTRMFromTreeDoesNotDedupeDifferentProviders(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "115", - Paths: []string{"cloud://openlist/Movies/Same.Movie.mkv", "cloud://cloud115/Movies/Same.Movie.mkv"}, - OutputDir: outDir, - Overwrite: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || res.Updated != 1 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want both provider-specific sources processed", res) - } - got := readSTRM(t, filepath.Join(outDir, "Movies", "Same.Movie.strm")) - if !strings.Contains(got, "/api/cloud/play/cloud115?") || !strings.Contains(got, "ref=%2FMovies%2FSame.Movie.mkv") { - t.Fatalf("final strm url = %q, want second provider write to prove it was not deduped", got) - } -} - -func TestGenerateSTRMFromTreeMissingOnlySkipsMediaAlreadyInLibrary(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - rows := []model.Media{ - { - Base: model.Base{ID: "existing-strm"}, - Title: "Existing STRM", - Path: "cloud://openlist/Movies/Existing.Path.Copy.mkv", - STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FExisting.Movie.mkv", - }, - { - Base: model.Base{ID: "existing-cloud-path"}, - Title: "Existing Cloud Path", - Path: "cloud://cloud115/Shows/Existing.Show.S01E01.mkv", - }, - { - Base: model.Base{ID: "different-provider"}, - Title: "Different Provider", - Path: "cloud://cloud115/Shows/Same.Provider.mkv", - }, - } - for i := range rows { - if err := repos.DB.Create(&rows[i]).Error; err != nil { - t.Fatal(err) - } - } - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), repos, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{ - "cloud://openlist/Movies/Existing.Movie.mkv", - "cloud://openlist/Movies/New.Movie.mkv", - "cloud://cloud115/Shows/Existing.Show.S01E01.mkv", - "cloud://openlist/Shows/Same.Provider.mkv", - }, - OutputDir: outDir, - MissingOnly: true, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 2 || res.Skipped != 2 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want generated=2 skipped=2", res) - } - if _, err := os.Stat(filepath.Join(outDir, "Movies", "Existing.Movie.strm")); !os.IsNotExist(err) { - t.Fatalf("existing media should not generate STRM, stat err=%v", err) - } - assertFileContains(t, filepath.Join(outDir, "Movies", "New.Movie.strm"), "/api/cloud/play/openlist?ref=%2FMovies%2FNew.Movie.mkv") - assertFileContains(t, filepath.Join(outDir, "Shows", "Same.Provider.strm"), "/api/cloud/play/openlist?ref=%2FShows%2FSame.Provider.mkv") - for _, item := range res.Items { - if item.Title == "Existing.Movie" && item.Reason != "already in media library" { - t.Fatalf("existing media skip reason = %q", item.Reason) - } - } -} - -func TestGenerateSTRMFromTreeStripsPathListPrefixes(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - tree := strings.Join([]string{ - "- /Movies/Dune.Part.Two.2024.mkv", - "* https://openlist.example.com/api/fs/get?path=%2FShows%2FSome.Show%2FS01E01.mp4", - "1. /Anime/Frieren/Frieren.S01E01.mp4", - "2) /Anime/Frieren/Frieren.S01E02.mp4", - "• /Documentaries/Earth.2026.mkv", - }, "\n") - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - TreeText: tree, - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 5 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want five generated videos with list prefixes stripped", res) - } - moviePath := filepath.Join(outDir, "Movies", "Dune.Part.Two.2024.strm") - movie := readSTRM(t, moviePath) - if strings.Contains(moviePath, "- ") || !strings.Contains(movie, "ref=%2FMovies%2FDune.Part.Two.2024.mkv") { - t.Fatalf("markdown bullet prefix leaked into movie path/ref: path=%q url=%q", moviePath, movie) - } - show := readSTRM(t, filepath.Join(outDir, "Shows", "Some.Show", "S01E01.strm")) - if strings.Contains(show, "api%2Ffs%2Fget") || !strings.Contains(show, "ref=%2FShows%2FSome.Show%2FS01E01.mp4") { - t.Fatalf("bullet URL query source was not cleaned: %q", show) - } - if _, err := os.Stat(filepath.Join(outDir, "1. ", "Anime", "Frieren", "Frieren.S01E01.strm")); !os.IsNotExist(err) { - t.Fatalf("numbered prefix should not create a literal prefix directory, stat err=%v", err) - } - if got := readSTRM(t, filepath.Join(outDir, "Documentaries", "Earth.2026.strm")); !strings.Contains(got, "ref=%2FDocumentaries%2FEarth.2026.mkv") { - t.Fatalf("round bullet source ref = %q", got) - } -} - -func TestGenerateSTRMFromTreeOutputPrefixOnlyAffectsLocalPath(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"/cloud/Dune.Part.Two.2024.mkv"}, - SourceRoot: "/cloud", - OutputPrefix: "电影/欧美电影", - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 1 || len(res.Errors) != 0 { - t.Fatalf("result = %#v, want one generated video", res) - } - got := readSTRM(t, filepath.Join(outDir, "电影", "欧美电影", "Dune.Part.Two.2024.strm")) - if strings.Contains(got, "%E7%94%B5%E5%BD%B1") || strings.Contains(got, "%E6%AC%A7%E7%BE%8E%E7%94%B5%E5%BD%B1") { - t.Fatalf("strm url = %q, output prefix should not be injected into cloud ref", got) - } - if !strings.Contains(got, "ref=%2Fcloud%2FDune.Part.Two.2024.mkv") { - t.Fatalf("strm url = %q, want original cloud ref", got) - } -} - -func TestGenerateSTRMFromTreeRejectsUnsafeOutputPrefix(t *testing.T) { - outDir := filepath.Join(t.TempDir(), "strm") - svc := NewSTRMService(zap.NewNop(), nil, nil) - - res, err := svc.GenerateFromTree(t.Context(), GenerateSTRMTreeOptions{ - Provider: "openlist", - Paths: []string{"Movies/Movie.mkv"}, - OutputPrefix: "../escape", - OutputDir: outDir, - }) - if err != nil { - t.Fatal(err) - } - if res.Generated != 0 || len(res.Errors) != 1 { - t.Fatalf("result = %#v, want unsafe prefix rejected", res) - } - if _, err := os.Stat(filepath.Join(outDir, "..", "escape", "Movies", "Movie.strm")); !os.IsNotExist(err) { - t.Fatalf("unsafe prefixed strm should not be written, stat err=%v", err) - } -} diff --git a/internal/service/strm_url.go b/internal/service/strm_url.go deleted file mode 100644 index 6481d0f..0000000 --- a/internal/service/strm_url.go +++ /dev/null @@ -1,196 +0,0 @@ -package service - -import ( - "context" - "fmt" - "net/url" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/golang-jwt/jwt/v5" - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *STRMService) strmPlaybackURL(ctx context.Context, media model.Media, baseURL, playbackToken string) string { - if media.ID == "" { - return "" - } - query := url.Values{} - token := strings.TrimSpace(playbackToken) - if token == "" { - token = s.defaultSTRMPlaybackToken(ctx) - } - if token != "" { - query.Set("token", token) - } - return buildAbsoluteSTRMAPIURL(firstNonEmpty(baseURL, PublicServerURL(ctx, s.repo, s.cfg)), "/api/stream/"+url.PathEscape(media.ID), query) -} - -func (s *STRMService) defaultSTRMPlaybackToken(ctx context.Context) string { - if s == nil || s.repo == nil || s.repo.User == nil || s.cfg == nil || strings.TrimSpace(s.cfg.Secrets.JWTSecret) == "" { - return "" - } - admin, err := s.repo.User.FirstAdmin(ctx) - if err != nil || admin == nil { - if err != nil && s.log != nil { - s.log.Warn("generate strm playback token failed", zap.Error(err)) - } - return "" - } - token, err := signSTRMPlaybackToken(admin, s.cfg.Secrets.JWTSecret) - if err != nil { - if s.log != nil { - s.log.Warn("sign strm playback token failed", zap.Error(err)) - } - return "" - } - return token -} - -func signSTRMPlaybackToken(u *model.User, secret string) (string, error) { - if u == nil || strings.TrimSpace(u.ID) == "" || strings.TrimSpace(secret) == "" { - return "", ErrSTRMURLInvalid - } - claims := Claims{ - UserID: u.ID, - Role: u.Role, - Tier: u.Tier, - RegisteredClaims: jwt.RegisteredClaims{ - IssuedAt: jwt.NewNumericDate(time.Now()), - ExpiresAt: jwt.NewNumericDate(time.Now().Add(EmbyTokenDuration)), - Issuer: "mediastationgo", - Subject: u.ID, - }, - } - t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return t.SignedString([]byte(secret)) -} - -func (s *STRMService) strmRelativePath(lib model.Library, media model.Media) string { - title := strings.TrimSpace(media.Title) - if title == "" { - title = strings.TrimSuffix(filepath.Base(media.Path), filepath.Ext(media.Path)) - } - if title == "" { - return "" - } - seriesLike := isSeriesLibraryType(lib.Type) || media.SeasonNum > 0 || media.EpisodeNum > 0 - if seriesLike { - show := inferSeriesNameFromPath(media.Path) - if show == "" { - show = title - } - season := media.SeasonNum - episode := media.EpisodeNum - if season <= 0 || episode <= 0 { - parsedSeason, parsedEpisode := ParseEpisode(media.Path) - if season <= 0 { - season = parsedSeason - } - if episode <= 0 { - episode = parsedEpisode - } - } - if season <= 0 { - season = 1 - } - name := strings.TrimSuffix(filepath.Base(media.Path), filepath.Ext(media.Path)) - if episode > 0 { - name = fmt.Sprintf("%s - S%02dE%02d", show, season, episode) - } else if strings.TrimSpace(name) == "" { - name = title - } - return filepath.Join(sanitizeFilename(show), fmt.Sprintf("Season %02d", season), sanitizeFilename(name)+".strm") - } - folder := title - if media.Year > 0 && !strings.Contains(folder, strconv.Itoa(media.Year)) { - folder = fmt.Sprintf("%s (%d)", folder, media.Year) - } - safe := sanitizeFilename(folder) - return filepath.Join(safe, safe+".strm") -} - -func (s *STRMService) strmTreeRelativePath(media model.Media) string { - parts := strmLibraryPathParts(media.Path) - if len(parts) == 0 { - return "" - } - parts = strmDropCategoryPrefix(parts) - if len(parts) == 0 { - return "" - } - last := parts[len(parts)-1] - ext := filepath.Ext(last) - if ext == "" { - return "" - } - parts[len(parts)-1] = strings.TrimSuffix(last, ext) + ".strm" - clean := make([]string, 0, len(parts)) - for _, part := range parts { - if safe := sanitizeFilename(part); safe != "" { - clean = append(clean, safe) - } - } - if len(clean) == 0 { - return "" - } - return filepath.Join(clean...) -} - -func strmDropCategoryPrefix(parts []string) []string { - if len(parts) == 0 { - return nil - } - for i, part := range parts { - if strmCanonicalRoot(part) == "" && strmCategoryRoot(part) == "" { - continue - } - next := i + 1 - if strmCanonicalRoot(part) != "" && next < len(parts) && strmCategoryRoot(parts[next]) != "" { - next++ - } - if next < len(parts) { - return append([]string(nil), parts[next:]...) - } - } - return append([]string(nil), parts...) -} - -func absolutizeSTRMURL(raw, baseURL string) string { - raw = strings.TrimSpace(raw) - if raw == "" || strings.HasPrefix(raw, "//") { - return raw - } - u, err := url.Parse(raw) - if err == nil && u.IsAbs() { - return raw - } - return buildAbsoluteSTRMAPIURL(baseURL, raw, nil) -} - -func buildAbsoluteSTRMAPIURL(baseURL, apiPath string, query url.Values) string { - apiPath = "/" + strings.TrimLeft(strings.TrimSpace(apiPath), "/") - if query != nil && len(query) > 0 { - apiPath += "?" + query.Encode() - } - baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") - if baseURL == "" { - return apiPath - } - base, err := url.Parse(baseURL) - if err != nil || base.Scheme == "" || base.Host == "" { - return apiPath - } - target, err := url.Parse(apiPath) - if err != nil { - return apiPath - } - base.Path = strings.TrimRight(base.Path, "/") + "/" + strings.TrimLeft(target.Path, "/") - base.RawQuery = target.RawQuery - base.Fragment = "" - return base.String() -} diff --git a/internal/service/subscription.go b/internal/service/subscription.go deleted file mode 100644 index f4e788e..0000000 --- a/internal/service/subscription.go +++ /dev/null @@ -1,274 +0,0 @@ -// Package service — RSS subscriptions for automated downloads. -// -// SubscriptionService periodically polls every Subscription row, fetches -// the configured RSS / Atom feed, and queues new items into the -// DownloadService. Items are deduplicated by GUID stored as a Setting key -// "subscription..last_guid" so the same episode is never re-queued. -package service - -import ( - "context" - "errors" - "fmt" - "strconv" - "strings" - "sync" - "time" - - "go.uber.org/zap" - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// SubscriptionService runs the polling loop. -type SubscriptionService struct { - cfg *config.Config - log *zap.Logger - repo *repository.Container - downloads *DownloadService - site *SiteService - scraper *ScraperService - hub *Hub - notify *NotifyChannelService - mu sync.Mutex - stop chan struct{} - running bool -} - -const ( - defaultSubscriptionPollInterval = 3 * time.Hour - minSubscriptionPollInterval = 3 * time.Hour - subscriptionStartupDelay = defaultSubscriptionPollInterval -) - -// NewSubscriptionService is the constructor. -func NewSubscriptionService(cfg *config.Config, log *zap.Logger, repo *repository.Container, downloads *DownloadService, site *SiteService, hub *Hub) *SubscriptionService { - return &SubscriptionService{ - cfg: cfg, - log: log, - repo: repo, - downloads: downloads, - site: site, - hub: hub, - } -} - -func (s *SubscriptionService) SetScraper(scraper *ScraperService) { - s.scraper = scraper -} - -func (s *SubscriptionService) SetNotifyChannels(notify *NotifyChannelService) { - s.notify = notify -} - -// Start runs the polling loop in the background. -func (s *SubscriptionService) Start(ctx context.Context) { - s.mu.Lock() - if s.running { - s.mu.Unlock() - return - } - stop := make(chan struct{}) - s.stop = stop - s.running = true - s.mu.Unlock() - go s.loop(ctx, stop) -} - -// Stop shuts the loop down. -func (s *SubscriptionService) Stop() { - s.mu.Lock() - if !s.running { - s.mu.Unlock() - return - } - stop := s.stop - s.stop = nil - s.running = false - s.mu.Unlock() - close(stop) -} - -// Create persists a new subscription. -func (s *SubscriptionService) Create(ctx context.Context, sub *model.Subscription) error { - if sub.Name == "" || sub.FeedURL == "" { - return errors.New("name and feed_url required") - } - normalizeSubscriptionDefaults(sub) - model.RefreshSubscriptionIdentity(sub) - if duplicate, err := s.subscriptionDuplicate(ctx, sub, ""); err != nil { - return err - } else if duplicate != nil { - return newSubscriptionAlreadyExistsError(duplicate.ID) - } - enabled := sub.Enabled - if err := s.repo.Subscription.Create(ctx, sub); err != nil { - if duplicate, lookupErr := s.subscriptionDuplicate(ctx, sub, ""); lookupErr == nil && duplicate != nil { - return newSubscriptionAlreadyExistsError(duplicate.ID) - } - return err - } - if !enabled { - if err := s.repo.DB.WithContext(ctx).Model(sub).Update("enabled", false).Error; err != nil { - return err - } - sub.Enabled = false - } - return nil -} - -func normalizeSubscriptionDefaults(sub *model.Subscription) { - if strings.TrimSpace(sub.SearchMode) == "" { - sub.SearchMode = "keyword" - } - if strings.TrimSpace(sub.Resolution) == "" { - sub.Resolution = "best" - } - if strings.TrimSpace(sub.WashPriority) == "" { - sub.WashPriority = "balanced" - } - if sub.Priority == 0 { - sub.Priority = 50 - } -} - -// List returns every subscription rule. -func (s *SubscriptionService) List(ctx context.Context) ([]model.Subscription, error) { - return s.repo.Subscription.List(ctx) -} - -// Delete removes a subscription. -func (s *SubscriptionService) Delete(ctx context.Context, id string) error { - var sub model.Subscription - if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&sub).Error; err != nil { - if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - if err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).First(&sub).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil - } - return err - } - } - if err := s.deleteSubscriptionDownloads(ctx, &sub); err != nil { - return err - } - if s.repo.Setting != nil { - _ = s.repo.Setting.Delete(ctx, fmt.Sprintf("subscription.%s.seen", id)) - } - return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Unscoped().Model(&model.Subscription{}).Where("id = ?", id).Update("enabled", false).Error; err != nil { - return err - } - if sub.DeletedAt.Valid { - return nil - } - return tx.Where("id = ?", id).Delete(&model.Subscription{}).Error - }) -} - -// RunNow forces a poll for one subscription, ignoring its schedule. Used -// by the admin UI's "test now" button. -func (s *SubscriptionService) RunNow(ctx context.Context, id string) (int, error) { - var sub model.Subscription - if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&sub).Error; err != nil { - return 0, err - } - if sub.ArchivedAt != nil { - if s.log != nil { - s.log.Info("subscription run skipped because it is archived", - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.String("archive_reason", sub.ArchiveReason)) - } - return 0, nil - } - return s.runOne(ctx, &sub) -} - -// loop polls subscription feeds and site-search subscriptions at a conservative -// cadence so tracker APIs are not hammered by every alias keyword. -func (s *SubscriptionService) loop(ctx context.Context, stop <-chan struct{}) { - defer s.markLoopStopped(stop) - interval := s.pollInterval(ctx) - delay := subscriptionStartupDelay - if interval < delay { - delay = interval - } - for { - timer := time.NewTimer(delay) - select { - case <-ctx.Done(): - timer.Stop() - return - case <-stop: - timer.Stop() - return - case <-timer.C: - } - s.runAll(ctx) - // Re-read after every run so changes from the settings page take effect - // without restarting the service. - delay = s.pollInterval(ctx) - } -} - -func (s *SubscriptionService) markLoopStopped(stop <-chan struct{}) { - s.mu.Lock() - defer s.mu.Unlock() - if s.stop == stop { - s.stop = nil - s.running = false - } -} - -func (s *SubscriptionService) pollInterval(ctx context.Context) time.Duration { - if s == nil || s.repo == nil || s.repo.Setting == nil { - return defaultSubscriptionPollInterval - } - raw, err := s.repo.Setting.Get(ctx, "subscription.interval_seconds") - if err != nil { - return defaultSubscriptionPollInterval - } - seconds, err := strconv.Atoi(strings.TrimSpace(raw)) - if err != nil || seconds <= 0 { - return defaultSubscriptionPollInterval - } - interval := time.Duration(seconds) * time.Second - if interval < minSubscriptionPollInterval { - return minSubscriptionPollInterval - } - return interval -} - -func (s *SubscriptionService) runAll(ctx context.Context) { - subs, err := s.repo.Subscription.List(ctx) - if err != nil { - s.log.Warn("subscription list failed", zap.Error(err)) - return - } - if s.log != nil { - s.log.Info("subscription sweep started", zap.Int("count", len(subs))) - } - for i := range subs { - if !subs[i].Enabled { - continue - } - if n, err := s.runOne(ctx, &subs[i]); err != nil { - s.log.Warn("subscription run failed", - zap.String("name", subs[i].Name), zap.Error(err)) - if subscriptionSiteSearchShouldStopOnError(err) { - s.log.Warn("subscription sweep stopped after upstream failure", - zap.String("name", subs[i].Name), zap.Error(err)) - return - } - } else if n > 0 { - s.log.Info("subscription queued items", - zap.String("name", subs[i].Name), zap.Int("count", n)) - } - } -} diff --git a/internal/service/subscription_archive.go b/internal/service/subscription_archive.go deleted file mode 100644 index 76b8a29..0000000 --- a/internal/service/subscription_archive.go +++ /dev/null @@ -1,173 +0,0 @@ -package service - -import ( - "context" - "fmt" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// History returns completed/archived subscription rules. -func (s *SubscriptionService) History(ctx context.Context) ([]model.Subscription, error) { - return s.repo.Subscription.History(ctx) -} - -// Restore moves an archived subscription back to the active management list. -// It also clears the per-subscription seen state so an unfinished historical -// rule can match resources again when it is run next. -func (s *SubscriptionService) Restore(ctx context.Context, id string) (*model.Subscription, error) { - var sub model.Subscription - if err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).First(&sub).Error; err != nil { - return nil, err - } - sub.Enabled = true - sub.ArchivedAt = nil - sub.ArchiveReason = "" - sub.DeletedAt.Valid = false - sub.TotalEpisodes = 0 - normalizeSubscriptionDefaults(&sub) - model.RefreshSubscriptionIdentity(&sub) - if duplicate, err := s.subscriptionDuplicate(ctx, &sub, sub.ID); err != nil { - return nil, err - } else if duplicate != nil { - return nil, newSubscriptionAlreadyExistsError(duplicate.ID) - } - if err := s.repo.DB.WithContext(ctx).Unscoped().Model(&model.Subscription{}). - Where("id = ?", id). - Updates(map[string]any{ - "enabled": true, - "archived_at": nil, - "archive_reason": "", - "deleted_at": nil, - "identity_key": sub.IdentityKey, - // 重置为 0:此前可能被 feed 低估并锁死(updateSubscriptionTotalEpisodes - // 只增不减,resolveSubscriptionTotalEpisodes 见 >0 即不再回查元数据)。 - // 归零后下次 run 会从 TMDb/豆瓣等权威源重算真实总集数,避免恢复后 - // 因"误判已无缺集"而不再搜索资源。 - "total_episodes": 0, - }).Error; err != nil { - if duplicate, lookupErr := s.subscriptionDuplicate(ctx, &sub, sub.ID); lookupErr == nil && duplicate != nil { - return nil, newSubscriptionAlreadyExistsError(duplicate.ID) - } - return nil, err - } - if s.repo.Setting != nil { - _ = s.repo.Setting.Delete(ctx, fmt.Sprintf("subscription.%s.seen", id)) - } - var restored model.Subscription - if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&restored).Error; err != nil { - return nil, err - } - return &restored, nil -} - -func (s *SubscriptionService) archiveCompletedSubscription(ctx context.Context, sub *model.Subscription, availability LocalAvailability) error { - if s == nil || s.repo == nil || s.repo.Subscription == nil || sub == nil { - return nil - } - if !subscriptionShouldArchive(sub, availability) { - return nil - } - now := time.Now() - reason := subscriptionArchiveReason(sub, availability) - if err := s.repo.Subscription.Archive(ctx, sub.ID, reason, now); err != nil { - return err - } - sub.Enabled = false - sub.ArchivedAt = &now - sub.ArchiveReason = reason - if s.log != nil { - s.log.Info("subscription completed, moved to history", - zap.String("id", sub.ID), - zap.String("name", sub.Name), - zap.String("reason", reason)) - } - if s.hub != nil { - s.hub.Publish("subscription", map[string]any{ - "id": sub.ID, - "name": sub.Name, - "archived": true, - "reason": reason, - }) - } - return nil -} - -func subscriptionShouldArchive(sub *model.Subscription, availability LocalAvailability) bool { - if sub == nil || subscriptionAllowsWash(sub) || sub.ArchivedAt != nil { - return false - } - mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "") - seriesLike := isSubscriptionSeriesType(mediaType) || len(availability.ExistingEpisodeKeys) > 0 || len(availability.MissingEpisodeKeys) > 0 - if !seriesLike { - return availability.InLibrary || availability.LocalMediaCount > 0 || availability.DownloadedEpisodes > 0 - } - total := trustedSeriesArchiveTotal(sub, availability) - if availability.HasSeriesPack { - if len(availability.ExistingEpisodeKeys) == 0 { - return true - } - return total > 0 && availability.DownloadedEpisodes >= total && len(availability.MissingEpisodes) == 0 - } - if total > 0 { - return availability.DownloadedEpisodes >= total && len(availability.MissingEpisodes) == 0 - } - return subscriptionLooksSingleEpisode(sub) && availability.DownloadedEpisodes > 0 -} - -func trustedSeriesArchiveTotal(sub *model.Subscription, availability LocalAvailability) int { - total := 0 - if sub != nil { - total = sub.TotalEpisodes - } - if total <= 0 { - total = availability.TotalEpisodes - } - if maxEpisode := maxAvailabilityEpisode(availability.ExistingEpisodeKeys); total > 0 && maxEpisode > total { - return 0 - } - return total -} - -func maxAvailabilityEpisode(keys map[string]struct{}) int { - maxEpisode := 0 - for key := range keys { - var season, episode int - if _, err := fmt.Sscanf(key, "%02dE%03d", &season, &episode); err == nil && episode > maxEpisode { - maxEpisode = episode - } - } - return maxEpisode -} - -func subscriptionArchiveReason(sub *model.Subscription, availability LocalAvailability) string { - if subscriptionAllowsWash(sub) { - return "" - } - if availability.HasSeriesPack { - return "整季资源已加入下载/入库" - } - if availability.TotalEpisodes > 0 { - return fmt.Sprintf("订阅完成:%d/%d", availability.DownloadedEpisodes, availability.TotalEpisodes) - } - if availability.DownloadedEpisodes > 0 { - return "单集订阅已加入下载/入库" - } - return "订阅媒体已加入下载/入库" -} - -func subscriptionLooksSingleEpisode(sub *model.Subscription) bool { - if sub == nil { - return false - } - for _, value := range []string{sub.Name, sub.Filter} { - _, episode := ParseEpisode(value) - if episode > 0 { - return true - } - } - return false -} diff --git a/internal/service/subscription_archive_test.go b/internal/service/subscription_archive_test.go deleted file mode 100644 index b9cef12..0000000 --- a/internal/service/subscription_archive_test.go +++ /dev/null @@ -1,318 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSubscriptionRunOneArchivesCompletedMovieRSS(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - Dune 2021 1080p WEB-DL - dune-1080-web - magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&dn=Dune+2021+1080p+WEB-DL - -`)) - })) - defer rss.Close() - - var addCalls int32 - var added bool - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if added { - _, _ = w.Write([]byte(`[{"hash":"dunehash","name":"Dune 2021 1080p WEB-DL","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - added = true - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - - sub := &model.Subscription{ - Name: "Dune 自动订阅", - FeedURL: rss.URL, - Filter: "Dune 2021", - MediaType: "movie", - SavePath: "/downloads/movies", - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 1 { - t.Fatalf("queued = %d, want 1", queued) - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 0 { - t.Fatalf("active subscriptions = %d, want 0 after completion", len(active)) - } - history, err := repos.Subscription.History(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(history) != 1 || history[0].ArchivedAt == nil { - t.Fatalf("history = %#v, want one archived subscription", history) - } -} - -func TestSubscriptionArchiveCompletedSingleEpisodeTV(t *testing.T) { - db := newServiceTestDB(t, &model.Subscription{}) - repos := repository.New(db) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Name: "Some Show S01E01 自动订阅", - FeedURL: "site-search://search?keyword=Some%20Show%20S01E01", - Filter: "Some Show S01E01", - MediaType: "tv", - Enabled: true, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - - if err := svc.archiveCompletedSubscription(t.Context(), sub, LocalAvailability{ - DownloadedEpisodes: 1, - LocalMediaCount: 1, - InLibrary: true, - ExistingEpisodeKeys: map[string]struct{}{ - episodeKey(1, 1): {}, - }, - }); err != nil { - t.Fatal(err) - } - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 0 { - t.Fatalf("active subscriptions = %d, want 0", len(active)) - } - history, err := repos.Subscription.History(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(history) != 1 || history[0].ArchiveReason == "" { - t.Fatalf("history = %#v, want archived single episode", history) - } -} - -func TestSubscriptionArchiveKeepsGenericUnknownTotalSeriesActive(t *testing.T) { - sub := &model.Subscription{ - Name: "Some Show 自动订阅", - Filter: "Some Show", - MediaType: "tv", - } - availability := LocalAvailability{ - DownloadedEpisodes: 1, - LocalMediaCount: 1, - InLibrary: true, - ExistingEpisodeKeys: map[string]struct{}{ - episodeKey(1, 1): {}, - }, - } - if subscriptionShouldArchive(sub, availability) { - t.Fatal("generic series with unknown total should stay active for incremental episodes") - } -} - -func TestSubscriptionArchiveKeepsPartialSeriesWithParentRowActive(t *testing.T) { - sub := &model.Subscription{ - Name: "南部档案 自动订阅", - Filter: "南部档案", - } - availability := LocalAvailability{ - DownloadedEpisodes: 6, - TotalEpisodes: 1, - LocalMediaCount: 7, - InLibrary: true, - HasSeriesPack: true, - ExistingEpisodeKeys: map[string]struct{}{ - episodeKey(1, 1): {}, - episodeKey(1, 2): {}, - episodeKey(1, 3): {}, - episodeKey(1, 4): {}, - episodeKey(1, 5): {}, - episodeKey(1, 6): {}, - }, - } - if subscriptionShouldArchive(sub, availability) { - t.Fatal("partial series with a parent/collection row should stay active") - } -} - -func TestSubscriptionArchiveKeepsWashSubscriptionActive(t *testing.T) { - db := newServiceTestDB(t, &model.Subscription{}) - repos := repository.New(db) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Name: "Dune 自动订阅", - FeedURL: "site-search://search?keyword=Dune", - Filter: "Dune 2021", - MediaType: "movie", - Resolution: "2160p", - WashEnabled: true, - Enabled: true, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - - if err := svc.archiveCompletedSubscription(t.Context(), sub, LocalAvailability{ - DownloadedEpisodes: 1, - LocalMediaCount: 1, - InLibrary: true, - }); err != nil { - t.Fatal(err) - } - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 1 { - t.Fatalf("active subscriptions = %d, want wash subscription to stay active", len(active)) - } - history, err := repos.Subscription.History(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(history) != 0 { - t.Fatalf("history subscriptions = %d, want 0", len(history)) - } -} - -func TestRestoreArchivedSubscriptionReturnsToActiveAndClearsSeenState(t *testing.T) { - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}) - repos := repository.New(db) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Name: "南部档案 自动订阅", - FeedURL: "https://rss.example/feed", - Filter: "南部档案", - MediaType: "tv", - TotalEpisodes: 33, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - archivedAt := time.Now() - if err := repos.Subscription.Archive(t.Context(), sub.ID, "已下载 1/33 集,缺 33 集", archivedAt); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "subscription."+sub.ID+".seen", "old-guid"); err != nil { - t.Fatal(err) - } - restored, err := svc.Restore(t.Context(), sub.ID) - if err != nil { - t.Fatal(err) - } - if restored.ArchivedAt != nil || restored.ArchiveReason != "" || !restored.Enabled { - t.Fatalf("restored subscription not active: archived=%v reason=%q enabled=%v", restored.ArchivedAt, restored.ArchiveReason, restored.Enabled) - } - if restored.TotalEpisodes != 0 { - t.Fatalf("restored total_episodes = %d, want 0 so it gets recomputed from authoritative metadata", restored.TotalEpisodes) - } - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 1 || active[0].ID != sub.ID { - t.Fatalf("active subscriptions = %#v, want restored subscription", active) - } - history, err := repos.Subscription.History(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(history) != 0 { - t.Fatalf("history subscriptions = %d, want 0 after restore", len(history)) - } - seen, err := repos.Setting.Get(t.Context(), "subscription."+sub.ID+".seen") - if err != nil { - t.Fatal(err) - } - if seen != "" { - t.Fatalf("seen state = %q, want cleared", seen) - } -} - -func TestRestoreSoftDeletedArchivedSubscriptionReturnsToActive(t *testing.T) { - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}) - repos := repository.New(db) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Name: "Legacy Hidden History 自动订阅", - FeedURL: "https://rss.example/feed", - Filter: "Legacy Hidden History", - MediaType: "tv", - TotalEpisodes: 12, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - archivedAt := time.Now() - if err := repos.Subscription.Archive(t.Context(), sub.ID, "订阅完成:12/12", archivedAt); err != nil { - t.Fatal(err) - } - if err := db.Where("id = ?", sub.ID).Delete(&model.Subscription{}).Error; err != nil { - t.Fatal(err) - } - - restored, err := svc.Restore(t.Context(), sub.ID) - if err != nil { - t.Fatal(err) - } - if restored.ArchivedAt != nil || restored.ArchiveReason != "" || !restored.Enabled || restored.TotalEpisodes != 0 { - t.Fatalf("restored subscription not reset: %#v", restored) - } - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 1 || active[0].ID != sub.ID { - t.Fatalf("active subscriptions = %#v, want restored legacy subscription", active) - } - var deletedCount int64 - if err := db.Unscoped().Model(&model.Subscription{}). - Where("id = ? AND deleted_at IS NOT NULL", sub.ID). - Count(&deletedCount).Error; err != nil { - t.Fatal(err) - } - if deletedCount != 0 { - t.Fatal("restored subscription kept deleted_at set") - } -} diff --git a/internal/service/subscription_availability.go b/internal/service/subscription_availability.go deleted file mode 100644 index 90d2e52..0000000 --- a/internal/service/subscription_availability.go +++ /dev/null @@ -1,263 +0,0 @@ -// Package service — subscription local and pending-download availability helpers. -package service - -import ( - "context" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *SubscriptionService) pendingDownloadAvailability(ctx context.Context, sub *model.Subscription) LocalAvailability { - out := LocalAvailability{ - ExistingEpisodeKeys: map[string]struct{}{}, - MissingEpisodeKeys: map[string]struct{}{}, - } - if sub != nil { - out.TotalEpisodes = sub.TotalEpisodes - } - queries := subscriptionAvailabilityQueries(sub) - if len(queries) == 0 { - return s.finalizePendingAvailability(sub, out) - } - root := s.subscriptionBaseSavePath(ctx, sub) - if root != "" { - _ = scanDownloadPathAny(ctx, root, queries, func(path string, season, episode int) bool { - out.LocalMediaCount++ - if refs := episodeRefsFromTitle(path); len(refs) > 0 { - for _, ref := range refs { - out.ExistingEpisodeKeys[episodeKey(ref.Season, ref.Episode)] = struct{}{} - } - } else if episode > 0 { - out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{} - } - return true - }) - } - s.addDownloadTaskAvailability(ctx, sub, queries, &out) - s.addLiveTorrentAvailability(ctx, queries, &out) - return s.finalizePendingAvailability(sub, out) -} - -func (s *SubscriptionService) EnrichProgress(ctx context.Context, items []model.Subscription) { - for i := range items { - availability := mergeLocalAvailability( - SubscriptionLocalAvailability(ctx, s.repo, &items[i]), - s.pendingDownloadAvailability(ctx, &items[i]), - ) - applySubscriptionAvailability(&items[i], availability) - } -} - -func (s *SubscriptionService) EnrichManagementProgress(ctx context.Context, items []model.Subscription) { - rows := s.downloadTaskRowsForAvailability(ctx) - for i := range items { - availability := mergeLocalAvailability( - SubscriptionLocalAvailability(ctx, s.repo, &items[i]), - s.pendingDownloadTaskAvailability(ctx, &items[i], rows, false), - ) - applySubscriptionAvailability(&items[i], availability) - } -} - -func applySubscriptionAvailability(sub *model.Subscription, availability LocalAvailability) { - if sub == nil { - return - } - sub.DownloadedEpisodes = availability.DownloadedEpisodes - sub.LocalMediaCount = availability.LocalMediaCount - sub.MissingEpisodes = availability.MissingEpisodes - sub.InLibrary = availability.InLibrary - if sub.TotalEpisodes == 0 { - sub.TotalEpisodes = availability.TotalEpisodes - } -} - -func (s *SubscriptionService) downloadTaskRowsForAvailability(ctx context.Context) []model.DownloadTask { - if s == nil || s.repo == nil || s.repo.Download == nil { - return nil - } - rows, err := s.repo.Download.List(ctx) - if err != nil { - return nil - } - return rows -} - -func (s *SubscriptionService) addDownloadTaskAvailability(ctx context.Context, sub *model.Subscription, queries []string, out *LocalAvailability) { - rows := s.downloadTaskRowsForAvailability(ctx) - s.addDownloadTaskRowsAvailability(ctx, sub, queries, rows, true, out) -} - -func (s *SubscriptionService) pendingDownloadTaskAvailability(ctx context.Context, sub *model.Subscription, rows []model.DownloadTask, verifyLive bool) LocalAvailability { - out := LocalAvailability{ - ExistingEpisodeKeys: map[string]struct{}{}, - MissingEpisodeKeys: map[string]struct{}{}, - } - if sub != nil { - out.TotalEpisodes = sub.TotalEpisodes - } - queries := subscriptionAvailabilityQueries(sub) - if len(queries) == 0 { - return s.finalizePendingAvailability(sub, out) - } - s.addDownloadTaskRowsAvailability(ctx, sub, queries, rows, verifyLive, &out) - return s.finalizePendingAvailability(sub, out) -} - -func (s *SubscriptionService) addDownloadTaskRowsAvailability(ctx context.Context, sub *model.Subscription, queries []string, rows []model.DownloadTask, verifyLive bool, out *LocalAvailability) { - if out == nil { - return - } - baseSavePath := s.subscriptionBaseSavePath(ctx, sub) - for _, row := range rows { - if !downloadTaskBlocksReadd(row.Status) { - continue - } - if verifyLive && !s.downloadTaskCountsAsPending(ctx, row) { - continue - } - linkedToSubscription := sub != nil && strings.TrimSpace(row.SubscriptionID) != "" && row.SubscriptionID == sub.ID - if !linkedToSubscription && baseSavePath != "" && row.SavePath != "" && !sameOrChildPath(row.SavePath, baseSavePath) && !sameOrChildPath(baseSavePath, row.SavePath) { - continue - } - if linkedToSubscription { - addTrustedAvailabilityTitle(row.Title, 0, 0, false, out) - continue - } - addAvailabilityTitleAny(row.Title, queries, out) - } -} - -func (s *SubscriptionService) downloadTaskCountsAsPending(ctx context.Context, row model.DownloadTask) bool { - if s == nil || s.downloads == nil { - return true - } - return s.downloads.subscriptionDownloadTaskStillLive(ctx, row) -} - -func (s *SubscriptionService) addLiveTorrentAvailability(ctx context.Context, queries []string, out *LocalAvailability) { - if s == nil || s.downloads == nil || out == nil { - return - } - live, err := s.downloads.listLiveTorrents(ctx, "") - if err != nil && len(live) == 0 { - return - } - for _, torrent := range live { - addAvailabilityTitleAny(torrent.Name, queries, out) - } -} - -func (s *SubscriptionService) finalizePendingAvailability(sub *model.Subscription, out LocalAvailability) LocalAvailability { - mediaType := "" - if sub != nil { - mediaType = sub.MediaType - } - if isSubscriptionSeriesType(mediaType) || len(out.ExistingEpisodeKeys) > 0 { - out.DownloadedEpisodes = len(out.ExistingEpisodeKeys) - out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes) - for _, episode := range out.MissingEpisodes { - out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{} - } - } else if out.LocalMediaCount > 0 { - out.DownloadedEpisodes = 1 - if out.TotalEpisodes == 0 { - out.TotalEpisodes = 1 - } - } - return out -} - -func (s *SubscriptionService) subscriptionBaseSavePath(ctx context.Context, sub *model.Subscription) string { - if sub == nil { - return "" - } - base := strings.TrimSpace(sub.SavePath) - if base == "" && s != nil && s.repo != nil && s.repo.Setting != nil { - base, _ = s.repo.Setting.Get(ctx, "qbittorrent.savepath") - } - return base -} - -func subscriptionName(sub *model.Subscription) string { - if sub == nil { - return "" - } - return sub.Name -} - -func subscriptionFilter(sub *model.Subscription) string { - if sub == nil { - return "" - } - return sub.Filter -} - -func subscriptionAvailabilityQueries(sub *model.Subscription) []string { - if sub == nil { - return nil - } - values := []string{availabilityQuery(subscriptionName(sub), subscriptionFilter(sub))} - for _, keyword := range siteSearchKeywords(sub) { - values = append(values, cleanAvailabilityTitle(keyword)) - } - if original := cleanAvailabilityTitle(sub.OriginalName); original != "" { - values = append(values, original) - } - return compactUniqueStrings(values...) -} - -func subscriptionMediaType(sub *model.Subscription) string { - if sub == nil { - return "" - } - return sub.MediaType -} - -func (s *SubscriptionService) downloadPathHasCandidate(ctx context.Context, sub *model.Subscription, title, savePath string) bool { - savePath = strings.TrimSpace(savePath) - if savePath == "" { - savePath = s.subscriptionBaseSavePath(ctx, sub) - } - query := availabilityQuery(title, subscriptionFilter(sub)) - if savePath == "" || query == "" { - return false - } - wanted := episodeRefsFromTitle(title) - if len(wanted) == 0 { - wantSeason, wantEpisode := ParseEpisode(title) - if wantEpisode > 0 { - wanted = []episodeRef{{Season: wantSeason, Episode: wantEpisode}} - } - } - found := false - foundEpisodes := map[string]struct{}{} - _ = scanDownloadPath(ctx, savePath, query, func(path string, season, episode int) bool { - if len(wanted) == 0 { - found = true - return false - } - if episode <= 0 { - return true - } - if season <= 0 { - season = 1 - } - if refs := episodeRefsFromTitle(path); len(refs) > 0 { - for _, ref := range refs { - foundEpisodes[episodeKey(ref.Season, ref.Episode)] = struct{}{} - } - } else { - foundEpisodes[episodeKey(season, episode)] = struct{}{} - } - for _, ref := range wanted { - if _, ok := foundEpisodes[episodeKey(ref.Season, ref.Episode)]; !ok { - return true - } - } - found = true - return false - }) - return found -} diff --git a/internal/service/subscription_availability_scan.go b/internal/service/subscription_availability_scan.go deleted file mode 100644 index a4b910a..0000000 --- a/internal/service/subscription_availability_scan.go +++ /dev/null @@ -1,93 +0,0 @@ -package service - -import ( - "context" - "os" - "path/filepath" - "strings" - "unicode" -) - -func sameOrChildPath(pathValue, root string) bool { - pathValue = filepath.Clean(strings.TrimSpace(pathValue)) - root = filepath.Clean(strings.TrimSpace(root)) - if pathValue == "" || root == "" || pathValue == "." || root == "." { - return false - } - if strings.EqualFold(pathValue, root) { - return true - } - rel, err := filepath.Rel(root, pathValue) - if err != nil { - return false - } - return rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) -} - -func scanDownloadPath(ctx context.Context, root, query string, visit func(path string, season, episode int) bool) error { - return scanDownloadPathAny(ctx, root, []string{query}, visit) -} - -func scanDownloadPathAny(ctx context.Context, root string, queries []string, visit func(path string, season, episode int) bool) error { - if strings.TrimSpace(root) == "" { - return nil - } - info, err := os.Stat(root) - if err != nil || !info.IsDir() { - return nil - } - if len(queries) == 0 { - return nil - } - visited := 0 - return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { - if err != nil { - return nil - } - if ctx.Err() != nil { - return ctx.Err() - } - if d.IsDir() { - if path != root && strings.HasPrefix(filepath.Base(path), ".") { - return filepath.SkipDir - } - return nil - } - if !isDownloadMediaPath(path) { - return nil - } - visited++ - if visited > 10000 { - return filepath.SkipAll - } - if !availabilityTitleMatchesAny(path, queries) { - return nil - } - season, episode := ParseEpisode(path) - if !visit(path, season, episode) { - return filepath.SkipAll - } - return nil - }) -} - -func isDownloadMediaPath(path string) bool { - ext := strings.ToLower(filepath.Ext(path)) - switch ext { - case ".!qb", ".part", ".aria2", ".crdownload": - path = strings.TrimSuffix(path, filepath.Ext(path)) - ext = strings.ToLower(filepath.Ext(path)) - } - _, ok := videoExtensions[ext] - return ok -} - -func normalizeAvailabilityComparable(value string) string { - var b strings.Builder - for _, r := range strings.ToLower(value) { - if unicode.IsLetter(r) || unicode.IsDigit(r) { - b.WriteRune(r) - } - } - return b.String() -} diff --git a/internal/service/subscription_availability_test.go b/internal/service/subscription_availability_test.go deleted file mode 100644 index 5591b3a..0000000 --- a/internal/service/subscription_availability_test.go +++ /dev/null @@ -1,336 +0,0 @@ -package service - -import ( - "context" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSubscriptionEnrichProgressIncludesPendingDownloads(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Media{}) - repos := repository.New(db) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:4444444444444444444444444444444444444444", - Title: "Inception 2010 1080p", - SavePath: "/downloads/movies", - Status: "completed", - Progress: 1, - }); err != nil { - t.Fatal(err) - } - svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil) - items := []model.Subscription{{ - Name: "Inception 2010", - Filter: "Inception 2010", - MediaType: "movie", - SavePath: "/downloads/movies", - }} - - svc.EnrichProgress(t.Context(), items) - if items[0].InLibrary { - t.Fatal("pending download should not be reported as in-library media") - } - if items[0].DownloadedEpisodes != 1 || items[0].LocalMediaCount != 1 || items[0].TotalEpisodes != 1 { - t.Fatalf("unexpected enriched progress: %+v", items[0]) - } -} - -func TestSubscriptionEnrichManagementProgressSkipsLiveQB(t *testing.T) { - var qbCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&qbCalls, 1) - http.Error(w, "management enrichment should not call qb", http.StatusInternalServerError) - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Media{}) - repos := repository.New(db) - sub := model.Subscription{ - Base: model.Base{ID: "sub-spy-family"}, - Name: "间谍过家家 自动订阅", - Filter: "间谍过家家", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 2, - } - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - SubscriptionID: sub.ID, - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:5555555555555555555555555555555555555555", - Title: "间谍过家家 S01E01 1080p", - SavePath: "/downloads/tv", - Status: "downloading", - Progress: 0.4, - }); err != nil { - t.Fatal(err) - } - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - downloads.qb.Configure(QBitConfig{BaseURL: qb.URL, Username: "admin", Password: "admin"}) - svc := NewSubscriptionService(nil, nil, repos, downloads, nil, nil) - items := []model.Subscription{sub} - - svc.EnrichManagementProgress(t.Context(), items) - if got := atomic.LoadInt32(&qbCalls); got != 0 { - t.Fatalf("management progress called qb %d times, want 0", got) - } - if items[0].DownloadedEpisodes != 1 || items[0].LocalMediaCount != 1 { - t.Fatalf("unexpected management progress: %+v", items[0]) - } -} - -func TestSubscriptionPollIntervalDefaultsAndClampsMinimum(t *testing.T) { - if subscriptionStartupDelay != defaultSubscriptionPollInterval { - t.Fatalf("startup delay = %v, want default poll interval %v", subscriptionStartupDelay, defaultSubscriptionPollInterval) - } - - db := newServiceTestDB(t, &model.Setting{}) - repos := repository.New(db) - svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil) - if got := svc.pollInterval(t.Context()); got != defaultSubscriptionPollInterval { - t.Fatalf("default poll interval = %v, want %v", got, defaultSubscriptionPollInterval) - } - - if err := repos.Setting.Set(t.Context(), "subscription.interval_seconds", "1800"); err != nil { - t.Fatal(err) - } - if got := svc.pollInterval(t.Context()); got != minSubscriptionPollInterval { - t.Fatalf("clamped poll interval = %v, want %v", got, minSubscriptionPollInterval) - } - - if err := repos.Setting.Set(t.Context(), "subscription.interval_seconds", "14400"); err != nil { - t.Fatal(err) - } - if got := svc.pollInterval(t.Context()); got != 4*time.Hour { - t.Fatalf("configured poll interval = %v, want 4h", got) - } -} - -func TestSubscriptionServiceStartIsSingleLoopAndRestartable(t *testing.T) { - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - svc := NewSubscriptionService(nil, zap.NewNop(), nil, nil, nil, nil) - - svc.Start(ctx) - firstStop := subscriptionStopChannel(svc) - if firstStop == nil { - t.Fatal("first Start did not create a stop channel") - } - svc.Start(ctx) - if got := subscriptionStopChannel(svc); got != firstStop { - t.Fatal("second Start should reuse the running loop instead of starting another") - } - - svc.Stop() - svc.Stop() - svc.Start(ctx) - secondStop := subscriptionStopChannel(svc) - if secondStop == nil { - t.Fatal("restart did not create a stop channel") - } - if secondStop == firstStop { - t.Fatal("restart should create a fresh loop after Stop") - } - svc.Stop() -} - -func subscriptionStopChannel(svc *SubscriptionService) chan struct{} { - svc.mu.Lock() - defer svc.mu.Unlock() - return svc.stop -} - -func TestMergeLocalAvailabilityKeepsLargerSeriesTotal(t *testing.T) { - existing := map[string]struct{}{} - for episode := 1; episode <= 6; episode++ { - existing[episodeKey(1, episode)] = struct{}{} - } - - got := mergeLocalAvailability( - LocalAvailability{TotalEpisodes: 1, LocalMediaCount: 1}, - LocalAvailability{TotalEpisodes: 33, LocalMediaCount: 6, ExistingEpisodeKeys: existing}, - ) - if got.TotalEpisodes != 33 { - t.Fatalf("TotalEpisodes = %d, want 33", got.TotalEpisodes) - } - if got.DownloadedEpisodes != 6 { - t.Fatalf("DownloadedEpisodes = %d, want 6", got.DownloadedEpisodes) - } - if len(got.MissingEpisodes) != 27 { - t.Fatalf("missing episodes = %d, want 27", len(got.MissingEpisodes)) - } -} - -func TestSubscriptionLocalAvailabilityMatchesMediaPath(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - if err := db.Create(&model.Media{ - Title: "Scraped English Title", - Path: "/media/电视剧/国产剧/凡人修仙传/Season 01/凡人修仙传 - S01E146.mkv", - SeasonNum: 1, - EpisodeNum: 146, - }).Error; err != nil { - t.Fatal(err) - } - sub := &model.Subscription{ - Name: "凡人修仙传 年番", - Filter: "凡人修仙传", - MediaType: "tv", - TotalEpisodes: 146, - } - - availability := SubscriptionLocalAvailability(t.Context(), repos, sub) - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 146)]; !ok { - t.Fatalf("missing path-matched E146 key: %#v", availability.ExistingEpisodeKeys) - } - results := []SearchResult{ - {Title: "凡人修仙传 年番 - 146 1080p", DownloadURL: "https://pt/download/146", Seeders: 80}, - } - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 0 { - t.Fatalf("selected %#v, want none because path-matched local episode exists", got) - } -} - -func TestSubscriptionPendingDownloadAvailabilityIgnoresDeletedTasks(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}) - repos := repository.New(db) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:3333333333333333333333333333333333333333", - Title: "间谍过家家 S01E02 1080p", - SavePath: "/downloads/tv", - Status: "deleted", - }); err != nil { - t.Fatal(err) - } - svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil) - sub := &model.Subscription{ - Name: "间谍过家家 自动订阅", - Filter: "间谍过家家", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 3, - } - - availability := svc.pendingDownloadAvailability(t.Context(), sub) - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 2)]; ok { - t.Fatalf("deleted E02 task should not count as available: %#v", availability.ExistingEpisodeKeys) - } - results := []SearchResult{ - {Title: "间谍过家家 S01E02 1080p WEB-DL", DownloadURL: "https://pt/download/2", Seeders: 80}, - {Title: "间谍过家家 S01E03 1080p WEB-DL", DownloadURL: "https://pt/download/3", Seeders: 70}, - } - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 2 || got[0].Episode != 2 || got[1].Episode != 3 { - t.Fatalf("selected %#v, want deleted episode 2 and new episode 3", got) - } -} - -func TestSubscriptionPendingDownloadAvailabilityIncludesLiveQBTorrents(t *testing.T) { - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[{"hash":"abc123","name":"间谍过家家 S01E01 1080p","state":"downloading","progress":0.2}]`)) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}) - repos := repository.New(db) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - downloads.qb.Configure(QBitConfig{BaseURL: qb.URL, Username: "admin", Password: "admin"}) - svc := NewSubscriptionService(nil, nil, repos, downloads, nil, nil) - sub := &model.Subscription{ - Name: "间谍过家家 自动订阅", - Filter: "间谍过家家", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 2, - } - - availability := svc.pendingDownloadAvailability(t.Context(), sub) - if availability.DownloadedEpisodes != 1 { - t.Fatalf("downloaded episodes = %d, want 1", availability.DownloadedEpisodes) - } - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 1)]; !ok { - t.Fatalf("missing live qB E01 key: %#v", availability.ExistingEpisodeKeys) - } -} - -func TestSiteSearchDownloadDedupMarksCandidateAvailable(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - sub := &model.Subscription{ - Base: model.Base{ID: "sub-nanyang"}, - UserID: "u1", - Name: "南部档案 自动订阅", - Filter: "南部档案", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 33, - } - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - SubscriptionID: sub.ID, - Source: "qbittorrent", - URL: "https://pt/download/existing", - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - SavePath: "/downloads/tv", - Status: "queued", - Progress: 0, - }); err != nil { - t.Fatal(err) - } - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - siteSvc := NewSiteService(zap.NewNop(), repos, "") - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, siteSvc, NewHub(zap.NewNop())) - state := &siteSearchRunState{ - Keyword: "南部档案", - SeenSet: map[string]struct{}{}, - Availability: LocalAvailability{ - TotalEpisodes: 33, - ExistingEpisodeKeys: map[string]struct{}{}, - MissingEpisodeKeys: map[string]struct{}{}, - }, - } - - title, err := svc.enqueueSiteSearchCandidate(t.Context(), sub, siteSearchCandidate{ - Item: SearchResult{ - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - DownloadURL: "https://pt/download/existing", - }, - Download: "https://pt/download/existing", - GUID: "site|mteam|nanyang-7-8", - Season: 1, - Episode: 7, - Episodes: []int{7, 8}, - Pack: true, - Score: 80, - }, state) - if err != nil { - t.Fatalf("enqueueSiteSearchCandidate returned %v, want dedup skipped without error", err) - } - if title != "" { - t.Fatalf("queued title = %q, want empty on dedup", title) - } - for _, episode := range []int{7, 8} { - if _, ok := state.Availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok { - t.Fatalf("deduped candidate should mark E%d available: %#v", episode, state.Availability.ExistingEpisodeKeys) - } - } - if len(state.Seen) != 0 { - t.Fatalf("deduped candidate should stay retryable instead of being marked seen: %#v", state.Seen) - } -} diff --git a/internal/service/subscription_availability_titles.go b/internal/service/subscription_availability_titles.go deleted file mode 100644 index 60d495b..0000000 --- a/internal/service/subscription_availability_titles.go +++ /dev/null @@ -1,182 +0,0 @@ -package service - -import ( - "context" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func addAvailabilityTitle(title, query string, out *LocalAvailability) { - if out == nil || strings.TrimSpace(title) == "" || strings.TrimSpace(query) == "" { - return - } - if !availabilityTitleMatchesAny(title, []string{query}) { - return - } - out.LocalMediaCount++ - if refs := episodeRefsFromTitle(title); len(refs) > 0 { - if out.ExistingEpisodeKeys == nil { - out.ExistingEpisodeKeys = map[string]struct{}{} - } - for _, ref := range refs { - out.ExistingEpisodeKeys[episodeKey(ref.Season, ref.Episode)] = struct{}{} - } - return - } - if isSeriesPackTitle(title) { - out.HasSeriesPack = true - } -} - -func addAvailabilityTitleAny(title string, queries []string, out *LocalAvailability) { - if !availabilityTitleMatchesAny(title, queries) { - return - } - addTrustedAvailabilityTitle(title, 0, 0, false, out) -} - -func availabilityTitleMatchesAny(title string, queries []string) bool { - titleKey := normalizeAvailabilityComparable(title) - if titleKey == "" { - return false - } - for _, query := range queries { - queryKey := normalizeAvailabilityComparable(query) - if queryKey == "" { - continue - } - if strings.Contains(titleKey, queryKey) { - return true - } - } - return false -} - -func addSiteSearchCandidateAvailability(candidate siteSearchCandidate, out *LocalAvailability) { - addTrustedAvailabilityTitle(subscriptionSearchResultText(candidate.Item), candidate.Season, candidate.Episode, candidate.Pack, out) -} - -func (s *SubscriptionService) subscriptionCandidateConfirmedAvailable(ctx context.Context, sub *model.Subscription, candidate siteSearchCandidate) bool { - availability := mergeLocalAvailability( - SubscriptionLocalAvailability(ctx, s.repo, sub), - s.pendingDownloadAvailability(ctx, sub), - ) - return candidateAvailableInAvailability(sub, candidate, availability) -} - -func candidateAvailableInAvailability(sub *model.Subscription, candidate siteSearchCandidate, availability LocalAvailability) bool { - mediaType := normalizeMediaType(subscriptionMediaType(sub), subscriptionName(sub)+" "+subscriptionFilter(sub), "") - if !isSubscriptionSeriesType(mediaType) { - return availability.LocalMediaCount > 0 || availability.InLibrary - } - episodes := candidateEpisodeNumbers(candidate) - if len(episodes) == 0 { - return availability.HasSeriesPack - } - season := candidate.Season - if season <= 0 { - season = 1 - } - for _, episode := range episodes { - if _, ok := availability.ExistingEpisodeKeys[episodeKey(season, episode)]; !ok { - return false - } - } - return true -} - -func addTrustedAvailabilityTitle(title string, season, episode int, pack bool, out *LocalAvailability) { - if out == nil { - return - } - if strings.TrimSpace(title) == "" && episode <= 0 && !pack { - return - } - out.LocalMediaCount++ - refs := episodeRefsFromTitle(title) - if len(refs) == 0 && episode > 0 { - if season <= 0 { - season = 1 - } - refs = []episodeRef{{Season: season, Episode: episode}} - } - if len(refs) > 0 { - if out.ExistingEpisodeKeys == nil { - out.ExistingEpisodeKeys = map[string]struct{}{} - } - for _, ref := range refs { - out.ExistingEpisodeKeys[episodeKey(ref.Season, ref.Episode)] = struct{}{} - } - return - } - if episode <= 0 { - season, episode = ParseEpisode(title) - } - if episode > 0 { - if out.ExistingEpisodeKeys == nil { - out.ExistingEpisodeKeys = map[string]struct{}{} - } - out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{} - return - } - if pack || isSeriesPackTitle(title) { - out.HasSeriesPack = true - } -} - -func mergeLocalAvailability(values ...LocalAvailability) LocalAvailability { - out := LocalAvailability{ - ExistingEpisodeKeys: map[string]struct{}{}, - MissingEpisodeKeys: map[string]struct{}{}, - } - for _, value := range values { - if value.TotalEpisodes > out.TotalEpisodes { - out.TotalEpisodes = value.TotalEpisodes - } - out.LocalMediaCount += value.LocalMediaCount - out.InLibrary = out.InLibrary || value.InLibrary - out.HasSeriesPack = out.HasSeriesPack || value.HasSeriesPack - for key := range value.ExistingEpisodeKeys { - out.ExistingEpisodeKeys[key] = struct{}{} - } - } - out.DownloadedEpisodes = len(out.ExistingEpisodeKeys) - if out.TotalEpisodes > 0 { - out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes) - for _, episode := range out.MissingEpisodes { - out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{} - } - } - if out.DownloadedEpisodes == 0 && out.LocalMediaCount > 0 { - out.DownloadedEpisodes = out.LocalMediaCount - if out.TotalEpisodes == 0 { - out.TotalEpisodes = 1 - } - } - return out -} - -// subscriptionItemAlreadyAvailable 判断某个订阅条目(按其标题解析出的季/集)是否已在媒体库存在。 -// 电影/无集号条目:媒体库已有该片即视为已存在;剧集条目:对应季集已入库即视为已存在。 -func subscriptionItemAlreadyAvailable(sub *model.Subscription, avail LocalAvailability, title string) bool { - if avail.LocalMediaCount == 0 && !avail.HasSeriesPack { - return false - } - if !isSubscriptionSeriesType(subscriptionMediaType(sub)) { - return true - } - if avail.HasSeriesPack { - return true - } - wantSeason, wantEpisode := ParseEpisode(title) - if wantEpisode <= 0 { - // 整季合集 / 无法解析集号:库里已有内容时保守跳过,避免重复整季下载。 - return true - } - if wantSeason <= 0 { - wantSeason = 1 - } - _, exists := avail.ExistingEpisodeKeys[episodeKey(wantSeason, wantEpisode)] - return exists -} diff --git a/internal/service/subscription_candidate_availability_test.go b/internal/service/subscription_candidate_availability_test.go deleted file mode 100644 index 33711d0..0000000 --- a/internal/service/subscription_candidate_availability_test.go +++ /dev/null @@ -1,340 +0,0 @@ -package service - -import ( - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestSelectSiteSearchCandidatesOnlyQueuesMissingLocalEpisodes(t *testing.T) { - sub := &model.Subscription{Name: "间谍过家家 自动订阅", Filter: "间谍过家家", MediaType: "tv", TotalEpisodes: 3} - results := []SearchResult{ - {Title: "间谍过家家 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 100}, - {Title: "间谍过家家 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 90}, - {Title: "间谍过家家 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 80}, - {Title: "间谍过家家 S01E03 1080p", DownloadURL: "https://pt/download/3", Seeders: 70}, - } - availability := LocalAvailability{ - TotalEpisodes: 3, - LocalMediaCount: 2, - MissingEpisodes: []int{3}, - ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}, episodeKey(1, 2): {}}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Episode != 3 { - t.Fatalf("selected %#v, want only missing episode 3", got) - } -} - -func TestSelectSiteSearchCandidatesWithUnknownTotalSkipsExistingEpisodes(t *testing.T) { - sub := &model.Subscription{Name: "葬送的芙莉莲 自动订阅", Filter: "葬送的芙莉莲", MediaType: "anime"} - results := []SearchResult{ - {Title: "葬送的芙莉莲 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 100}, - {Title: "葬送的芙莉莲 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 90}, - {Title: "葬送的芙莉莲 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 80}, - {Title: "葬送的芙莉莲 S01E03 1080p", DownloadURL: "https://pt/download/3", Seeders: 70}, - } - availability := LocalAvailability{ - LocalMediaCount: 2, - ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}, episodeKey(1, 2): {}}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Episode != 3 { - t.Fatalf("selected %#v, want only not-yet-local episode 3", got) - } -} - -func TestSelectSiteSearchCandidatesSingleExistingEpisodeIsSkipped(t *testing.T) { - sub := &model.Subscription{Name: "葬送的芙莉莲 自动订阅", Filter: "葬送的芙莉莲", MediaType: "anime", TotalEpisodes: 3} - results := []SearchResult{ - {Title: "葬送的芙莉莲 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 90}, - } - availability := LocalAvailability{ - TotalEpisodes: 3, - LocalMediaCount: 1, - MissingEpisodes: []int{2, 3}, - ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 0 { - t.Fatalf("selected %#v, want none because E01 already exists", got) - } -} - -func TestSelectSiteSearchCandidatesFullPackUsedAsFallbackWhenLibraryPartiallyExists(t *testing.T) { - // 本地缺第 3 集,站点只有整季全集包(无单集种)。剧集完结后站点常只挂全集包, - // 此时必须用全集包兜底补缺集,否则"补全缺失集"永远匹配为空(用户报告的 bug)。 - sub := &model.Subscription{Name: "间谍过家家 自动订阅", Filter: "间谍过家家", MediaType: "tv", TotalEpisodes: 3} - results := []SearchResult{ - {Title: "间谍过家家 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 100}, - } - availability := LocalAvailability{ - TotalEpisodes: 3, - LocalMediaCount: 2, - MissingEpisodes: []int{3}, - ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}, episodeKey(1, 2): {}}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 { - t.Fatalf("selected %#v, want the full pack as fallback to cover missing episode 3", got) - } - if got[0].Download != "https://pt/download/pack" { - t.Fatalf("selected %#v, want the Complete pack", got) - } -} - -func TestSelectSiteSearchCandidatesPartialSeriesPackDoesNotSatisfySubscription(t *testing.T) { - sub := &model.Subscription{Name: "问心2 自动订阅", Filter: "问心2", MediaType: "tv", TotalEpisodes: 33} - results := []SearchResult{ - {Title: "问心2 S01E07 2160p WEB-DL", DownloadURL: "https://pt/download/7", Seeders: 100}, - } - availability := LocalAvailability{ - TotalEpisodes: 33, - LocalMediaCount: 7, - HasSeriesPack: true, - MissingEpisodes: []int{7}, - ExistingEpisodeKeys: map[string]struct{}{}, - } - for episode := 1; episode <= 6; episode++ { - availability.ExistingEpisodeKeys[episodeKey(1, episode)] = struct{}{} - } - - got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Episode != 7 { - t.Fatalf("selected %#v, want missing episode 7 despite local pack marker", got) - } - if stats.LocalSeriesPackPresent { - t.Fatalf("LocalSeriesPackPresent = true, want false for partial series availability") - } -} - -func TestSelectSiteSearchCandidatesIgnoresUnderestimatedLocalTotal(t *testing.T) { - sub := &model.Subscription{Name: "南部档案 自动订阅", Filter: "南部档案", MediaType: "tv"} - results := []SearchResult{ - {Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/29-33", Seeders: 100}, - } - existing := map[string]struct{}{} - for episode := 1; episode <= 6; episode++ { - existing[episodeKey(1, episode)] = struct{}{} - } - availability := LocalAvailability{ - TotalEpisodes: 1, - LocalMediaCount: 7, - HasSeriesPack: true, - ExistingEpisodeKeys: existing, - } - - got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Download != "https://pt/download/29-33" { - t.Fatalf("selected %#v, want high-episode candidate despite underestimated local total", got) - } - if stats.SeriesComplete || stats.NotMissingEpisodeSkipped != 0 { - t.Fatalf("stats = %#v, underestimated total must not mark series complete or skip high episodes", stats) - } -} - -func TestSelectSiteSearchCandidatesRangeCanCoverMissingEpisodesAfterExistingStart(t *testing.T) { - sub := &model.Subscription{Name: "南部档案 自动订阅", Filter: "南部档案", MediaType: "tv", TotalEpisodes: 33} - results := []SearchResult{ - {Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/29-33", Seeders: 100}, - } - existing := map[string]struct{}{episodeKey(1, 29): {}} - availability := LocalAvailability{ - TotalEpisodes: 33, - LocalMediaCount: 1, - MissingEpisodes: []int{30, 31, 32, 33}, - ExistingEpisodeKeys: existing, - } - - got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Download != "https://pt/download/29-33" { - t.Fatalf("selected %#v, want range candidate because it covers E30-E33", got) - } - if stats.ExistingEpisodeSkipped != 0 || stats.NotMissingEpisodeSkipped != 0 { - t.Fatalf("stats = %#v, range covering missing episodes must not be skipped", stats) - } -} - -func TestSelectSiteSearchCandidatesMissingEpisodeCanMatchSubtitleAlias(t *testing.T) { - sub := &model.Subscription{Name: "躲在超市后门抽烟的两人 自动订阅", Filter: "躲在超市后门抽烟的两人", MediaType: "tv", TotalEpisodes: 12} - results := []SearchResult{ - {Title: "Smoking Behind the Supermarket with You S01E01 1080p", Subtitle: "躲在超市后门抽烟的两人", DownloadURL: "https://pt/download/1", Seeders: 100}, - {Title: "Smoking Behind the Supermarket with You S01E12 1080p", Subtitle: "躲在超市后门抽烟的两人", DownloadURL: "https://pt/download/12", Seeders: 80}, - } - existing := map[string]struct{}{} - for episode := 1; episode <= 11; episode++ { - existing[episodeKey(1, episode)] = struct{}{} - } - availability := LocalAvailability{ - TotalEpisodes: 12, - LocalMediaCount: 11, - MissingEpisodes: []int{12}, - ExistingEpisodeKeys: existing, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Episode != 12 || got[0].Download != "https://pt/download/12" { - t.Fatalf("selected %#v, want subtitle-matched missing episode 12", got) - } -} - -func TestSelectSiteSearchCandidatesRelaxesQueryForExistingSeriesMissingEpisodes(t *testing.T) { - sub := &model.Subscription{Name: "翘楚 S01E06 自动订阅", Filter: "翘楚 S01E06", MediaType: "tv", TotalEpisodes: 24} - results := []SearchResult{ - {Title: "Qiao Chu 2026 S01E06 2160p WEB-DL", DownloadURL: "https://pt/download/6", Seeders: 10}, - {Title: "Ashes to Crown 2026 S01E21 2160p WEB-DL", DownloadURL: "https://pt/download/21", Seeders: 8}, - {Title: "Ashes to Crown 2026 S01E99 2160p WEB-DL", DownloadURL: "https://pt/download/99", Seeders: 99}, - } - availability := LocalAvailability{ - TotalEpisodes: 24, - LocalMediaCount: 1, - MissingEpisodes: []int{21}, - ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 6): {}}, - } - - got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Episode != 21 || got[0].Download != "https://pt/download/21" { - t.Fatalf("selected %#v, want relaxed alias-like missing episode 21 only", got) - } - if stats.QueryMismatch != 3 || stats.RelaxedQueryMatch != 3 || stats.ExistingEpisodeSkipped != 1 || stats.NotMissingEpisodeSkipped != 1 { - t.Fatalf("unexpected relaxed stats: %#v", stats) - } -} - -func TestAddSiteSearchCandidateAvailabilityTracksRelaxedAliasCandidate(t *testing.T) { - sub := &model.Subscription{Name: "翘楚 S01E06 自动订阅", Filter: "翘楚 S01E06", MediaType: "tv", TotalEpisodes: 24} - availability := LocalAvailability{ - TotalEpisodes: 24, - LocalMediaCount: 1, - MissingEpisodes: []int{21}, - ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 6): {}}, - MissingEpisodeKeys: map[string]struct{}{episodeKey(1, 21): {}}, - } - candidate := siteSearchCandidate{ - Item: SearchResult{ - Title: "Ashes to Crown 2026 S01E21 2160p WEB-DL", - DownloadURL: "https://pt/download/21", - }, - Download: "https://pt/download/21", - GUID: "site|m-team|ashes-to-crown-21", - Season: 1, - Episode: 21, - } - - addSiteSearchCandidateAvailability(candidate, &availability) - availability = NewSubscriptionService(nil, nil, nil, nil, nil, nil).finalizePendingAvailability(sub, availability) - - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 21)]; !ok { - t.Fatalf("missing relaxed alias candidate E21 key: %#v", availability.ExistingEpisodeKeys) - } - got := selectSiteSearchCandidates([]SearchResult{candidate.Item}, sub, map[string]struct{}{}, availability) - if len(got) != 0 { - t.Fatalf("selected %#v, want relaxed alias candidate skipped after dedup availability update", got) - } -} - -func TestAddSiteSearchCandidateAvailabilityTracksEpisodeRange(t *testing.T) { - availability := LocalAvailability{ - TotalEpisodes: 33, - ExistingEpisodeKeys: map[string]struct{}{}, - MissingEpisodeKeys: map[string]struct{}{}, - } - candidate := siteSearchCandidate{ - Item: SearchResult{ - Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", - DownloadURL: "https://pt/download/29-33", - }, - Download: "https://pt/download/29-33", - GUID: "site|m-team|nanyang-29-33", - Season: 1, - Episode: 29, - Episodes: []int{29, 30, 31, 32, 33}, - Pack: true, - } - - addSiteSearchCandidateAvailability(candidate, &availability) - for episode := 29; episode <= 33; episode++ { - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok { - t.Fatalf("availability missing E%d after range mark: %#v", episode, availability.ExistingEpisodeKeys) - } - } -} - -func TestCandidateAvailableInAvailabilityRequiresFullRangeCoverage(t *testing.T) { - sub := &model.Subscription{Name: "南部档案 自动订阅", Filter: "南部档案", MediaType: "tv", TotalEpisodes: 33} - candidate := siteSearchCandidate{ - Item: SearchResult{ - Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", - DownloadURL: "https://pt/download/29-33", - }, - Season: 1, - Episode: 29, - Episodes: []int{29, 30, 31, 32, 33}, - Pack: true, - } - availability := LocalAvailability{ - TotalEpisodes: 33, - ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 29): {}, episodeKey(1, 30): {}}, - } - - if candidateAvailableInAvailability(sub, candidate, availability) { - t.Fatal("partial range availability must not confirm a deduped subscription candidate") - } - for episode := 31; episode <= 33; episode++ { - availability.ExistingEpisodeKeys[episodeKey(1, episode)] = struct{}{} - } - if !candidateAvailableInAvailability(sub, candidate, availability) { - t.Fatal("complete range availability should confirm a deduped subscription candidate") - } -} - -func TestShouldSkipExistingTorrentKeepsSeriesRangeCandidate(t *testing.T) { - svc := &SubscriptionService{downloads: &DownloadService{}} - candidate := siteSearchCandidate{ - Item: SearchResult{ - Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", - DownloadURL: "https://pt/download/29-33", - }, - Season: 1, - Episode: 29, - Episodes: []int{29, 30, 31, 32, 33}, - Pack: true, - } - - if svc.shouldSkipExistingTorrent(t.Context(), "tv", candidate) { - t.Fatal("series range candidate should not be skipped by global torrent-name precheck") - } -} - -func TestSelectSiteSearchCandidatesDoesNotRelaxQueryForMovies(t *testing.T) { - sub := &model.Subscription{Name: "玩具总动员 5 自动订阅", Filter: "玩具总动员 5 2026", MediaType: "movie"} - results := []SearchResult{ - {Title: "Toy Story 4 2019 2160p WEB-DL", DownloadURL: "https://pt/download/wrong", Seeders: 100}, - } - availability := LocalAvailability{} - - got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) - if len(got) != 0 { - t.Fatalf("selected %#v, want no relaxed movie match", got) - } - if stats.QueryMismatch != 1 || stats.RelaxedQueryMatch != 0 { - t.Fatalf("unexpected stats: %#v", stats) - } -} - -func TestSelectSiteSearchCandidatesSingleExistingMovieIsSkippedWhenNotWashing(t *testing.T) { - sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie"} - results := []SearchResult{ - {Title: "Inception 2010 1080p WEB-DL", DownloadURL: "https://pt/download/web", Seeders: 90}, - } - availability := LocalAvailability{LocalMediaCount: 1, InLibrary: true, DownloadedEpisodes: 1, TotalEpisodes: 1} - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 0 { - t.Fatalf("selected %#v, want none because movie already exists and wash is disabled", got) - } -} diff --git a/internal/service/subscription_candidate_collect.go b/internal/service/subscription_candidate_collect.go deleted file mode 100644 index b7b4184..0000000 --- a/internal/service/subscription_candidate_collect.go +++ /dev/null @@ -1,237 +0,0 @@ -package service - -import ( - "regexp" - "strconv" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -var looseSubscriptionEpisodeRE = regexp.MustCompile(`(?i)(?:^|[\s._\-\[\(])0?(\d{1,3})(?:v\d+)?(?:$|[\s._\-\]\)])`) - -func collectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, allowQueryMismatch bool, stats *siteSearchSelectionStats) []siteSearchCandidate { - candidates := make([]siteSearchCandidate, 0, len(results)) - for _, item := range results { - matchText := subscriptionSearchResultText(item) - if !subscriptionSearchResultMatchesQuery(sub, item) { - if allowQueryMismatch { - stats.RelaxedQueryMatch++ - } else { - stats.QueryMismatch++ - stats.QueryMismatchExamples = appendLimitedStrings(stats.QueryMismatchExamples, matchText, 5) - continue - } - } - if !matchesSubscriptionRules(sub, matchText) || !matchesSubscriptionTorrentRules(sub, item) { - stats.RuleMismatch++ - continue - } - download := strings.TrimSpace(item.DownloadURL) - if download == "" { - download = strings.TrimSpace(item.TorrentURL) - } - if download == "" { - stats.MissingDownload++ - continue - } - guid := stableSiteSearchGUID(item, download) - if _, ok := seenSet[guid]; ok { - stats.Seen++ - continue - } - refs := subscriptionCandidateEpisodeRefs(sub, matchText) - season, episode := ParseEpisode(matchText) - if episode <= 0 && len(refs) > 0 { - season = refs[0].Season - episode = refs[0].Episode - } - episodes := episodeNumbersFromRefs(refs, season) - score := subscriptionCandidateScore(sub, item) - stats.Prepared++ - candidates = append(candidates, siteSearchCandidate{ - Item: item, - Download: download, - GUID: guid, - Season: season, - Episode: episode, - Episodes: episodes, - Pack: isSeriesPackTitle(item.Title) || len(episodes) > 1, - Score: score, - }) - } - return candidates -} - -func appendLimitedStrings(values []string, value string, limit int) []string { - value = strings.TrimSpace(value) - if value == "" || limit <= 0 || len(values) >= limit { - return values - } - return append(values, value) -} - -func shouldRelaxSiteSearchQueryMatch(sub *model.Subscription, local LocalAvailability) bool { - if sub == nil { - return false - } - mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "") - if !isSubscriptionSeriesType(mediaType) { - return false - } - if local.LocalMediaCount == 0 && len(local.ExistingEpisodeKeys) == 0 { - return false - } - return local.TotalEpisodes > 0 || len(local.MissingEpisodes) > 0 -} - -func subscriptionSearchResultText(item SearchResult) string { - return strings.TrimSpace(strings.Join([]string{item.Title, item.Subtitle, item.Labels}, " ")) -} - -func selectRSSSubscriptionCandidates(items []rssItem, sub *model.Subscription, filter *regexp.Regexp, seenSet map[string]struct{}, local LocalAvailability) []siteSearchCandidate { - if seenSet == nil { - seenSet = map[string]struct{}{} - } - candidates := make([]siteSearchCandidate, 0, len(items)) - for _, item := range items { - title := strings.TrimSpace(item.Title) - if title == "" { - continue - } - if filter != nil && !filter.MatchString(title) { - continue - } - searchItem := SearchResult{Title: title} - if !matchesSubscriptionRules(sub, title) || !matchesSubscriptionTorrentRules(sub, searchItem) { - continue - } - download := strings.TrimSpace(item.Enclosure.URL) - if download == "" { - download = strings.TrimSpace(item.Link) - } - if download == "" { - continue - } - guid := stableRSSItemGUID(title, item.GUID, item.Link, item.Enclosure.URL) - if _, ok := seenSet[guid]; ok { - continue - } - searchItem.DownloadURL = download - refs := subscriptionCandidateEpisodeRefs(sub, title) - season, episode := ParseEpisode(title) - if episode <= 0 && len(refs) > 0 { - season = refs[0].Season - episode = refs[0].Episode - } - episodes := episodeNumbersFromRefs(refs, season) - candidates = append(candidates, siteSearchCandidate{ - Item: searchItem, - Download: download, - GUID: guid, - Season: season, - Episode: episode, - Episodes: episodes, - Pack: isSeriesPackTitle(title) || len(episodes) > 1, - Score: subscriptionCandidateScore(sub, searchItem), - }) - } - return selectPreparedSubscriptionCandidates(candidates, sub, local) -} - -func subscriptionCandidateEpisodeRefs(sub *model.Subscription, text string) []episodeRef { - if refs := episodeRefsFromTitle(text); len(refs) > 0 { - return refs - } - if sub == nil || !isSubscriptionSeriesType(sub.MediaType) || isSeriesPackTitle(text) || patSeasonOnly.MatchString(text) { - return nil - } - episode := inferLooseSubscriptionEpisode(maskSubscriptionTitleQueries(sub, text)) - if episode <= 0 { - return nil - } - return []episodeRef{{Season: 1, Episode: episode}} -} - -func maskSubscriptionTitleQueries(sub *model.Subscription, text string) string { - if sub == nil || strings.TrimSpace(text) == "" { - return text - } - out := text - outFold := strings.ToLower(out) - for _, query := range subscriptionTitleMatchQueries(sub) { - query = strings.TrimSpace(query) - if query == "" { - continue - } - queryFold := strings.ToLower(query) - for { - idx := strings.Index(outFold, queryFold) - if idx < 0 { - break - } - out = out[:idx] + strings.Repeat(" ", len(query)) + out[idx+len(query):] - outFold = strings.ToLower(out) - } - } - return out -} - -func inferLooseSubscriptionEpisode(text string) int { - for _, match := range looseSubscriptionEpisodeRE.FindAllStringSubmatchIndex(text, -1) { - if len(match) < 4 || match[2] < 0 || match[3] < 0 { - continue - } - if isDecimalFractionMatch(text, match[2]) { - continue - } - value, err := strconv.Atoi(text[match[2]:match[3]]) - if err != nil || !looksLikeLooseEpisodeNumber(value) { - continue - } - return value - } - return 0 -} - -func isDecimalFractionMatch(text string, digitStart int) bool { - return digitStart >= 2 && text[digitStart-1] == '.' && text[digitStart-2] >= '0' && text[digitStart-2] <= '9' -} - -func looksLikeLooseEpisodeNumber(value int) bool { - switch { - case value <= 0, value > 200: - return false - default: - return true - } -} - -func episodeNumbersFromRefs(refs []episodeRef, fallbackSeason int) []int { - if len(refs) == 0 { - return nil - } - if fallbackSeason <= 0 { - fallbackSeason = refs[0].Season - } - out := make([]int, 0, len(refs)) - seen := map[int]struct{}{} - for _, ref := range refs { - season := ref.Season - if season <= 0 { - season = 1 - } - if fallbackSeason > 0 && season != fallbackSeason { - continue - } - if ref.Episode <= 0 { - continue - } - if _, ok := seen[ref.Episode]; ok { - continue - } - seen[ref.Episode] = struct{}{} - out = append(out, ref.Episode) - } - return out -} diff --git a/internal/service/subscription_candidate_defaults_test.go b/internal/service/subscription_candidate_defaults_test.go deleted file mode 100644 index 122b619..0000000 --- a/internal/service/subscription_candidate_defaults_test.go +++ /dev/null @@ -1,299 +0,0 @@ -package service - -import ( - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestSelectSiteSearchCandidatesRejectsKeywordOriginWithConflictingYear(t *testing.T) { - sub := &model.Subscription{ - Name: "玩具总动员 5 自动订阅", - Filter: "玩具总动员 5 2026", - MediaType: "movie", - Year: 2026, - } - results := []SearchResult{{ - Title: "Toy Story 4 2019 2160p DSNP WEB-DL", - DownloadURL: "https://pt/download/toy-story-4", - SearchKeyword: "玩具总动员 5", - Seeders: 90, - }} - - got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, LocalAvailability{}) - if len(got) != 0 { - t.Fatalf("selected %#v, want conflicting-year keyword-origin result rejected", got) - } - if stats.QueryMismatch != 1 || stats.Prepared != 0 { - t.Fatalf("stats = %#v, want query mismatch for conflicting year", stats) - } -} - -func TestSelectSiteSearchCandidatesDoesNotWashByDefault(t *testing.T) { - sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", WashPriority: "resolution"} - results := []SearchResult{ - {Title: "Inception 2010 1080p", DownloadURL: "https://pt/download/1080", Seeders: 90}, - {Title: "Inception 2010 2160p", DownloadURL: "https://pt/download/2160", Seeders: 80}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/2160" { - t.Fatalf("selected %#v, want default best single result when wash disabled", got) - } -} - -func TestSelectSiteSearchCandidatesWashNeedsExplicitUpgradeCriteria(t *testing.T) { - sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", WashEnabled: true, WashPriority: "resolution"} - local := LocalAvailability{LocalMediaCount: 1, InLibrary: true} - results := []SearchResult{ - {Title: "Inception 2010 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/1080", Seeders: 90}, - {Title: "Inception 2010 2160p WEB-DL H264 AAC", DownloadURL: "https://pt/download/2160", Seeders: 80}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, local) - if len(got) != 0 { - t.Fatalf("selected %#v, want no wash download without explicit upgrade criteria", got) - } - - sub.Resolution = "2160p" - got = selectSiteSearchCandidates(results, sub, map[string]struct{}{}, local) - if len(got) != 1 || got[0].Download != "https://pt/download/2160" { - t.Fatalf("selected %#v, want explicit 2160p wash candidate", got) - } -} - -func TestSelectSiteSearchCandidatesWashWithoutCriteriaUsesDefaultQuality(t *testing.T) { - sub := &model.Subscription{Name: "Dune 自动订阅", Filter: "Dune 2021", MediaType: "movie", WashEnabled: true, WashPriority: "quality"} - results := []SearchResult{ - {Title: "Dune 2021 2160p REMUX H264 AAC", DownloadURL: "https://pt/download/remux", Seeders: 80}, - {Title: "Dune 2021 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/webdl", Seeders: 60}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/webdl" { - t.Fatalf("selected %#v, want default compatible WEB-DL when wash has no explicit criteria", got) - } -} - -func TestSelectSiteSearchCandidatesDefaultsToOnePreferredVersionPerEpisode(t *testing.T) { - sub := &model.Subscription{Name: "House of the Dragon 自动订阅", Filter: "House of the Dragon", MediaType: "tv"} - results := []SearchResult{ - {Title: "House of the Dragon S03E01 1080p HDTV", DownloadURL: "https://pt/download/e01-hdtv", Seeders: 50000}, - {Title: "House of the Dragon S03E01 1080p WEB-DL", DownloadURL: "https://pt/download/e01-webdl-1080", Seeders: 100}, - {Title: "House of the Dragon S03E01 2160p WEB-DL", DownloadURL: "https://pt/download/e01-webdl-2160", Seeders: 80}, - {Title: "House of the Dragon S03E02 720p HDTV", DownloadURL: "https://pt/download/e02-hdtv", Seeders: 500}, - {Title: "House of the Dragon S03E02 1080p WEBRip", DownloadURL: "https://pt/download/e02-webrip", Seeders: 60}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 2 { - t.Fatalf("selected %d candidates, want one per episode", len(got)) - } - if got[0].Download != "https://pt/download/e01-webdl-2160" { - t.Fatalf("episode 1 selected %q, want best WEB-DL version", got[0].Download) - } - if got[1].Download != "https://pt/download/e02-webrip" { - t.Fatalf("episode 2 selected %q, want WEBRip over high-seeder HDTV", got[1].Download) - } -} - -func TestSelectSiteSearchCandidatesDefaultQualityRecognizesWebDLVariants(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - results := []SearchResult{ - {Title: "Some Show S01E01 1080p BluRay H264 AAC", DownloadURL: "https://pt/download/e01-bluray", Seeders: 900}, - {Title: "Some Show S01E01 2160p WEB.DL H264 AAC", DownloadURL: "https://pt/download/e01-webdotdl", Seeders: 40}, - {Title: "Some Show S01E01 1080p WEB DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl", Seeders: 50}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdotdl" { - t.Fatalf("selected %#v, want one best WEB-DL variant", got) - } -} - -func TestSelectSiteSearchCandidatesDefaultPrefersWebDLBeforeResolution(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - results := []SearchResult{ - {Title: "Some Show S01E01 2160p BluRay H264 AAC", DownloadURL: "https://pt/download/e01-bluray-2160", Seeders: 900}, - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl-1080", Seeders: 50}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdl-1080" { - t.Fatalf("selected %#v, want one compatible WEB-DL version before higher-resolution BluRay", got) - } -} - -func TestSelectSiteSearchCandidatesDefaultPrefersFreeWithinSameQualityBand(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - results := []SearchResult{ - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-nonfree", Seeders: 5000}, - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-free", Seeders: 80, Free: true}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/e01-free" { - t.Fatalf("selected %#v, want free candidate within same quality/resolution band", got) - } -} - -func TestSelectSiteSearchCandidatesDefaultDoesNotLetFreeOverrideBetterQuality(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - results := []SearchResult{ - {Title: "Some Show S01E01 1080p HDTV H264 AAC", DownloadURL: "https://pt/download/e01-free-hdtv", Seeders: 80, Free: true}, - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl", Seeders: 50}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdl" { - t.Fatalf("selected %#v, want WEB-DL quality to stay ahead of free HDTV", got) - } -} - -func TestSelectSiteSearchCandidatesDefaultDoesNotLetFreeOverrideBetterResolution(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - results := []SearchResult{ - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-free-1080", Seeders: 80, Free: true}, - {Title: "Some Show S01E01 2160p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-2160", Seeders: 50}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/e01-2160" { - t.Fatalf("selected %#v, want better resolution to stay ahead of free lower-resolution release", got) - } -} - -func TestSelectSiteSearchCandidatesDefaultsToOneLooseNumberedEpisode(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - results := []SearchResult{ - {Title: "Some Show 01 1080p HDTV H264 AAC", DownloadURL: "https://pt/download/e01-hdtv", Seeders: 5000}, - {Title: "Some Show 01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl-1080", Seeders: 100}, - {Title: "Some Show 01 2160p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl-2160", Seeders: 80}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 { - t.Fatalf("selected %d candidates, want one preferred loose-numbered episode", len(got)) - } - if got[0].Download != "https://pt/download/e01-webdl-2160" || got[0].Episode != 1 { - t.Fatalf("selected %#v, want episode 1 best WEB-DL version", got) - } -} - -func TestSelectSiteSearchCandidatesDoesNotTreatTitleNumberAsLooseEpisode(t *testing.T) { - sub := &model.Subscription{Name: "问心2 自动订阅", Filter: "问心2 2023", MediaType: "tv"} - results := []SearchResult{ - {Title: "问心2 2023 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/season", Seeders: 100}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 { - t.Fatalf("selected %#v, want one fallback candidate", got) - } - if got[0].Episode != 0 { - t.Fatalf("episode = %d, want title number not treated as episode", got[0].Episode) - } -} - -func TestSelectSiteSearchCandidatesRejectsRiskyLabelsFromSiteResult(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - results := []SearchResult{ - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", Labels: "HR", DownloadURL: "https://pt/download/e01-hr", Seeders: 900}, - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-safe", Seeders: 80}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/e01-safe" { - t.Fatalf("selected %#v, want non-HR candidate only", got) - } -} - -func TestSelectSiteSearchCandidatesWashPriorityDoesNotLetFreeOverrideResolution(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv", WashEnabled: true, WashPriority: "resolution"} - results := []SearchResult{ - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-free-1080", Seeders: 80, Free: true}, - {Title: "Some Show S01E01 2160p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-2160", Seeders: 50}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/e01-2160" { - t.Fatalf("selected %#v, want wash resolution priority to stay ahead of free lower-resolution release", got) - } -} - -func TestSelectSiteSearchCandidatesRejectsDefaultCompatibilityVersions(t *testing.T) { - sub := &model.Subscription{Name: "House of the Dragon 自动订阅", Filter: "House of the Dragon", MediaType: "tv"} - results := []SearchResult{ - {Title: "House of the Dragon S03E01 2160p WEB-DL HEVC 10bit DoVi Atmos", DownloadURL: "https://pt/download/e01-dovi", Seeders: 900}, - {Title: "House of the Dragon S03E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl", Seeders: 80}, - {Title: "House of the Dragon S03E01 1080p HDTV H264 AAC", DownloadURL: "https://pt/download/e01-hdtv", Seeders: 5000}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdl" { - t.Fatalf("selected %#v, want compatible WEB-DL only", got) - } -} - -func TestSelectSiteSearchCandidatesKeepsCompatibilityExcludesWithCustomExcludeWords(t *testing.T) { - sub := &model.Subscription{ - Name: "House of the Dragon 自动订阅", - Filter: "House of the Dragon", - MediaType: "tv", - ExcludeWords: "官中,无字幕", - } - results := []SearchResult{ - {Title: "House of the Dragon S03E01 2160p WEB-DL HEVC 10bit DoVi Atmos", DownloadURL: "https://pt/download/e01-dovi", Seeders: 900}, - {Title: "House of the Dragon S03E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-webdl", Seeders: 80}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/e01-webdl" { - t.Fatalf("selected %#v, want custom exclude words to keep default compatible WEB-DL guard", got) - } -} - -func TestSelectSiteSearchCandidatesAvoidsOverlappingEpisodeRanges(t *testing.T) { - sub := &model.Subscription{Name: "House of the Dragon 自动订阅", Filter: "House of the Dragon", MediaType: "tv", WashEnabled: true, WashPriority: "quality"} - availability := LocalAvailability{ - LocalMediaCount: 1, - TotalEpisodes: 3, - MissingEpisodes: []int{1, 2, 3}, - ExistingEpisodeKeys: map[string]struct{}{}, - } - results := []SearchResult{ - {Title: "House of the Dragon S03E01-E02 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e01-e02-pack", Seeders: 90}, - {Title: "House of the Dragon S03E02 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e02-duplicate", Seeders: 80}, - {Title: "House of the Dragon S03E03 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/e03", Seeders: 70}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 2 { - t.Fatalf("selected %d candidates, want pack plus non-overlapping episode", len(got)) - } - if got[0].Download != "https://pt/download/e01-e02-pack" || got[1].Download != "https://pt/download/e03" { - t.Fatalf("selected %#v, want overlapping E02 duplicate skipped", got) - } -} - -func TestSelectSiteSearchCandidatesAppliesQualityRules(t *testing.T) { - sub := &model.Subscription{ - Name: "Dune 自动订阅", - Filter: "Dune 2021", - MediaType: "movie", - Resolution: "2160p", - Quality: "remux", - Effects: "hdr", - ExcludeWords: "cam,ts", - } - results := []SearchResult{ - {Title: "Dune 2021 2160p WEB-DL HDR", DownloadURL: "https://pt/download/web", Seeders: 100}, - {Title: "Dune 2021 2160p UHD BluRay REMUX HDR", DownloadURL: "https://pt/download/remux", Seeders: 30}, - {Title: "Dune 2021 2160p REMUX HDR CAM", DownloadURL: "https://pt/download/cam", Seeders: 200}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/remux" { - t.Fatalf("selected %#v, want filtered remux", got) - } -} diff --git a/internal/service/subscription_candidate_select.go b/internal/service/subscription_candidate_select.go deleted file mode 100644 index 187fa69..0000000 --- a/internal/service/subscription_candidate_select.go +++ /dev/null @@ -1,184 +0,0 @@ -package service - -import ( - "sort" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func selectPreparedSubscriptionCandidates(candidates []siteSearchCandidate, sub *model.Subscription, local LocalAvailability) []siteSearchCandidate { - return selectPreparedSubscriptionCandidatesWithStats(candidates, sub, local, nil) -} - -func selectPreparedSubscriptionCandidatesWithStats(candidates []siteSearchCandidate, sub *model.Subscription, local LocalAvailability, stats *siteSearchSelectionStats) []siteSearchCandidate { - if len(candidates) > 1 { - sort.SliceStable(candidates, func(i, j int) bool { - if candidates[i].Score != candidates[j].Score { - return candidates[i].Score > candidates[j].Score - } - if candidates[i].Item.Seeders != candidates[j].Item.Seeders { - return candidates[i].Item.Seeders > candidates[j].Item.Seeders - } - return candidates[i].Item.Size > candidates[j].Item.Size - }) - } - if len(candidates) == 0 { - return recordPreparedSelection(nil, stats) - } - - mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "") - if !isSubscriptionSeriesType(mediaType) { - // 非洗版订阅成功下载一次即满足,媒体库/下载中已存在则不再重复下载。 - if !subscriptionAllowsWash(sub) && local.LocalMediaCount > 0 { - if stats != nil { - stats.LocalAlreadySatisfied = true - } - return recordPreparedSelection(nil, stats) - } - return recordPreparedSelection(candidates[:1], stats) - } - - if localSeriesPackSatisfiesSubscription(local) { - if stats != nil { - stats.LocalSeriesPackPresent = true - } - return recordPreparedSelection(nil, stats) - } - if local.LocalMediaCount > 0 { - trustedTotal := trustedAvailabilityTotal(local) - if trustedTotal > 0 && len(local.MissingEpisodes) == 0 { - if stats != nil { - stats.SeriesComplete = true - } - return recordPreparedSelection(nil, stats) - } - missingSet := missingEpisodeSet(local) - onlyMissing := make([]siteSearchCandidate, 0, len(candidates)) - var packFallback *siteSearchCandidate - for i := range candidates { - candidate := candidates[i] - if candidate.Episode <= 0 { - // 整季/全集包(无单集号)。剧集完结后站点常只挂全集包, - // 这里记下来作兜底:当单集候选不足以补齐缺失集时启用, - // 否则"补全缺失集"在站点只有全集包时永远匹配为空。 - if stats != nil { - stats.NoEpisodeSkipped++ - } - if candidate.Pack && packFallback == nil { - packFallback = &candidates[i] - if stats != nil { - stats.PackFallbackAvailable = true - } - } - continue - } - season := candidate.Season - if season <= 0 { - season = 1 - } - if candidateEpisodesAllExist(local.ExistingEpisodeKeys, season, candidate) { - if stats != nil { - stats.ExistingEpisodeSkipped++ - } - continue - } - if trustedTotal > 0 && !candidateCoversMissingEpisode(candidate, missingSet) { - if stats != nil { - stats.NotMissingEpisodeSkipped++ - } - continue - } - onlyMissing = append(onlyMissing, candidate) - } - selected := sortedEpisodeCandidates(onlyMissing) - if len(selected) == 0 && packFallback != nil { - // 没有可用的单集候选,但站点有整季/全集包 → 用包兜底补缺集。 - // 代价是会重下已有集,但用户主动触发补全时这是可接受的。 - if stats != nil { - stats.PackFallbackUsed = true - } - return recordPreparedSelection([]siteSearchCandidate{*packFallback}, stats) - } - return recordPreparedSelection(selected, stats) - } - - for _, candidate := range candidates { - if candidate.Pack { - return recordPreparedSelection([]siteSearchCandidate{candidate}, stats) - } - } - - selected := sortedEpisodeCandidates(candidates) - if len(selected) == 0 { - return recordPreparedSelection(candidates[:1], stats) - } - return recordPreparedSelection(selected, stats) -} - -func candidateEpisodesAllExist(existing map[string]struct{}, season int, candidate siteSearchCandidate) bool { - episodes := candidateEpisodeNumbers(candidate) - if len(episodes) == 0 { - return false - } - for _, episode := range episodes { - if _, exists := existing[episodeKey(season, episode)]; !exists { - return false - } - } - return true -} - -func candidateCoversMissingEpisode(candidate siteSearchCandidate, missingSet map[int]struct{}) bool { - episodes := candidateEpisodeNumbers(candidate) - if len(episodes) == 0 { - return false - } - for _, episode := range episodes { - if _, missing := missingSet[episode]; missing { - return true - } - } - return false -} - -func candidateEpisodeNumbers(candidate siteSearchCandidate) []int { - if len(candidate.Episodes) > 0 { - return candidate.Episodes - } - if candidate.Episode > 0 { - return []int{candidate.Episode} - } - return nil -} - -func recordPreparedSelection(candidates []siteSearchCandidate, stats *siteSearchSelectionStats) []siteSearchCandidate { - if stats != nil { - stats.Selected = len(candidates) - } - return candidates -} - -func localSeriesPackSatisfiesSubscription(local LocalAvailability) bool { - if !local.HasSeriesPack { - return false - } - total := trustedAvailabilityTotal(local) - if total <= 0 { - return len(local.ExistingEpisodeKeys) == 0 - } - if len(local.MissingEpisodes) > 0 { - return false - } - return len(local.ExistingEpisodeKeys) >= total -} - -func trustedAvailabilityTotal(local LocalAvailability) int { - total := local.TotalEpisodes - if total <= 0 { - return 0 - } - if maxEpisode := maxAvailabilityEpisode(local.ExistingEpisodeKeys); maxEpisode > total { - return 0 - } - return total -} diff --git a/internal/service/subscription_candidate_selection_test.go b/internal/service/subscription_candidate_selection_test.go deleted file mode 100644 index 6fee3bc..0000000 --- a/internal/service/subscription_candidate_selection_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package service - -import ( - "strings" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestSelectSiteSearchCandidatesPrefersSeriesPack(t *testing.T) { - sub := &model.Subscription{Name: "间谍过家家 自动订阅", Filter: "间谍过家家 2022", MediaType: "tv"} - results := []SearchResult{ - {Title: "间谍过家家 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 80}, - {Title: "间谍过家家 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 50}, - {Title: "间谍过家家 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 70}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 { - t.Fatalf("selected %d candidates, want 1", len(got)) - } - if got[0].Download != "https://pt/download/pack" || !got[0].Pack { - t.Fatalf("selected %#v, want complete pack", got[0]) - } -} - -func TestSelectSiteSearchCandidatesQueuesDistinctEpisodesWhenNoPack(t *testing.T) { - sub := &model.Subscription{Name: "葬送的芙莉莲 自动订阅", Filter: "葬送的芙莉莲", MediaType: "anime", WashEnabled: true, WashPriority: "resolution"} - results := []SearchResult{ - {Title: "葬送的芙莉莲 S01E01 1080p", DownloadURL: "https://pt/download/1a", Seeders: 90}, - {Title: "葬送的芙莉莲 S01E01 2160p", DownloadURL: "https://pt/download/1b", Seeders: 80}, - {Title: "葬送的芙莉莲 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 70}, - {Title: "葬送的芙莉莲 S01E03 1080p", DownloadURL: "https://pt/download/3", Seeders: 60}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 3 { - t.Fatalf("selected %d candidates, want 3", len(got)) - } - if got[0].Episode != 1 || got[1].Episode != 2 || got[2].Episode != 3 { - t.Fatalf("episodes = %d,%d,%d; want 1,2,3", got[0].Episode, got[1].Episode, got[2].Episode) - } - if got[0].Download != "https://pt/download/1b" { - t.Fatalf("duplicate episode should keep wash-priority best result, got %q", got[0].Download) - } -} - -func TestSelectSiteSearchCandidatesKeepsMovieSingleBest(t *testing.T) { - sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", WashPriority: "seeders"} - results := []SearchResult{ - {Title: "Inception 2010 1080p HDTV", DownloadURL: "https://pt/download/1080-hdtv", Seeders: 900}, - {Title: "Inception 2010 2160p WEB-DL", DownloadURL: "https://pt/download/2160-webdl", Seeders: 80}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/2160-webdl" { - t.Fatalf("selected %#v, want movie best only", got) - } -} - -func TestSelectSiteSearchCandidatesRejectsUnrelatedHighSeederResult(t *testing.T) { - sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", WashPriority: "seeders"} - results := []SearchResult{ - {Title: "Unrelated Movie 2026 2160p", DownloadURL: "https://pt/download/wrong", Seeders: 999}, - {Title: "Inception 2010 1080p", DownloadURL: "https://pt/download/right", Seeders: 90}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/right" { - t.Fatalf("selected %#v, want title-matched result only", got) - } -} - -func TestSelectSiteSearchCandidatesMatchesTranslatedSubtitle(t *testing.T) { - sub := &model.Subscription{Name: "真人快打2 自动订阅", Filter: "真人快打2 2026", MediaType: "movie", WashPriority: "seeders"} - results := []SearchResult{ - {Title: "Unrelated Movie 2026 2160p", DownloadURL: "https://pt/download/wrong", Seeders: 999}, - {Title: "Mortal Kombat II 2026 1080p WEB-DL", Subtitle: "真人快打2", DownloadURL: "https://pt/download/right", Seeders: 90}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/right" { - t.Fatalf("selected %#v, want translated subtitle match", got) - } -} - -func TestSelectSiteSearchCandidatesMatchesFeedAlias(t *testing.T) { - sub := &model.Subscription{ - Name: "真人快打2 自动订阅", - FeedURL: "site-search://search?keyword=%E7%9C%9F%E4%BA%BA%E5%BF%AB%E6%89%932%202026&alias=Mortal%20Kombat%20II%202026", - Filter: "真人快打2 2026", - MediaType: "movie", - } - results := []SearchResult{ - {Title: "Mortal Kombat II 2026 1080p WEB-DL", DownloadURL: "https://pt/download/right", Seeders: 90}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/right" { - t.Fatalf("selected %#v, want alias-matched result", got) - } -} - -func TestSelectSiteSearchCandidatesMatchesSubscriptionOriginalNameAlias(t *testing.T) { - sub := &model.Subscription{ - Name: "玩具总动员 5 自动订阅", - Filter: "玩具总动员 5 2026", - OriginalName: "Toy Story 5", - Year: 2026, - MediaType: "movie", - } - results := []SearchResult{ - {Title: "Toy Story 5 2026 1080p WEB-DL", DownloadURL: "https://pt/download/right", Seeders: 90}, - } - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) - if len(got) != 1 || got[0].Download != "https://pt/download/right" { - t.Fatalf("selected %#v, want original-name alias match", got) - } -} - -func TestSelectSiteSearchCandidatesTrustsMatchedSearchKeyword(t *testing.T) { - sub := &model.Subscription{ - Name: "南部档案 自动订阅", - Filter: "南部档案", - MediaType: "tv", - TotalEpisodes: 33, - } - results := []SearchResult{{ - Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", - DownloadURL: "https://pt/download/nanyang-29-33", - SearchKeyword: "南部档案 2026", - Seeders: 90, - }} - availability := LocalAvailability{ - TotalEpisodes: 33, - ExistingEpisodeKeys: map[string]struct{}{ - episodeKey(1, 1): {}, - }, - MissingEpisodes: []int{2, 3, 4, 5}, - } - - got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Download != "https://pt/download/nanyang-29-33" || !got[0].Pack { - t.Fatalf("selected %#v, want English pack matched by Chinese search keyword", got) - } - if stats.QueryMismatch != 0 || stats.Prepared != 1 || stats.Selected != 1 { - t.Fatalf("stats = %#v, want keyword-origin match without query mismatch", stats) - } -} - -func TestDedupeSiteSearchResultsKeepsMatchedSearchKeyword(t *testing.T) { - sub := &model.Subscription{ - Name: "南部档案 自动订阅", - Filter: "南部档案", - MediaType: "tv", - TotalEpisodes: 33, - } - results := dedupeSiteSearchResults([]SearchResult{ - { - SiteID: "mteam", - Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", - DownloadURL: "https://pt/download/nanyang-29-33", - SearchKeyword: "Archives The Nanyang Mystery", - Seeders: 80, - Size: 1024, - }, - { - SiteID: "mteam", - Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", - DownloadURL: "https://pt/download/nanyang-29-33", - SearchKeyword: "南部档案 2026", - Seeders: 80, - Size: 1024, - }, - }) - if len(results) != 1 { - t.Fatalf("deduped results = %#v, want one merged result", results) - } - if !strings.Contains(results[0].SearchKeyword, "南部档案 2026") { - t.Fatalf("merged search keyword = %q, missing Chinese keyword", results[0].SearchKeyword) - } - availability := LocalAvailability{TotalEpisodes: 33, MissingEpisodes: []int{29, 30, 31, 32, 33}, ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}}} - got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Download != "https://pt/download/nanyang-29-33" { - t.Fatalf("selected %#v, want merged keyword candidate", got) - } - if stats.QueryMismatch != 0 || stats.Prepared != 1 { - t.Fatalf("stats = %#v, want merged keyword to avoid query mismatch", stats) - } -} diff --git a/internal/service/subscription_classifier.go b/internal/service/subscription_classifier.go deleted file mode 100644 index fd20a24..0000000 --- a/internal/service/subscription_classifier.go +++ /dev/null @@ -1,172 +0,0 @@ -package service - -import ( - "context" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *SubscriptionService) classifySubscriptionItem(ctx context.Context, sub *model.Subscription, title, sourceCategory string) (string, string) { - mediaType := normalizeMediaType(sub.MediaType, title+" "+sub.Name+" "+sub.Filter, sourceCategory) - category := strings.TrimSpace(sub.MediaCategory) - if category == "" { - if match := s.lookupSubscriptionMetadata(ctx, mediaType, title, sub); match != nil { - category = classifyMediaCategory(mediaClassifyInput{ - MediaType: mediaType, - Title: match.Title + " " + match.OriginalName, - Languages: match.Languages, - Countries: match.Countries, - Genres: match.Genres, - Category: sourceCategory, - }, s.categoryMap()) - if s != nil && s.log != nil && category != "" { - s.log.Info("subscription metadata classified", - zap.String("title", title), - zap.String("matched_title", match.Title), - zap.String("media_type", mediaType), - zap.String("media_category", category), - zap.Int("tmdb_id", match.TMDbID), - zap.Int("bangumi_id", match.BangumiID), - zap.String("douban_id", match.DoubanID), - zap.String("thetvdb_id", match.TheTVDBID)) - } - } - } - if category == "" { - category = classifyMediaCategory(mediaClassifyInput{ - MediaType: mediaType, - Title: title + " " + sub.Name + " " + sub.Filter, - Category: sourceCategory, - }, s.categoryMap()) - } - return mediaType, category -} - -func (s *SubscriptionService) lookupSubscriptionMetadata(ctx context.Context, mediaType, title string, sub *model.Subscription) *Match { - if s == nil || s.scraper == nil || !s.scraper.AnyEnabled() { - return nil - } - queries := subscriptionMetadataQueries(title, sub) - if len(queries) == 0 { - return nil - } - for _, libType := range subscriptionMetadataLibraryTypes(mediaType, title) { - lib := &model.Library{Type: libType, Enabled: true} - for _, query := range queries { - cleaned, year := CleanQueryWithRecognition(ctx, s.repo, query) - if cleaned == "" { - cleaned = strings.TrimSpace(query) - } - for _, candidate := range titleCandidates(cleaned) { - if candidate == "" { - continue - } - match := s.scraper.lookup(ctx, lib, nil, candidate, year) - if match == nil || strings.TrimSpace(match.Title) == "" { - continue - } - if !organizeMetadataMatchTrusted(candidate, year, match) { - continue - } - return match - } - } - } - return nil -} - -func subscriptionMetadataQueries(title string, sub *model.Subscription) []string { - seen := map[string]struct{}{} - out := make([]string, 0, 3) - add := func(value string) { - value = strings.TrimSpace(value) - if value == "" { - return - } - key := strings.ToLower(value) - if _, ok := seen[key]; ok { - return - } - seen[key] = struct{}{} - out = append(out, value) - } - add(title) - if sub != nil { - add(sub.Filter) - add(sub.Name) - } - return out -} - -func subscriptionMetadataLibraryTypes(mediaType, title string) []string { - if strings.TrimSpace(mediaType) == "" { - text := strings.ToLower(title) - switch { - case classifierEpisodeRE.MatchString(text) || classifierSeasonRE.MatchString(text): - return []string{"tv", "anime", "movie"} - case containsAnyText(text, "动漫", "动画", "anime", "bangumi"): - return []string{"anime", "tv", "movie"} - case containsAnyText(text, "电影", "movie", "film"): - return []string{"movie", "tv", "anime"} - default: - return []string{"tv", "movie", "anime"} - } - } - switch normalizeMediaType(mediaType, title, "") { - case "movie": - return []string{"movie"} - case "anime": - return []string{"anime", "tv"} - case "tv", "variety": - return []string{"tv", "anime"} - default: - if classifierEpisodeRE.MatchString(title) || classifierSeasonRE.MatchString(title) { - return []string{"tv", "anime"} - } - return []string{"movie", "tv", "anime"} - } -} - -func (s *SubscriptionService) categoryMap() map[string]string { - if s == nil || s.cfg == nil || s.cfg.Organizer.Categories == nil { - return nil - } - return s.cfg.Organizer.Categories -} - -func (s *SubscriptionService) resolveSubscriptionSavePath(ctx context.Context, sub *model.Subscription, mediaType, category string) string { - if sub == nil { - return "" - } - base := strings.TrimSpace(sub.SavePath) - if base == "" { - base = downloadDefaultSaveRoot(ctx, s.repo) - } - if base == "" { - return "" - } - if !s.isSmartClassifyEnabled(ctx) || category == "" { - return base - } - return downloadSavePathCategoryRoot(base, sanitizeFilename(category)) -} - -func (s *SubscriptionService) isSmartClassifyEnabled(ctx context.Context) bool { - if s != nil && s.repo != nil && s.repo.Setting != nil { - val, err := s.repo.Setting.Get(ctx, DownloadSmartClassifySettingKey) - if err == nil && val != "" { - return parseBoolSetting(val, true) - } - val, err = s.repo.Setting.Get(ctx, "organizer.smart_classify") - if err == nil && parseBoolSetting(val, false) { - return true - } - } - if s != nil && s.cfg != nil && s.cfg.Organizer.SmartClassify { - return true - } - return true -} diff --git a/internal/service/subscription_delete.go b/internal/service/subscription_delete.go deleted file mode 100644 index 115f2fd..0000000 --- a/internal/service/subscription_delete.go +++ /dev/null @@ -1,131 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "gorm.io/gorm" -) - -func (s *SubscriptionService) deleteSubscriptionDownloads(ctx context.Context, sub *model.Subscription) error { - if s == nil || s.repo == nil || s.repo.Download == nil || sub == nil { - return nil - } - rows, err := s.repo.Download.List(ctx) - if err != nil { - return err - } - candidates := make([]model.DownloadTask, 0) - for _, row := range rows { - if subscriptionDeleteMatchesTask(ctx, s, sub, row) { - candidates = append(candidates, row) - } - } - if len(candidates) == 0 { - return nil - } - - var live []QBitTorrent - if s.downloads != nil { - live, _ = s.downloads.listLiveTorrents(ctx, "") - } - deletedHashes := map[string]struct{}{} - for _, task := range candidates { - hash := firstNonEmpty(task.ExternalID, downloadTaskInfoHash(task)) - clientID := strings.TrimSpace(task.DownloadClientID) - if matched, ok := matchingLiveTorrent(task, live); ok { - hash = firstNonEmpty(hash, matched.Hash) - clientID = firstNonEmpty(clientID, matched.ClientID) - } - if hash != "" && s.downloads != nil { - key := strings.ToLower(clientID + ":" + hash) - if _, ok := deletedHashes[key]; !ok { - if err := s.downloads.Delete(ctx, hash, false, clientID); err != nil { - return fmt.Errorf("删除订阅关联下载任务 %q 失败: %w", task.Title, err) - } - deletedHashes[key] = struct{}{} - } - continue - } - markDownloadTaskDeletedByID(ctx, s.repo.DB, task) - } - return nil -} - -func subscriptionDeleteMatchesTask(ctx context.Context, s *SubscriptionService, sub *model.Subscription, task model.DownloadTask) bool { - if strings.TrimSpace(task.Status) != "" && !downloadTaskBlocksReadd(task.Status) { - return false - } - if strings.TrimSpace(task.SubscriptionID) != "" { - return task.SubscriptionID == sub.ID - } - if strings.TrimSpace(sub.UserID) != "" && strings.TrimSpace(task.UserID) != "" && sub.UserID != task.UserID { - return false - } - baseSavePath := s.subscriptionBaseSavePath(ctx, sub) - if baseSavePath != "" && task.SavePath != "" && !sameOrChildPath(task.SavePath, baseSavePath) && !sameOrChildPath(baseSavePath, task.SavePath) { - return false - } - query := normalizeAvailabilityComparable(availabilityQuery(subscriptionName(sub), subscriptionFilter(sub))) - if query == "" { - return false - } - title := normalizeAvailabilityComparable(task.Title) - if title == "" { - title = normalizeAvailabilityComparable(publicDownloadTitle(task.URL)) - } - return title != "" && (strings.Contains(title, query) || strings.Contains(query, title)) -} - -func downloadTaskInfoHash(task model.DownloadTask) string { - return torrentURLInfoHash(task.URL) -} - -func matchingLiveTorrentHash(task model.DownloadTask, live []QBitTorrent) string { - if torrent, ok := matchingLiveTorrent(task, live); ok { - return strings.TrimSpace(torrent.Hash) - } - return "" -} - -func matchingLiveTorrent(task model.DownloadTask, live []QBitTorrent) (QBitTorrent, bool) { - for _, torrent := range live { - if strings.TrimSpace(task.DownloadClientID) != "" && task.DownloadClientID != torrent.ClientID { - continue - } - if strings.TrimSpace(task.ExternalID) != "" && strings.EqualFold(task.ExternalID, torrent.Hash) { - return torrent, true - } - } - key := downloadTaskIdentityKey(task.Title) - if key == "" { - key = downloadTaskIdentityKey(publicDownloadTitle(task.URL)) - } - if key == "" { - return QBitTorrent{}, false - } - for _, torrent := range live { - current := downloadTaskIdentityKey(torrent.Name) - if current == "" { - continue - } - if current == key || strings.Contains(current, key) || strings.Contains(key, current) { - return torrent, true - } - } - return QBitTorrent{}, false -} - -func markDownloadTaskDeletedByID(ctx context.Context, db *gorm.DB, task model.DownloadTask) { - if db == nil || strings.TrimSpace(task.ID) == "" { - return - } - _ = db.WithContext(ctx).Model(&model.DownloadTask{}). - Where("id = ?", task.ID). - Updates(map[string]any{ - "status": "deleted", - "progress": task.Progress, - }).Error -} diff --git a/internal/service/subscription_downloader_test.go b/internal/service/subscription_downloader_test.go deleted file mode 100644 index f273cfa..0000000 --- a/internal/service/subscription_downloader_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSubscriptionRunOneDoesNotUseDeletedDownloader(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - Deleted Downloader Show S01E01 1080p - deleted-downloader-episode-1 - magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&dn=Deleted+Downloader+Show+S01E01 - -`)) - })) - defer rss.Close() - - var qbCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&qbCalls, 1) - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - client := &model.DownloadClient{Name: "qB deleted", Type: "qbittorrent", Host: qb.URL, Username: "admin", Password: "admin", IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil { - t.Fatal(err) - } - if err := repos.DownloadClient.Delete(t.Context(), client.ID); err != nil { - t.Fatal(err) - } - - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Name: "Deleted Downloader Show 自动订阅", - FeedURL: rss.URL, - Filter: "Deleted Downloader Show", - MediaType: "tv", - SavePath: "/downloads/tv", - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 0 { - t.Fatalf("queued = %d, want 0 when default downloader was deleted", queued) - } - if got := atomic.LoadInt32(&qbCalls); got != 0 { - t.Fatalf("qB calls = %d, want 0 after downloader deletion", got) - } - rows, err := repos.Download.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("download rows = %d, want 0", len(rows)) - } -} diff --git a/internal/service/subscription_episode_queries.go b/internal/service/subscription_episode_queries.go deleted file mode 100644 index f5f2586..0000000 --- a/internal/service/subscription_episode_queries.go +++ /dev/null @@ -1,176 +0,0 @@ -package service - -import ( - "fmt" - "net/url" - "regexp" - "strconv" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func subscriptionTitleMatchesQuery(sub *model.Subscription, title string) bool { - if strings.TrimSpace(title) == "" { - return false - } - for _, query := range subscriptionTitleMatchQueries(sub) { - if strings.Contains(normalizeAvailabilityComparable(title), normalizeAvailabilityComparable(query)) { - return true - } - } - return len(subscriptionTitleMatchQueries(sub)) == 0 -} - -func subscriptionSearchResultMatchesQuery(sub *model.Subscription, item SearchResult) bool { - matchText := subscriptionSearchResultText(item) - if subscriptionTitleMatchesQuery(sub, matchText) { - return true - } - if !subscriptionSearchResultYearCompatible(sub, matchText) { - return false - } - return subscriptionKeywordMatchesQuery(sub, item.SearchKeyword) -} - -func subscriptionSearchResultYearCompatible(sub *model.Subscription, title string) bool { - expected := subscriptionExpectedYear(sub) - if expected <= 0 { - return true - } - years := titleYears(title) - if len(years) == 0 { - return true - } - for _, year := range years { - if year == expected { - return true - } - } - return false -} - -func subscriptionExpectedYear(sub *model.Subscription) int { - if sub == nil { - return 0 - } - if sub.Year > 0 { - return sub.Year - } - for _, value := range []string{sub.Filter, sub.Name, sub.FeedURL} { - for _, year := range titleYears(value) { - return year - } - } - return 0 -} - -func titleYears(value string) []int { - matches := regexp.MustCompile(`\b(19\d{2}|20\d{2})\b`).FindAllString(value, -1) - if len(matches) == 0 { - return nil - } - out := make([]int, 0, len(matches)) - seen := map[int]struct{}{} - for _, match := range matches { - year, err := strconv.Atoi(match) - if err != nil { - continue - } - if _, ok := seen[year]; ok { - continue - } - seen[year] = struct{}{} - out = append(out, year) - } - return out -} - -func subscriptionKeywordMatchesQuery(sub *model.Subscription, keyword string) bool { - keyword = strings.TrimSpace(keyword) - if keyword == "" { - return false - } - normalizedKeyword := normalizeAvailabilityComparable(keyword) - if normalizedKeyword == "" { - return false - } - for _, query := range subscriptionTitleMatchQueries(sub) { - normalizedQuery := normalizeAvailabilityComparable(query) - if normalizedQuery == "" { - continue - } - if strings.Contains(normalizedKeyword, normalizedQuery) || strings.Contains(normalizedQuery, normalizedKeyword) { - return true - } - } - return len(subscriptionTitleMatchQueries(sub)) == 0 -} - -func subscriptionTitleMatchQueries(sub *model.Subscription) []string { - if sub == nil { - return nil - } - values := []string{ - availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), - cleanAvailabilityTitle(subscriptionFilter(sub)), - cleanAvailabilityTitle(subscriptionName(sub)), - } - for _, alias := range subscriptionFeedAliases(sub) { - values = append(values, alias, cleanAvailabilityTitle(alias)) - } - for _, alias := range subscriptionMetadataAliases(sub) { - values = append(values, alias, cleanAvailabilityTitle(alias)) - } - return compactUniqueStrings(values...) -} - -func subscriptionEpisodeMetadataQueries(sub *model.Subscription) []string { - if sub == nil { - return nil - } - raw := []string{ - siteSearchKeyword(sub), - sub.Filter, - sub.Name, - availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), - } - out := make([]string, 0, len(raw)*2) - for _, value := range raw { - value = cleanAvailabilityTitle(value) - if value == "" { - continue - } - if cleaned, _ := CleanQuery(value); cleaned != "" { - out = append(out, cleaned) - } - out = append(out, value) - } - return compactUniqueStrings(out...) -} - -func subscriptionExplicitTMDbID(sub *model.Subscription) int { - if sub == nil { - return 0 - } - values := []string{sub.Name, sub.Filter, sub.FeedURL} - for _, raw := range values { - for _, pattern := range []string{`(?i)\btmdb[_:\-\s=]+(\d{2,})`, `(?i)\btmdbid[_:\-\s=]+(\d{2,})`} { - if m := regexp.MustCompile(pattern).FindStringSubmatch(raw); len(m) >= 2 { - var id int - if _, err := fmt.Sscanf(m[1], "%d", &id); err == nil && id > 0 { - return id - } - } - } - if u, err := url.Parse(raw); err == nil { - for _, key := range []string{"tmdb_id", "tmdb", "tmdbid"} { - var id int - if _, err := fmt.Sscanf(u.Query().Get(key), "%d", &id); err == nil && id > 0 { - return id - } - } - } - } - return 0 -} diff --git a/internal/service/subscription_episode_totals.go b/internal/service/subscription_episode_totals.go deleted file mode 100644 index 500ecbd..0000000 --- a/internal/service/subscription_episode_totals.go +++ /dev/null @@ -1,197 +0,0 @@ -package service - -import ( - "context" - "regexp" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *SubscriptionService) updateSubscriptionTotalEpisodes(ctx context.Context, sub *model.Subscription, total int) { - if s == nil || s.repo == nil || s.repo.DB == nil || sub == nil || total <= sub.TotalEpisodes { - return - } - sub.TotalEpisodes = total - _ = s.repo.DB.WithContext(ctx).Model(sub).Update("total_episodes", total).Error -} - -func inferRSSTotalEpisodes(items []rssItem, sub *model.Subscription, filter *regexp.Regexp) int { - if !subscriptionShouldInferTotal(sub) { - return 0 - } - maxEpisode := 0 - for _, item := range items { - title := strings.TrimSpace(item.Title) - if title == "" { - continue - } - if filter != nil && !filter.MatchString(title) { - continue - } - if !subscriptionTitleMatchesQuery(sub, title) { - continue - } - if !matchesSubscriptionRules(sub, title) { - continue - } - _, episode := ParseEpisode(title) - if episode > maxEpisode { - maxEpisode = episode - } - } - return maxEpisode -} - -func inferSearchTotalEpisodes(results []SearchResult, sub *model.Subscription) int { - if !subscriptionShouldInferTotal(sub) { - return 0 - } - maxEpisode := 0 - for _, item := range results { - matchText := subscriptionSearchResultText(item) - if !subscriptionSearchResultMatchesQuery(sub, item) { - continue - } - if !matchesSubscriptionRules(sub, matchText) { - continue - } - _, episode := ParseEpisode(matchText) - if episode > maxEpisode { - maxEpisode = episode - } - } - return maxEpisode -} - -func subscriptionShouldInferTotal(sub *model.Subscription) bool { - if sub == nil { - return false - } - mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "") - return isSubscriptionSeriesType(mediaType) -} - -func (s *SubscriptionService) resolveSubscriptionTotalEpisodes(ctx context.Context, sub *model.Subscription, fallback int) int { - if !subscriptionShouldInferTotal(sub) { - return 0 - } - if sub.TotalEpisodes > 0 { - return sub.TotalEpisodes - } - if total := s.resolveSubscriptionMetadataTotalEpisodes(ctx, sub); total > 0 { - return total - } - return fallback -} - -func (s *SubscriptionService) resolveSubscriptionMetadataTotalEpisodes(ctx context.Context, sub *model.Subscription) int { - if s == nil || s.scraper == nil || sub == nil { - return 0 - } - queries := subscriptionEpisodeMetadataQueries(sub) - - // Priority: TMDb -> Douban -> Bangumi -> TheTVDB -> Fanart -> title fallback. - // Fanart.tv is artwork-only in MediaStationGo, so it intentionally does not - // claim episode counts and lets the title fallback handle the final layer. - if s.scraper.tmdb != nil { - if id := subscriptionExplicitTMDbID(sub); id > 0 { - if total, err := s.scraper.tmdb.GetTVEpisodeCount(ctx, id); err == nil && total > 0 { - return total - } else if err != nil && s.log != nil { - s.log.Debug("subscription tmdb episode count failed", zap.Int("tmdb_id", id), zap.Error(err)) - } - } - for _, query := range queries { - match, err := s.scraper.tmdb.SearchTV(ctx, query, 0) - if err != nil { - if s.log != nil { - s.log.Debug("subscription tmdb search failed", zap.String("query", query), zap.Error(err)) - } - continue - } - if match == nil || match.TMDbID <= 0 { - continue - } - total, err := s.scraper.tmdb.GetTVEpisodeCount(ctx, match.TMDbID) - if err != nil { - if s.log != nil { - s.log.Debug("subscription tmdb episode count failed", zap.Int("tmdb_id", match.TMDbID), zap.Error(err)) - } - continue - } - if total > 0 { - return total - } - } - } - - if s.scraper.douban != nil { - for _, query := range queries { - total, err := s.scraper.douban.GetEpisodeCount(ctx, query) - if err != nil { - if s.log != nil { - s.log.Debug("subscription douban episode count failed", zap.String("query", query), zap.Error(err)) - } - continue - } - if total > 0 { - return total - } - } - } - - if s.scraper.bangumi != nil { - for _, query := range queries { - match, err := s.scraper.bangumi.Search(ctx, query) - if err != nil { - if s.log != nil { - s.log.Debug("subscription bangumi search failed", zap.String("query", query), zap.Error(err)) - } - continue - } - if match == nil || match.BangumiID <= 0 { - continue - } - total, err := s.scraper.bangumi.GetEpisodeCount(ctx, match.BangumiID) - if err != nil { - if s.log != nil { - s.log.Debug("subscription bangumi episode count failed", zap.Int("bangumi_id", match.BangumiID), zap.Error(err)) - } - continue - } - if total > 0 { - return total - } - } - } - - if s.scraper.thetvdb != nil { - for _, query := range queries { - match, err := s.scraper.thetvdb.SearchSeries(ctx, query) - if err != nil { - if s.log != nil { - s.log.Debug("subscription thetvdb search failed", zap.String("query", query), zap.Error(err)) - } - continue - } - if match == nil || strings.TrimSpace(match.TheTVDBID) == "" { - continue - } - total, err := s.scraper.thetvdb.GetSeriesEpisodeCount(ctx, match.TheTVDBID) - if err != nil { - if s.log != nil { - s.log.Debug("subscription thetvdb episode count failed", zap.String("thetvdb_id", match.TheTVDBID), zap.Error(err)) - } - continue - } - if total > 0 { - return total - } - } - } - - return 0 -} diff --git a/internal/service/subscription_episode_totals_test.go b/internal/service/subscription_episode_totals_test.go deleted file mode 100644 index c10b17e..0000000 --- a/internal/service/subscription_episode_totals_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestInferSubscriptionTotalEpisodesFromSearchAndRSS(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - results := []SearchResult{ - {Title: "Some Show S01E01 1080p"}, - {Title: "Some Show S01E12 1080p"}, - {Title: "Other Show S01E99 1080p"}, - } - if got := inferSearchTotalEpisodes(results, sub); got != 12 { - t.Fatalf("search inferred total = %d, want 12", got) - } - subtitleResults := []SearchResult{ - {Title: "Smoking Behind the Supermarket with You", Subtitle: "躲在超市后门抽烟的两人 S01E12"}, - } - subtitleSub := &model.Subscription{Name: "躲在超市后门抽烟的两人 自动订阅", Filter: "躲在超市后门抽烟的两人", MediaType: "tv"} - if got := inferSearchTotalEpisodes(subtitleResults, subtitleSub); got != 12 { - t.Fatalf("subtitle search inferred total = %d, want 12", got) - } - items := []rssItem{ - {Title: "Some Show S01E02 WEB-DL"}, - {Title: "Some Show S01E10 WEB-DL"}, - } - if got := inferRSSTotalEpisodes(items, sub, compileFilter("Some Show")); got != 10 { - t.Fatalf("rss inferred total = %d, want 10", got) - } -} - -func TestResolveSubscriptionTotalEpisodesPrefersTMDbOverTitleFallback(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/search/tv": - _, _ = w.Write([]byte(`{"results":[{"id":42,"name":"Some Show","first_air_date":"2026-01-01"}]}`)) - case "/tv/42": - _, _ = w.Write([]byte(`{"number_of_episodes":13}`)) - default: - http.NotFound(w, r) - } - })) - defer upstream.Close() - - cfg := &config.Config{} - cfg.Secrets.TMDbAPIKey = "test" - cfg.Secrets.TMDbAPIProxy = upstream.URL - tmdb := NewTMDbProvider(cfg, zap.NewNop(), nil) - svc := NewSubscriptionService(cfg, zap.NewNop(), nil, nil, nil, NewHub(zap.NewNop())) - svc.SetScraper(NewScraperService(cfg, zap.NewNop(), nil, tmdb, nil, nil, nil, NewHub(zap.NewNop()))) - - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - if got := svc.resolveSubscriptionTotalEpisodes(t.Context(), sub, 10); got != 13 { - t.Fatalf("resolved total = %d, want TMDb total 13", got) - } -} diff --git a/internal/service/subscription_exclude_rules.go b/internal/service/subscription_exclude_rules.go deleted file mode 100644 index 90cd4fe..0000000 --- a/internal/service/subscription_exclude_rules.go +++ /dev/null @@ -1,187 +0,0 @@ -package service - -import ( - "strings" - "unicode" -) - -// defaultExcludeWords 是默认过滤的「垃圾版本」排除清单,对所有订阅生效。 -// 拉丁词在 containsAnyExcludeToken 里按词边界匹配以避免子串误伤。 -const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample,hr,h&r,hit and run,hit&run,hit-and-run,禁转,禁止转载,禁下,禁止下载" - -// defaultCompatibilityExcludeWords 是面向自动订阅的兼容性默认排除清单。 -// 仅在用户未真正自定义排除词时启用,避免默认命中 DoVi/H.265/10bit/杜比音轨等版本。 -const defaultCompatibilityExcludeWords = "dovi,dv,dolby vision,dolby,杜比视界,杜比,h265,h.265,h-265,h_265,h 265,hevc,x265,10bit,10-bit,10 bit,hi10p,atmos,truehd,ddp,dd+,eac3" - -func containsAnyToken(titleFold, csv string) bool { - for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool { - return r == ',' || r == '/' || r == '|' || r == ';' || r == ',' - }) { - token = strings.TrimSpace(token) - if token != "" && strings.Contains(titleFold, token) { - return true - } - } - return false -} - -// containsAnyExcludeToken 用于排除词匹配:纯 ASCII 字母数字的词按词边界匹配(避免 "ts" -// 误伤 "tsukihime"、"cam" 误伤 "camp" 之类的子串误判),含 CJK/符号的词仍按子串匹配。 -func containsAnyExcludeToken(titleFold, csv string) bool { - for _, token := range excludeWordTokens(csv) { - if matchesExcludeToken(titleFold, token) { - return true - } - } - return false -} - -func excludeWordTokens(csv string) []string { - parts := make([]string, 0) - for _, token := range strings.FieldsFunc(strings.ToLower(csv), isExcludeSeparator) { - token = strings.TrimSpace(token) - if token == "" { - continue - } - parts = append(parts, token) - if shouldExpandDottedExcludeToken(token) { - parts = append(parts, dottedExcludeTokenParts(token)...) - } - } - return parts -} - -func isExcludeSeparator(r rune) bool { - switch r { - case ',', '/', '|', ';', ',', '、', '\n', '\r', '\t': - return true - default: - return false - } -} - -func shouldExpandDottedExcludeToken(token string) bool { - return strings.Count(token, ".") >= 2 -} - -func dottedExcludeTokenParts(token string) []string { - rawParts := strings.Split(token, ".") - parts := make([]string, 0, len(rawParts)) - for _, part := range rawParts { - part = strings.TrimSpace(part) - if len(part) < 2 || isDigitsOnly(part) { - continue - } - parts = append(parts, part) - } - return parts -} - -func isDigitsOnly(value string) bool { - if value == "" { - return false - } - for _, r := range value { - if !unicode.IsDigit(r) { - return false - } - } - return true -} - -func matchesExcludeToken(titleFold, token string) bool { - if token == "" { - return false - } - if isASCIIWordToken(token) { - return matchesWordBoundary(titleFold, token) || matchesReleasePrefixToken(titleFold, token) - } - return strings.Contains(titleFold, token) -} - -func isASCIIWordToken(token string) bool { - for _, r := range token { - if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r)) { - return false - } - } - return token != "" -} - -// matchesWordBoundary 判断 token 是否作为独立词出现在 title 中,词边界为「非字母数字」。 -func matchesWordBoundary(titleFold, token string) bool { - from := 0 - for { - idx := strings.Index(titleFold[from:], token) - if idx < 0 { - return false - } - start := from + idx - end := start + len(token) - leftOK := start == 0 || !isASCIIAlnumByte(titleFold[start-1]) - rightOK := end >= len(titleFold) || !isASCIIAlnumByte(titleFold[end]) - if leftOK && rightOK { - return true - } - from = start + 1 - if from >= len(titleFold) { - return false - } - } -} - -func matchesReleasePrefixToken(titleFold, token string) bool { - if !isReleasePrefixExcludeToken(token) { - return false - } - from := 0 - for { - idx := strings.Index(titleFold[from:], token) - if idx < 0 { - return false - } - start := from + idx - end := start + len(token) - leftOK := start == 0 || !isASCIIAlnumByte(titleFold[start-1]) - if leftOK && releasePrefixSuffixOK(token, titleFold[end:]) { - return true - } - from = start + 1 - if from >= len(titleFold) { - return false - } - } -} - -func isReleasePrefixExcludeToken(token string) bool { - switch token { - case "ddp", "dolby": - return true - default: - return false - } -} - -func releasePrefixSuffixOK(token, suffix string) bool { - if suffix == "" { - return false - } - switch token { - case "ddp": - return isASCIIDigitByte(suffix[0]) - case "dolby": - return strings.HasPrefix(suffix, "vision") || - strings.HasPrefix(suffix, "atmos") || - strings.HasPrefix(suffix, "digital") - default: - return false - } -} - -func isASCIIAlnumByte(b byte) bool { - return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || isASCIIDigitByte(b) -} - -func isASCIIDigitByte(b byte) bool { - return b >= '0' && b <= '9' -} diff --git a/internal/service/subscription_guid.go b/internal/service/subscription_guid.go deleted file mode 100644 index 08a474b..0000000 --- a/internal/service/subscription_guid.go +++ /dev/null @@ -1,69 +0,0 @@ -package service - -import ( - "fmt" - "net/url" - "strings" -) - -func stableRSSItemGUID(title, guid, link, enclosureURL string) string { - parts := []string{"rss", strings.ToLower(strings.TrimSpace(title))} - for _, raw := range []string{guid, enclosureURL, link} { - if key := stableDownloadURLKey(raw); key != "" { - parts = append(parts, key) - return strings.Join(parts, "|") - } - if raw = strings.TrimSpace(raw); raw != "" { - parts = append(parts, strings.ToLower(raw)) - return strings.Join(parts, "|") - } - } - return strings.Join(parts, "|") -} - -func stableSiteSearchGUID(item SearchResult, download string) string { - parts := []string{ - "site", - strings.ToLower(strings.TrimSpace(firstNonEmpty(item.SiteID, item.SiteName))), - strings.ToLower(strings.TrimSpace(item.Category)), - strings.ToLower(strings.TrimSpace(item.Title)), - fmt.Sprintf("%d", item.Size), - } - if key := stableDownloadURLKey(download); key != "" { - parts = append(parts, key) - } - return strings.Join(parts, "|") -} - -func stableDownloadURLKey(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - u, err := url.Parse(raw) - if err != nil { - return strings.ToLower(raw) - } - if strings.EqualFold(u.Scheme, "magnet") { - xt := strings.ToLower(strings.TrimSpace(u.Query().Get("xt"))) - if xt != "" { - return "magnet:" + xt - } - return strings.ToLower(raw) - } - if u.Host == "" { - return strings.ToLower(raw) - } - q := u.Query() - kept := make([]string, 0, 4) - for _, key := range []string{"id", "tid", "torrent", "torrent_id", "torrentid", "hash", "info_hash"} { - if value := strings.TrimSpace(q.Get(key)); value != "" { - kept = append(kept, key+"="+strings.ToLower(value)) - } - } - base := strings.ToLower(strings.TrimRight(u.Host, "/") + "/" + strings.TrimLeft(u.Path, "/")) - if len(kept) > 0 { - return base + "?" + strings.Join(kept, "&") - } - return base -} diff --git a/internal/service/subscription_identity.go b/internal/service/subscription_identity.go deleted file mode 100644 index c11dc6b..0000000 --- a/internal/service/subscription_identity.go +++ /dev/null @@ -1,89 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -var ErrSubscriptionAlreadyExists = errors.New("subscription already exists") - -type SubscriptionAlreadyExistsError struct { - ExistingID string -} - -func (e *SubscriptionAlreadyExistsError) Error() string { - if e == nil || e.ExistingID == "" { - return ErrSubscriptionAlreadyExists.Error() - } - return fmt.Sprintf("%s: %s", ErrSubscriptionAlreadyExists, e.ExistingID) -} - -func (e *SubscriptionAlreadyExistsError) Unwrap() error { - return ErrSubscriptionAlreadyExists -} - -func newSubscriptionAlreadyExistsError(existingID string) error { - return &SubscriptionAlreadyExistsError{ExistingID: existingID} -} - -func SubscriptionAlreadyExistsID(err error) string { - var conflict *SubscriptionAlreadyExistsError - if errors.As(err, &conflict) && conflict != nil { - return conflict.ExistingID - } - return "" -} - -func (s *SubscriptionService) subscriptionDuplicate(ctx context.Context, sub *model.Subscription, excludeID string) (*model.Subscription, error) { - if s == nil || s.repo == nil || s.repo.Subscription == nil || sub == nil { - return nil, nil - } - return s.repo.Subscription.FindActiveByIdentity(ctx, sub.UserID, sub.IdentityKey, excludeID) -} - -// Update applies API patch fields while recomputing the functional identity. -// The database partial unique index remains the final concurrency guard. -func (s *SubscriptionService) Update(ctx context.Context, id string, updates map[string]any) error { - if s == nil || s.repo == nil || s.repo.DB == nil { - return errors.New("subscription service unavailable") - } - var sub model.Subscription - if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&sub).Error; err != nil { - return err - } - raw, err := json.Marshal(updates) - if err != nil { - return err - } - if err := json.Unmarshal(raw, &sub); err != nil { - return err - } - if sub.Name == "" || sub.FeedURL == "" { - return errors.New("name and feed_url required") - } - normalizeSubscriptionDefaults(&sub) - model.RefreshSubscriptionIdentity(&sub) - if duplicate, err := s.subscriptionDuplicate(ctx, &sub, sub.ID); err != nil { - return err - } else if duplicate != nil { - return newSubscriptionAlreadyExistsError(duplicate.ID) - } - - updates["search_mode"] = sub.SearchMode - updates["resolution"] = sub.Resolution - updates["wash_priority"] = sub.WashPriority - updates["priority"] = sub.Priority - updates["identity_key"] = sub.IdentityKey - if err := s.repo.DB.WithContext(ctx).Model(&model.Subscription{}). - Where("id = ?", sub.ID).Updates(updates).Error; err != nil { - if duplicate, lookupErr := s.subscriptionDuplicate(ctx, &sub, sub.ID); lookupErr == nil && duplicate != nil { - return newSubscriptionAlreadyExistsError(duplicate.ID) - } - return err - } - return nil -} diff --git a/internal/service/subscription_identity_test.go b/internal/service/subscription_identity_test.go deleted file mode 100644 index ca9bcb6..0000000 --- a/internal/service/subscription_identity_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package service - -import ( - "errors" - "path/filepath" - "sync" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/database" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSubscriptionCreateRejectsConcurrentDuplicateRules(t *testing.T) { - cfg := &config.Config{} - cfg.Database.Type = "sqlite" - cfg.Database.DBPath = filepath.Join(t.TempDir(), "subscriptions.db") - cfg.Database.WALMode = true - cfg.Database.BusyTimeout = 5000 - cfg.Database.MaxOpenConns = 4 - db, err := database.Open(cfg, zap.NewNop()) - if err != nil { - t.Fatal(err) - } - if sqlDB, dbErr := db.DB(); dbErr == nil { - t.Cleanup(func() { _ = sqlDB.Close() }) - } - if err := database.AutoMigrate(db); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - svc := NewSubscriptionService(cfg, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) - - start := make(chan struct{}) - results := make(chan error, 2) - var wg sync.WaitGroup - for i := 0; i < 2; i++ { - wg.Add(1) - go func() { - defer wg.Done() - <-start - results <- svc.Create(t.Context(), &model.Subscription{ - UserID: "user-1", - Name: "Example Show", - FeedURL: "site-search://search?keyword=Example+Show", - Filter: "Example Show", - MediaType: "tv", - Resolution: "1080p", - Enabled: true, - }) - }() - } - close(start) - wg.Wait() - close(results) - - var created, conflicts int - for createErr := range results { - switch { - case createErr == nil: - created++ - case errors.Is(createErr, ErrSubscriptionAlreadyExists): - conflicts++ - default: - t.Fatalf("unexpected create error: %v", createErr) - } - } - if created != 1 || conflicts != 1 { - t.Fatalf("created=%d conflicts=%d, want 1/1", created, conflicts) - } - var count int64 - if err := db.Model(&model.Subscription{}).Where("archived_at IS NULL").Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("active subscription count = %d, want 1", count) - } -} - -func TestSubscriptionUpdateAndRestoreReturnExistingConflict(t *testing.T) { - db := newServiceTestDB(t, &model.Subscription{}) - if err := database.AutoMigrate(db); err != nil { - t.Fatal(err) - } - repos := repository.New(db) - svc := NewSubscriptionService(&config.Config{}, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) - first := &model.Subscription{UserID: "user-1", Name: "Example", FeedURL: "rss://example", Filter: "Example", Resolution: "1080p", Enabled: true} - second := &model.Subscription{UserID: "user-1", Name: "Example", FeedURL: "rss://example", Filter: "Example", Resolution: "2160p", Enabled: true} - if err := svc.Create(t.Context(), first); err != nil { - t.Fatal(err) - } - if err := svc.Create(t.Context(), second); err != nil { - t.Fatal(err) - } - if err := svc.Update(t.Context(), second.ID, map[string]any{"resolution": "1080p"}); !errors.Is(err, ErrSubscriptionAlreadyExists) || SubscriptionAlreadyExistsID(err) != first.ID { - t.Fatalf("update error = %v, want conflict with %s", err, first.ID) - } - - archivedAt := time.Now() - archived := *first - archived.ID = "" - archived.ArchivedAt = &archivedAt - archived.Enabled = false - if err := repos.Subscription.Create(t.Context(), &archived); err != nil { - t.Fatal(err) - } - if _, err := svc.Restore(t.Context(), archived.ID); !errors.Is(err, ErrSubscriptionAlreadyExists) || SubscriptionAlreadyExistsID(err) != first.ID { - t.Fatalf("restore error = %v, want conflict with %s", err, first.ID) - } -} diff --git a/internal/service/subscription_logging.go b/internal/service/subscription_logging.go deleted file mode 100644 index 85973a4..0000000 --- a/internal/service/subscription_logging.go +++ /dev/null @@ -1,54 +0,0 @@ -package service - -import ( - "net/url" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func subscriptionRunLogFields(sub *model.Subscription) []zap.Field { - fields := []zap.Field{} - if sub == nil { - return fields - } - return append(fields, - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.String("feed_kind", subscriptionFeedKind(sub.FeedURL)), - zap.String("filter", sub.Filter), - zap.String("media_type", sub.MediaType), - zap.String("media_category", sub.MediaCategory), - zap.String("search_mode", sub.SearchMode), - zap.Bool("enabled", sub.Enabled), - zap.Bool("wash_enabled", sub.WashEnabled), - zap.String("wash_priority", sub.WashPriority), - zap.Int("total_episodes", sub.TotalEpisodes), - ) -} - -func appendSubscriptionRunResultFields(fields []zap.Field, queued int, started time.Time) []zap.Field { - return append(fields, - zap.Int("queued", queued), - zap.Int64("duration_ms", time.Since(started).Milliseconds()), - ) -} - -func subscriptionFeedKind(feedURL string) string { - raw := strings.TrimSpace(feedURL) - if raw == "" { - return "empty" - } - lower := strings.ToLower(raw) - if strings.HasPrefix(lower, "site-search://") { - return "site-search" - } - parsed, err := url.Parse(raw) - if err == nil && parsed.Scheme != "" { - return parsed.Scheme - } - return "unknown" -} diff --git a/internal/service/subscription_metadata_prepare.go b/internal/service/subscription_metadata_prepare.go deleted file mode 100644 index 91cd7ef..0000000 --- a/internal/service/subscription_metadata_prepare.go +++ /dev/null @@ -1,110 +0,0 @@ -package service - -import ( - "context" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *SubscriptionService) prepareSubscriptionForRun(ctx context.Context, sub *model.Subscription) { - if s == nil || sub == nil { - return - } - normalizeSubscriptionDefaults(sub) - updates := map[string]any{} - s.fillSubscriptionRunMetadata(ctx, sub, updates) - if identityKey := model.SubscriptionIdentityKey(sub); identityKey != sub.IdentityKey { - sub.IdentityKey = identityKey - updates["identity_key"] = identityKey - } - if len(updates) > 0 && s.repo != nil && s.repo.DB != nil { - if err := s.repo.DB.WithContext(ctx).Model(&model.Subscription{}).Where("id = ?", sub.ID).Updates(updates).Error; err != nil && s.log != nil { - s.log.Debug("subscription metadata prepare persist failed", zap.String("id", sub.ID), zap.Error(err)) - } - } -} - -func (s *SubscriptionService) fillSubscriptionRunMetadata(ctx context.Context, sub *model.Subscription, updates map[string]any) { - if sub == nil { - return - } - if needsSubscriptionMetadataLookup(sub) { - query := subscriptionMetadataPrepareQuery(sub) - lookupCtx, cancel := context.WithTimeout(ctx, 12*time.Second) - defer cancel() - if match := s.lookupSubscriptionMetadata(lookupCtx, strings.TrimSpace(sub.MediaType), query, sub); match != nil { - applySubscriptionMetadataMatch(sub, match, updates) - } - } - if isSubscriptionSeriesType(strings.TrimSpace(sub.MediaType)) && sub.TotalEpisodes <= 0 { - if total := s.resolveSubscriptionTotalEpisodes(ctx, sub, 0); total > 0 { - sub.TotalEpisodes = total - updates["total_episodes"] = total - } - } -} - -func needsSubscriptionMetadataLookup(sub *model.Subscription) bool { - if sub == nil { - return false - } - return strings.TrimSpace(sub.MediaType) == "" || - strings.TrimSpace(sub.OriginalName) == "" || - sub.Year <= 0 -} - -func subscriptionMetadataPrepareQuery(sub *model.Subscription) string { - if sub == nil { - return "" - } - if value := strings.TrimSpace(sub.Filter); value != "" { - return value - } - return strings.TrimSpace(sub.Name) -} - -func applySubscriptionMetadataMatch(sub *model.Subscription, match *Match, updates map[string]any) { - if sub == nil || match == nil { - return - } - if strings.TrimSpace(sub.MediaType) == "" { - if mediaType := normalizeMetadataMatchSubscriptionType(match); mediaType != "" { - sub.MediaType = mediaType - updates["media_type"] = mediaType - } - } - if strings.TrimSpace(sub.OriginalName) == "" { - if value := strings.TrimSpace(match.OriginalName); value != "" { - sub.OriginalName = value - updates["original_name"] = value - } - } - if sub.Year <= 0 && match.Year > 0 { - sub.Year = match.Year - updates["year"] = match.Year - } -} - -func normalizeMetadataMatchSubscriptionType(match *Match) string { - if match == nil { - return "" - } - switch normalizeOrganizeMediaType(match.MediaType) { - case "movie": - return "movie" - case "tv": - return "tv" - case "anime": - return "anime" - case "variety": - return "variety" - case "adult": - return "adult" - default: - return "" - } -} diff --git a/internal/service/subscription_metadata_prepare_test.go b/internal/service/subscription_metadata_prepare_test.go deleted file mode 100644 index 6aeeecb..0000000 --- a/internal/service/subscription_metadata_prepare_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package service - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestPrepareSubscriptionForRunFillsSeriesMetadata(t *testing.T) { - var searchedTV bool - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/search/tv": - searchedTV = true - _ = json.NewEncoder(w).Encode(map[string]any{ - "results": []map[string]any{{ - "id": 12345, - "name": "南部档案", - "original_name": "Archives The Nanyang Mystery", - "original_language": "zh", - "origin_country": []string{"CN"}, - "first_air_date": "2026-01-01", - }}, - }) - case "/tv/12345": - _ = json.NewEncoder(w).Encode(map[string]any{"number_of_episodes": 33}) - default: - http.NotFound(w, r) - } - })) - defer upstream.Close() - - cfg := &config.Config{} - cfg.Secrets.TMDbAPIKey = "test-key" - cfg.Secrets.TMDbAPIProxy = upstream.URL - repos := repository.New(newServiceTestDB(t, &model.Subscription{})) - scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop())) - svc := NewSubscriptionService(cfg, zap.NewNop(), repos, nil, nil, nil) - svc.SetScraper(scraper) - - sub := model.Subscription{Name: "南部档案 自动订阅", FeedURL: "site-search://search?keyword=南部档案", Filter: "南部档案 2026", Enabled: true} - if err := repos.Subscription.Create(t.Context(), &sub); err != nil { - t.Fatal(err) - } - - svc.prepareSubscriptionForRun(t.Context(), &sub) - if !searchedTV { - t.Fatal("blank media type subscription should try TV metadata before defaulting to movie") - } - if sub.MediaType != "tv" || sub.OriginalName != "Archives The Nanyang Mystery" || sub.Year != 2026 || sub.TotalEpisodes != 33 { - t.Fatalf("prepared subscription = %#v, want tv metadata with total episodes", sub) - } - - var stored model.Subscription - if err := repos.DB.First(&stored, "id = ?", sub.ID).Error; err != nil { - t.Fatal(err) - } - if stored.MediaType != "tv" || stored.OriginalName != "Archives The Nanyang Mystery" || stored.Year != 2026 || stored.TotalEpisodes != 33 { - t.Fatalf("stored subscription = %#v, want persisted metadata", stored) - } -} - -func TestSubscriptionMetadataLibraryTypesSearchesTVForBlankType(t *testing.T) { - got := subscriptionMetadataLibraryTypes("", "南部档案 2026") - if len(got) < 2 || got[0] != "tv" || got[1] != "movie" { - t.Fatalf("library types = %#v, want tv before movie for blank subscription type", got) - } -} diff --git a/internal/service/subscription_notification.go b/internal/service/subscription_notification.go deleted file mode 100644 index 5a55db7..0000000 --- a/internal/service/subscription_notification.go +++ /dev/null @@ -1,73 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *SubscriptionService) notifySubscriptionHit(sub *model.Subscription, queued int, resources []string) { - if s == nil || s.notify == nil || sub == nil || queued <= 0 { - return - } - body := fmt.Sprintf("订阅:%s\n新增资源:%d", sub.Name, queued) - if len(resources) > 0 { - body += "\n资源:\n- " + strings.Join(resources, "\n- ") - } - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - data := map[string]interface{}{} - if strings.TrimSpace(sub.PosterURL) != "" { - data["poster_url"] = sub.PosterURL - } - if strings.TrimSpace(sub.BackdropURL) != "" { - data["backdrop_url"] = sub.BackdropURL - } - if strings.TrimSpace(sub.MediaType) != "" { - data["media_type"] = sub.MediaType - } - if strings.TrimSpace(sub.MediaCategory) != "" { - data["media_category"] = sub.MediaCategory - } - // 补充媒体通知模板(formatTelegramMediaNotification)所需字段:片名 / 原名 / - // 语言 / 年份 / 评分 / 类型 / 简介 / 外链 / 资源标题(供模板提取季集 + 版本)。 - // 仅填现成可用的,缺失项模板会自动略过。 - if strings.TrimSpace(sub.Name) != "" { - data["title"] = sub.Name - } - if strings.TrimSpace(sub.OriginalName) != "" { - data["original_title"] = sub.OriginalName - } - if strings.TrimSpace(sub.OriginalLanguage) != "" { - data["original_language"] = sub.OriginalLanguage - } - if sub.Year > 0 { - data["year"] = sub.Year - } - if sub.Rating > 0 { - data["rating"] = sub.Rating - } - if strings.TrimSpace(sub.Genres) != "" { - data["genres"] = sub.Genres - } - if strings.TrimSpace(sub.Overview) != "" { - data["overview"] = sub.Overview - } - if id := strings.TrimSpace(sub.IMDBID); id != "" { - data["imdb_url"] = "https://www.imdb.com/title/" + id + "/" - } - if len(resources) > 0 { - data["resource_title"] = resources[0] - } - s.notify.BroadcastEvent(ctx, NotifyEvent{ - Type: EventSubscriptionHit, - Title: "MediaStationGo 订阅命中新资源", - Message: body, - Data: data, - }) - }() -} diff --git a/internal/service/subscription_pack.go b/internal/service/subscription_pack.go deleted file mode 100644 index 02bf2f4..0000000 --- a/internal/service/subscription_pack.go +++ /dev/null @@ -1,23 +0,0 @@ -package service - -import ( - "regexp" - "strings" -) - -var ( - seriesPackRE = regexp.MustCompile(`(?i)(complete|batch|合集|全集|全\s*\d+\s*[集话話期]|整季|全季|s\d{1,2}\s*(?:complete|batch|pack)|season\s*\d{1,2}\s*(?:complete|batch|pack)|s\d{1,2}e\d{1,3}\s*[-~–—]\s*(?:s\d{1,2})?e?\d{1,3}|第\s*\d+\s*[-~–—]\s*\d+\s*[集话話期])`) - seasonOnlyRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\s*\d{1,2}(?:[\s._-]|$)|第\s*\d+\s*季`) -) - -func isSeriesPackTitle(title string) bool { - title = strings.TrimSpace(title) - if title == "" { - return false - } - if seriesPackRE.MatchString(title) { - return true - } - _, episode := ParseEpisode(title) - return episode == 0 && seasonOnlyRE.MatchString(title) -} diff --git a/internal/service/subscription_pending_availability_test.go b/internal/service/subscription_pending_availability_test.go deleted file mode 100644 index 5bb621f..0000000 --- a/internal/service/subscription_pending_availability_test.go +++ /dev/null @@ -1,267 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSubscriptionPendingDownloadAvailabilitySkipsUnorganizedEpisodes(t *testing.T) { - root := t.TempDir() - seasonDir := filepath.Join(root, "间谍过家家", "Season 01") - if err := os.MkdirAll(seasonDir, 0o755); err != nil { - t.Fatal(err) - } - for _, name := range []string{ - "间谍过家家 - S01E01.mkv", - "间谍过家家 - S01E02.mkv.!qB", - } { - if err := os.WriteFile(filepath.Join(seasonDir, name), []byte("video"), 0o644); err != nil { - t.Fatal(err) - } - } - - sub := &model.Subscription{ - Name: "间谍过家家 自动订阅", - Filter: "间谍过家家", - MediaType: "tv", - SavePath: root, - TotalEpisodes: 3, - } - svc := NewSubscriptionService(nil, nil, nil, nil, nil, nil) - availability := svc.pendingDownloadAvailability(t.Context(), sub) - if availability.DownloadedEpisodes != 2 { - t.Fatalf("downloaded episodes = %d, want 2", availability.DownloadedEpisodes) - } - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 1)]; !ok { - t.Fatalf("missing pending E01 key: %#v", availability.ExistingEpisodeKeys) - } - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 2)]; !ok { - t.Fatalf("missing pending E02 key: %#v", availability.ExistingEpisodeKeys) - } - - results := []SearchResult{ - {Title: "间谍过家家 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 100}, - {Title: "间谍过家家 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 90}, - {Title: "间谍过家家 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 80}, - {Title: "间谍过家家 S01E03 1080p", DownloadURL: "https://pt/download/3", Seeders: 70}, - } - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Episode != 3 { - t.Fatalf("selected %#v, want only not-yet-downloaded episode 3", got) - } - if !svc.downloadPathHasCandidate(t.Context(), sub, "间谍过家家 S01E02 1080p", root) { - t.Fatal("expected existing pending E02 file to be detected") - } - if svc.downloadPathHasCandidate(t.Context(), sub, "间谍过家家 S01E03 1080p", root) { - t.Fatal("did not expect missing E03 to be detected") - } -} - -func TestSubscriptionPendingDownloadAvailabilityIncludesQueuedTasks(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}) - repos := repository.New(db) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - Source: "qbittorrent", - URL: "magnet:?xt=urn:btih:2222222222222222222222222222222222222222", - Title: "间谍过家家 S01E02 1080p", - SavePath: "/downloads/tv", - Status: "queued", - }); err != nil { - t.Fatal(err) - } - svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil) - sub := &model.Subscription{ - Name: "间谍过家家 自动订阅", - Filter: "间谍过家家", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 3, - } - - availability := svc.pendingDownloadAvailability(t.Context(), sub) - if availability.DownloadedEpisodes != 1 { - t.Fatalf("downloaded episodes = %d, want 1", availability.DownloadedEpisodes) - } - if availability.InLibrary { - t.Fatal("queued download should not be reported as already in library") - } - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 2)]; !ok { - t.Fatalf("missing queued E02 key: %#v", availability.ExistingEpisodeKeys) - } - - results := []SearchResult{ - {Title: "间谍过家家 S01E02 1080p WEB-DL", DownloadURL: "https://pt/download/2", Seeders: 80}, - {Title: "间谍过家家 S01E03 1080p WEB-DL", DownloadURL: "https://pt/download/3", Seeders: 70}, - } - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Episode != 3 { - t.Fatalf("selected %#v, want only not-yet-downloaded episode 3", got) - } -} - -func TestSubscriptionPendingDownloadAvailabilityUsesOriginalNameAliasForTasks(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}) - repos := repository.New(db) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - Source: "qbittorrent", - URL: "https://pt/download/7-8", - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - SavePath: "/downloads/tv", - Status: "queued", - }); err != nil { - t.Fatal(err) - } - svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil) - sub := &model.Subscription{ - Name: "南部档案 自动订阅", - Filter: "南部档案 2026", - OriginalName: "Archives The Nanyang Mystery", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 33, - } - - availability := svc.pendingDownloadAvailability(t.Context(), sub) - if availability.DownloadedEpisodes != 2 { - t.Fatalf("downloaded episodes = %d, want 2", availability.DownloadedEpisodes) - } - for _, episode := range []int{7, 8} { - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok { - t.Fatalf("missing pending E%02d key: %#v", episode, availability.ExistingEpisodeKeys) - } - } - got := selectSiteSearchCandidates([]SearchResult{ - {Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/7-8", Seeders: 80}, - }, sub, map[string]struct{}{}, availability) - if len(got) != 0 { - t.Fatalf("selected %#v, want existing alias range to satisfy E07-E08", got) - } -} - -func TestSubscriptionPendingDownloadAvailabilityUsesOriginalNameAliasForLiveTorrents(t *testing.T) { - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL","save_path":"/downloads/tv","state":"downloading","progress":0.3}]`)) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - downloads.qb.Configure(QBitConfig{BaseURL: qb.URL, Username: "admin", Password: "admin"}) - svc := NewSubscriptionService(nil, nil, repos, downloads, nil, nil) - sub := &model.Subscription{ - Name: "南部档案 自动订阅", - Filter: "南部档案 2026", - OriginalName: "Archives The Nanyang Mystery", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 33, - } - - availability := svc.pendingDownloadAvailability(t.Context(), sub) - if availability.DownloadedEpisodes != 5 { - t.Fatalf("downloaded episodes = %d, want 5", availability.DownloadedEpisodes) - } - for _, episode := range []int{29, 30, 31, 32, 33} { - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok { - t.Fatalf("missing live E%02d key: %#v", episode, availability.ExistingEpisodeKeys) - } - } - got := selectSiteSearchCandidates([]SearchResult{ - {Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/29-33", Seeders: 80}, - }, sub, map[string]struct{}{}, availability) - if len(got) != 0 { - t.Fatalf("selected %#v, want existing live alias range to satisfy E29-E33", got) - } -} - -func TestSubscriptionPendingDownloadAvailabilityIncludesLinkedAliasTask(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}) - repos := repository.New(db) - sub := &model.Subscription{ - Base: model.Base{ID: "sub-qiao-chu"}, - Name: "翘楚 S01E06 自动订阅", - Filter: "翘楚 S01E06", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 24, - } - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - SubscriptionID: sub.ID, - Source: "qbittorrent", - URL: "https://pt/download/21", - Title: "Ashes to Crown 2026 S01E21 2160p WEB-DL", - SavePath: "/downloads/tv", - Status: "queued", - }); err != nil { - t.Fatal(err) - } - svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil) - - availability := svc.pendingDownloadAvailability(t.Context(), sub) - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 21)]; !ok { - t.Fatalf("missing linked alias E21 key: %#v", availability.ExistingEpisodeKeys) - } - got := selectSiteSearchCandidates([]SearchResult{ - {Title: "Ashes to Crown 2026 S01E21 2160p WEB-DL", DownloadURL: "https://pt/download/21", Seeders: 80}, - }, sub, map[string]struct{}{}, availability) - if len(got) != 0 { - t.Fatalf("selected %#v, want linked alias task to satisfy E21", got) - } -} - -func TestSubscriptionPendingDownloadAvailabilitySkipsStaleTaskMissingFromQB(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}) - repos := repository.New(db) - sub := &model.Subscription{ - Base: model.Base{ID: "sub-nanyang"}, - Name: "南部档案 自动订阅", - Filter: "南部档案", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 33, - } - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - SubscriptionID: sub.ID, - Source: "qbittorrent", - URL: "https://pt/download/stale", - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - SavePath: "/downloads/tv", - Status: "queued", - Progress: 0, - }); err != nil { - t.Fatal(err) - } - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - downloads.recordLiveTorrentSnapshot(nil) - svc := NewSubscriptionService(nil, nil, repos, downloads, nil, nil) - - availability := svc.pendingDownloadAvailability(t.Context(), sub) - if availability.DownloadedEpisodes != 0 { - t.Fatalf("downloaded episodes = %d, want stale task not counted", availability.DownloadedEpisodes) - } - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 7)]; ok { - t.Fatalf("stale E07 task should not count as available: %#v", availability.ExistingEpisodeKeys) - } - got := selectSiteSearchCandidates([]SearchResult{ - {Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/7-8", Seeders: 80}, - }, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Episode != 7 { - t.Fatalf("selected %#v, want stale missing range to be eligible", got) - } -} diff --git a/internal/service/subscription_planner.go b/internal/service/subscription_planner.go deleted file mode 100644 index b1ebf7f..0000000 --- a/internal/service/subscription_planner.go +++ /dev/null @@ -1,85 +0,0 @@ -// Package service — subscription planning and release candidate selection. -package service - -import "github.com/ShukeBta/MediaStationGo/internal/model" - -type siteSearchCandidate struct { - Item SearchResult - Download string - GUID string - Season int - Episode int - Episodes []int - Pack bool - Score int -} - -type siteSearchSelectionStats struct { - Total int - QueryMismatch int - QueryMismatchExamples []string - RelaxedQueryMatch int - RuleMismatch int - MissingDownload int - Seen int - Prepared int - Selected int - LocalAlreadySatisfied bool - LocalSeriesPackPresent bool - SeriesComplete bool - ExistingEpisodeSkipped int - NotMissingEpisodeSkipped int - NoEpisodeSkipped int - PackFallbackAvailable bool - PackFallbackUsed bool -} - -// SubscriptionPlanner owns release selection decisions for subscriptions: -// rule matching, candidate scoring, and filtering against known availability. -type SubscriptionPlanner struct{} - -func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, availability ...LocalAvailability) []siteSearchCandidate { - return SubscriptionPlanner{}.SelectSiteSearchCandidates(results, sub, seenSet, availability...) -} - -func (SubscriptionPlanner) SelectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, availability ...LocalAvailability) []siteSearchCandidate { - if sub == nil { - return nil - } - if seenSet == nil { - seenSet = map[string]struct{}{} - } - local := LocalAvailability{} - if len(availability) > 0 { - local = availability[0] - } - candidates, _ := selectSiteSearchCandidatesWithStats(results, sub, seenSet, local) - return candidates -} - -func selectSiteSearchCandidatesWithAvailability(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, local LocalAvailability) []siteSearchCandidate { - candidates, _ := selectSiteSearchCandidatesWithStats(results, sub, seenSet, local) - return candidates -} - -func selectSiteSearchCandidatesWithStats(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, local LocalAvailability) ([]siteSearchCandidate, siteSearchSelectionStats) { - stats := siteSearchSelectionStats{Total: len(results)} - if sub == nil { - return nil, stats - } - if seenSet == nil { - seenSet = map[string]struct{}{} - } - candidates := collectSiteSearchCandidates(results, sub, seenSet, false, &stats) - if len(candidates) == 0 && shouldRelaxSiteSearchQueryMatch(sub, local) && stats.QueryMismatch > 0 { - relaxedStats := siteSearchSelectionStats{Total: len(results)} - candidates = collectSiteSearchCandidates(results, sub, seenSet, true, &relaxedStats) - stats.RuleMismatch = relaxedStats.RuleMismatch - stats.MissingDownload = relaxedStats.MissingDownload - stats.Seen = relaxedStats.Seen - stats.Prepared = relaxedStats.Prepared - stats.RelaxedQueryMatch = relaxedStats.RelaxedQueryMatch - } - selected := selectPreparedSubscriptionCandidatesWithStats(candidates, sub, local, &stats) - return selected, stats -} diff --git a/internal/service/subscription_rss.go b/internal/service/subscription_rss.go deleted file mode 100644 index f765551..0000000 --- a/internal/service/subscription_rss.go +++ /dev/null @@ -1,79 +0,0 @@ -package service - -import ( - "context" - "encoding/xml" - "fmt" - "io" - "net/http" - "regexp" - "strings" -) - -// rssFeed is the minimal RSS subset we need to decode. -type rssFeed struct { - XMLName xml.Name `xml:"rss"` - Channel struct { - Items []rssItem `xml:"item"` - } `xml:"channel"` -} - -type rssItem struct { - Title string `xml:"title"` - Link string `xml:"link"` - GUID string `xml:"guid"` - Description string `xml:"description"` - Enclosure struct { - URL string `xml:"url,attr"` - } `xml:"enclosure"` -} - -func (s *SubscriptionService) fetch(ctx context.Context, feedURL string) (*rssFeed, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil) - if err != nil { - return nil, err - } - req.Header.Set("User-Agent", "MediaStationGo/0.1") - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("rss %s: %d", feedURL, resp.StatusCode) - } - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - var f rssFeed - if err := xml.Unmarshal(body, &f); err != nil { - return nil, err - } - return &f, nil -} - -func compileFilter(pat string) *regexp.Regexp { - pat = strings.TrimSpace(pat) - if pat == "" { - return nil - } - if r, err := regexp.Compile("(?i)" + pat); err == nil { - return r - } - return nil -} - -func splitNonEmpty(s string) []string { - if s == "" { - return nil - } - out := make([]string, 0) - for _, p := range strings.Split(s, "\n") { - p = strings.TrimSpace(p) - if p != "" { - out = append(out, p) - } - } - return out -} diff --git a/internal/service/subscription_rss_defaults_test.go b/internal/service/subscription_rss_defaults_test.go deleted file mode 100644 index c4e15d8..0000000 --- a/internal/service/subscription_rss_defaults_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package service - -import ( - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestSelectRSSSubscriptionCandidatesDefaultKeepsOnlyBestWebDLPerEpisode(t *testing.T) { - sub := &model.Subscription{Name: "House of the Dragon 自动订阅", Filter: "House of the Dragon", MediaType: "tv"} - items := []rssItem{ - rssSubscriptionItem("House of the Dragon S03E01 1080p WEB-DL H264 AAC", "https://pt/download/e01-1080"), - rssSubscriptionItem("House of the Dragon S03E01 2160p WEB-DL H264 AAC", "https://pt/download/e01-2160"), - rssSubscriptionItem("House of the Dragon S03E01 720p WEBRip H264 AAC", "https://pt/download/e01-720"), - rssSubscriptionItem("House of the Dragon S03E02 1080p HDTV H264 AAC", "https://pt/download/e02-hdtv"), - rssSubscriptionItem("House of the Dragon S03E02 1080p WEB-DL H264 AAC", "https://pt/download/e02-webdl"), - } - - got := selectRSSSubscriptionCandidates(items, sub, compileFilter(sub.Filter), nil, LocalAvailability{}) - if len(got) != 2 { - t.Fatalf("selected %d candidates, want one best release per episode", len(got)) - } - if got[0].Download != "https://pt/download/e01-2160" { - t.Fatalf("episode 1 selected %q, want 2160p WEB-DL", got[0].Download) - } - if got[1].Download != "https://pt/download/e02-webdl" { - t.Fatalf("episode 2 selected %q, want WEB-DL over HDTV", got[1].Download) - } -} - -func rssSubscriptionItem(title, download string) rssItem { - item := rssItem{Title: title, Link: download, GUID: download} - item.Enclosure.URL = download - return item -} diff --git a/internal/service/subscription_rss_run.go b/internal/service/subscription_rss_run.go deleted file mode 100644 index e9158cb..0000000 --- a/internal/service/subscription_rss_run.go +++ /dev/null @@ -1,188 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -type rssSubscriptionRunState struct { - seen []string - seenSet map[string]struct{} - availability LocalAvailability - availabilityQuery string - washOff bool -} - -func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscription) (queued int, err error) { - s.prepareSubscriptionForRun(ctx, sub) - started := time.Now() - if s.log != nil { - s.log.Info("subscription run started", subscriptionRunLogFields(sub)...) - defer func() { - fields := appendSubscriptionRunResultFields(subscriptionRunLogFields(sub), queued, started) - if err != nil { - fields = append(fields, zap.Error(err)) - s.log.Warn("subscription run finished with error", fields...) - return - } - s.log.Info("subscription run finished", fields...) - }() - } - if strings.HasPrefix(strings.ToLower(strings.TrimSpace(sub.FeedURL)), "site-search://") { - return s.runSiteSearch(ctx, sub) - } - - feed, err := s.fetch(ctx, sub.FeedURL) - if err != nil { - return 0, err - } - - filter := compileFilter(sub.Filter) - guidKey := fmt.Sprintf("subscription.%s.seen", sub.ID) - seenRaw, _ := s.repo.Setting.Get(ctx, guidKey) - seen := splitNonEmpty(seenRaw) - seenSet := make(map[string]struct{}, len(seen)) - for _, g := range seen { - seenSet[g] = struct{}{} - } - - s.updateSubscriptionTotalEpisodes(ctx, sub, s.resolveSubscriptionTotalEpisodes(ctx, sub, inferRSSTotalEpisodes(feed.Channel.Items, sub, filter))) - // RSS 和站点搜索统一使用候选规划:先按订阅规则过滤,再按洗版优先级/集数去重择优。 - // 非洗版订阅成功下载一次即满足,媒体库与下载中任务会作为可用性输入避免重复下载。 - runState := &rssSubscriptionRunState{ - seen: seen, - seenSet: seenSet, - availability: mergeLocalAvailability(SubscriptionLocalAvailability(ctx, s.repo, sub), s.pendingDownloadAvailability(ctx, sub)), - availabilityQuery: availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), - washOff: !subscriptionAllowsWash(sub), - } - candidates := selectRSSSubscriptionCandidates(feed.Channel.Items, sub, filter, runState.seenSet, runState.availability) - queued = s.enqueueRSSSubscriptionCandidates(ctx, sub, candidates, runState) - s.finishRSSSubscriptionRun(ctx, sub, guidKey, runState, queued) - return queued, nil -} - -func (s *SubscriptionService) enqueueRSSSubscriptionCandidates(ctx context.Context, sub *model.Subscription, candidates []siteSearchCandidate, state *rssSubscriptionRunState) int { - queued := 0 - for _, candidate := range candidates { - if s.enqueueRSSSubscriptionCandidate(ctx, sub, candidate, state) { - queued++ - } - } - return queued -} - -func (s *SubscriptionService) enqueueRSSSubscriptionCandidate(ctx context.Context, sub *model.Subscription, candidate siteSearchCandidate, state *rssSubscriptionRunState) bool { - item := candidate.Item - mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, item.Title, "") - savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory) - if s.downloadPathHasCandidate(ctx, sub, item.Title, savePath) { - state.markTitleAvailable(item.Title) - return false - } - if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, candidate.Download, savePath, DownloadTaskMeta{ - SubscriptionID: sub.ID, - Title: firstNonEmpty(item.Title, sub.Name), - PosterURL: sub.PosterURL, - BackdropURL: sub.BackdropURL, - Overview: sub.Overview, - MediaType: mediaType, - MediaCategory: mediaCategory, - AllowExistingLibrary: subscriptionAllowsWash(sub), - }); err != nil { - if IsDownloadDedupError(err) { - if s.subscriptionCandidateConfirmedAvailable(ctx, sub, candidate) { - state.markCandidateAvailable(candidate) - return false - } - if s.log != nil { - s.log.Info("subscription dedup candidate not confirmed available", - zap.String("title", item.Title), - zap.String("media_type", mediaType), - zap.String("media_category", mediaCategory), - zap.String("save_path", savePath)) - } - return false - } - s.log.Warn("subscription enqueue failed", - zap.String("title", item.Title), - zap.String("media_type", mediaType), - zap.String("media_category", mediaCategory), - zap.String("save_path", savePath), - zap.Error(err)) - return false - } - if s.log != nil { - s.log.Info("rss subscription candidate queued", - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.String("title", item.Title), - zap.String("media_type", mediaType), - zap.String("media_category", mediaCategory), - zap.String("save_path", savePath)) - } - state.markTitleAvailable(item.Title) - state.markSeen(candidate.GUID) - return true -} - -func (s *SubscriptionService) finishRSSSubscriptionRun(ctx context.Context, sub *model.Subscription, guidKey string, state *rssSubscriptionRunState, queued int) { - state.availability = s.finalizePendingAvailability(sub, state.availability) - // Remember the last 200 GUIDs so the seen set doesn't grow forever. - if len(state.seen) > 200 { - state.seen = state.seen[len(state.seen)-200:] - } - if err := s.repo.Setting.Set(ctx, guidKey, strings.Join(state.seen, "\n")); err != nil && s.log != nil { - s.log.Warn("subscription seen state update failed", - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.Error(err)) - } - - now := time.Now() - if err := s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error; err != nil && s.log != nil { - s.log.Warn("subscription last_run_at update failed", - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.Error(err)) - } - if err := s.archiveCompletedSubscription(ctx, sub, state.availability); err != nil && s.log != nil { - s.log.Warn("subscription archive check failed", - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.Error(err)) - } - if queued > 0 { - s.hub.Publish("subscription", map[string]any{ - "id": sub.ID, - "name": sub.Name, - "queued": queued, - }) - s.notifySubscriptionHit(sub, queued, nil) - } -} - -func (state *rssSubscriptionRunState) markTitleAvailable(title string) { - if state.washOff { - addAvailabilityTitle(title, state.availabilityQuery, &state.availability) - } -} - -func (state *rssSubscriptionRunState) markCandidateAvailable(candidate siteSearchCandidate) { - if state.washOff { - addSiteSearchCandidateAvailability(candidate, &state.availability) - } -} - -func (state *rssSubscriptionRunState) markSeen(guid string) { - state.seen = append(state.seen, guid) - if state.seenSet != nil { - state.seenSet[guid] = struct{}{} - } -} diff --git a/internal/service/subscription_rules.go b/internal/service/subscription_rules.go deleted file mode 100644 index 2fe52af..0000000 --- a/internal/service/subscription_rules.go +++ /dev/null @@ -1,104 +0,0 @@ -package service - -import ( - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func matchesSubscriptionRules(sub *model.Subscription, title string) bool { - titleFold := strings.ToLower(title) - if containsAnyExcludeToken(titleFold, defaultExcludeWords) { - return false - } - if sub == nil { - return true - } - if compatibilityExcludes := defaultCompatibilityExcludesForSubscription(sub); compatibilityExcludes != "" && containsAnyExcludeToken(titleFold, compatibilityExcludes) { - return false - } - if sub.ExcludeWords != "" && containsAnyExcludeToken(titleFold, sub.ExcludeWords) { - return false - } - if sub.ReleaseGroups != "" && !containsAnyToken(titleFold, sub.ReleaseGroups) { - return false - } - if sub.Resolution != "" && sub.Resolution != "best" && !titleMatchesResolution(titleFold, sub.Resolution) { - return false - } - if sub.Quality != "" && sub.Quality != "best" && !titleMatchesQuality(titleFold, sub.Quality) { - return false - } - if sub.Effects != "" && !containsAnyEffect(titleFold, sub.Effects) { - return false - } - return true -} - -func defaultCompatibilityExcludesForSubscription(sub *model.Subscription) string { - if sub == nil { - return defaultCompatibilityExcludeWords - } - requested := strings.ToLower(strings.Join([]string{sub.Effects, sub.Quality}, ",")) - if strings.TrimSpace(requested) == "" { - return defaultCompatibilityExcludeWords - } - tokens := excludeWordTokens(defaultCompatibilityExcludeWords) - out := make([]string, 0, len(tokens)) - for _, token := range tokens { - if token == "" || compatibilityTokenRequested(requested, token) { - continue - } - out = append(out, token) - } - return strings.Join(out, ",") -} - -func compatibilityTokenRequested(requested, token string) bool { - switch token { - case "dovi", "dv", "dolby vision", "杜比视界": - return containsAnyEffect(requested, "dolby-vision") || containsAnyToken(requested, "dovi,dv,dolby vision,杜比视界") - case "dolby", "杜比": - return containsAnyEffect(requested, "dolby-vision") || containsAnyToken(requested, "dolby,dolby vision,杜比,杜比视界,atmos,dolby atmos,杜比全景声") - case "atmos": - return containsAnyToken(requested, "atmos,dolby atmos,杜比全景声") - case "h265", "h.265", "h-265", "h_265", "h 265", "hevc", "x265": - return containsAnyToken(requested, "h265,h.265,h-265,h_265,h 265,hevc,x265") - case "10bit", "10-bit", "10 bit", "hi10p": - return containsAnyToken(requested, "10bit,10-bit,10 bit,hi10p") - case "truehd", "ddp", "dd+", "eac3": - return containsAnyToken(requested, "truehd,ddp,dd+,eac3") - default: - return containsAnyToken(requested, token) - } -} - -func isSubscriptionSeriesType(mediaType string) bool { - switch normalizeMediaType(mediaType, "", "") { - case "tv", "anime", "variety": - return true - default: - return false - } -} - -func subscriptionAllowsWash(sub *model.Subscription) bool { - if sub == nil || !sub.WashEnabled { - return false - } - return subscriptionHasExplicitUpgradeCriteria(sub) -} - -func subscriptionHasExplicitUpgradeCriteria(sub *model.Subscription) bool { - if sub == nil { - return false - } - if value := strings.TrimSpace(strings.ToLower(sub.Resolution)); value != "" && value != "best" { - return true - } - if value := strings.TrimSpace(strings.ToLower(sub.Quality)); value != "" && value != "best" { - return true - } - return strings.TrimSpace(sub.Effects) != "" || - strings.TrimSpace(sub.ReleaseGroups) != "" -} diff --git a/internal/service/subscription_rules_test.go b/internal/service/subscription_rules_test.go deleted file mode 100644 index ebf60aa..0000000 --- a/internal/service/subscription_rules_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package service - -import ( - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestMatchesSubscriptionRulesUserExcludeWords(t *testing.T) { - sub := &model.Subscription{ExcludeWords: "10bit,dolby vision,杜比"} - cases := []struct { - title string - want bool - }{ - {"Movie 2024 1080p WEB-DL", true}, - {"Movie 2024 2160p 10bit HEVC", false}, - {"Movie 2024 2160p Dolby Vision", false}, - {"电影 2024 杜比全景声", false}, - } - for _, c := range cases { - if got := matchesSubscriptionRules(sub, c.title); got != c.want { - t.Errorf("matchesSubscriptionRules(%q) = %v, want %v", c.title, got, c.want) - } - } -} - -func TestMatchesSubscriptionRulesReleaseStyleExcludeWords(t *testing.T) { - cases := []struct { - name string - sub *model.Subscription - title string - }{ - { - name: "default excludes ddp channel suffix", - sub: &model.Subscription{}, - title: "Some Show 2026 S01E01 1080p WEB-DL DDP5.1 H264", - }, - { - name: "default excludes dolby glued word", - sub: &model.Subscription{}, - title: "Some Movie 2026 1080p WEB-DL DolbyVision H264", - }, - { - name: "custom dotted list excludes split tokens", - sub: &model.Subscription{ExcludeWords: "DoVi.H265.10bit.杜比"}, - title: "Some Movie 2026 1080p WEB-DL H265", - }, - { - name: "custom dotted list excludes cjk split token", - sub: &model.Subscription{ExcludeWords: "DoVi.H265.10bit.杜比"}, - title: "某电影 2026 1080p 杜比全景声", - }, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if matchesSubscriptionRules(c.sub, c.title) { - t.Fatalf("expected exclude words to reject %q", c.title) - } - }) - } -} - -func TestMatchesSubscriptionRulesDefaultExcludesJunkReleases(t *testing.T) { - sub := &model.Subscription{} - for _, title := range []string{ - "Some Movie 2024 CAM", - "Some Movie 2024 HDTS", - "某电影 2024 枪版", - "Some Movie 2024 TELESYNC", - "Some Show 预告", - } { - if matchesSubscriptionRules(sub, title) { - t.Errorf("expected default rules to exclude junk release %q", title) - } - } -} - -func TestMatchesSubscriptionRulesDefaultExcludesRiskyTorrentLabels(t *testing.T) { - sub := &model.Subscription{} - for _, title := range []string{ - "Some Show S01E01 1080p WEB-DL HR", - "Some Show S01E01 1080p WEB-DL H&R", - "Some Show S01E01 1080p WEB-DL Hit and Run", - "Some Show S01E01 1080p WEB-DL 禁转", - "Some Show S01E01 1080p WEB-DL 禁止下载", - } { - if matchesSubscriptionRules(sub, title) { - t.Errorf("expected default rules to exclude risky torrent label %q", title) - } - } -} - -func TestMatchesSubscriptionRulesDefaultExcludesCompatibilityReleases(t *testing.T) { - cases := []struct { - name string - sub *model.Subscription - }{ - {name: "empty exclude words", sub: &model.Subscription{}}, - {name: "legacy frontend defaults", sub: &model.Subscription{ExcludeWords: "cam,ts,tc,枪版"}}, - {name: "custom exclude words", sub: &model.Subscription{ExcludeWords: "官中,无字幕"}}, - } - - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - for _, title := range []string{ - "Some Movie 2024 2160p DoVi H.265 10bit", - "Some Movie 2024 2160p H-265", - "Some Movie 2024 2160p H 265 10 bit", - "Some Movie 2024 1080p HEVC", - "Some Movie 2024 1080p x265", - "Some Movie 2024 2160p Dolby Vision Atmos", - "某电影 2024 1080p 杜比全景声", - "Some Anime 2024 1080p Hi10P", - } { - if matchesSubscriptionRules(c.sub, title) { - t.Errorf("expected default compatibility rules to exclude %q", title) - } - } - }) - } -} - -func TestMatchesSubscriptionRulesCustomExcludeWordsKeepCompatibilityDefaults(t *testing.T) { - sub := &model.Subscription{ExcludeWords: "sample"} - title := "Some Movie 2024 2160p DoVi HEVC 10bit" - if matchesSubscriptionRules(sub, title) { - t.Fatalf("custom exclude words should keep default compatibility excludes for %q", title) - } - if matchesSubscriptionRules(sub, "Some Movie 2024 1080p SAMPLE") { - t.Fatal("custom exclude words should still apply") - } -} - -func TestMatchesSubscriptionRulesExplicitEffectsCanRequestCompatibilityFormats(t *testing.T) { - sub := &model.Subscription{Effects: "dolby vision"} - title := "Some Movie 2024 2160p DoVi WEB-DL" - if !matchesSubscriptionRules(sub, title) { - t.Fatalf("explicit requested effects should allow compatibility format for %q", title) - } -} - -func TestMatchesSubscriptionRulesExplicitAtmosDoesNotAllowOtherCompatibilityFormats(t *testing.T) { - sub := &model.Subscription{Effects: "atmos"} - if !matchesSubscriptionRules(sub, "Some Movie 2024 1080p WEB-DL Atmos") { - t.Fatal("explicit atmos should allow an Atmos-only release") - } - if !matchesSubscriptionRules(sub, "Some Movie 2024 1080p WEB-DL Dolby Atmos") { - t.Fatal("explicit atmos should allow Dolby Atmos wording") - } - if matchesSubscriptionRules(sub, "Some Movie 2024 2160p WEB-DL HEVC 10bit DoVi Atmos") { - t.Fatal("explicit atmos should not also allow DoVi/HEVC/10bit") - } -} - -func TestMatchesSubscriptionRulesWordBoundaryAvoidsFalsePositives(t *testing.T) { - sub := &model.Subscription{} - // "ts" / "cam" / "tc" 作为子串出现在合法标题里时不应被默认排除误伤。 - for _, title := range []string{ - "Tsukihime 2024 1080p WEB-DL", - "Camp Rock 2024 1080p BluRay", - "Catch Me 2024 1080p WEB-DL", - } { - if !matchesSubscriptionRules(sub, title) { - t.Errorf("word-boundary match wrongly excluded %q", title) - } - } -} - -func TestSelectSiteSearchCandidatesSkipsExistingMovieWhenNotWashing(t *testing.T) { - sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie"} - results := []SearchResult{ - {Title: "Inception 2010 2160p 10bit Dolby Vision Atmos", DownloadURL: "https://pt/download/dovi", Seeders: 500}, - {Title: "Inception 2010 1080p WEB-DL", DownloadURL: "https://pt/download/web", Seeders: 90}, - } - availability := LocalAvailability{LocalMediaCount: 1, InLibrary: true, DownloadedEpisodes: 1, TotalEpisodes: 1} - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 0 { - t.Fatalf("selected %#v, want none (movie already in library, wash disabled)", got) - } -} - -func TestSelectSiteSearchCandidatesAllowsMovieWashUpgradeWithExplicitCriteria(t *testing.T) { - sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", Resolution: "2160p", WashEnabled: true, WashPriority: "resolution"} - results := []SearchResult{ - {Title: "Inception 2010 2160p REMUX", DownloadURL: "https://pt/download/2160", Seeders: 80}, - {Title: "Inception 2010 1080p WEB-DL", DownloadURL: "https://pt/download/1080", Seeders: 200}, - } - availability := LocalAvailability{LocalMediaCount: 1, InLibrary: true, DownloadedEpisodes: 1, TotalEpisodes: 1} - - got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) - if len(got) != 1 || got[0].Download != "https://pt/download/2160" { - t.Fatalf("selected %#v, want 2160p upgrade allowed when washing", got) - } -} - -func TestSubscriptionItemAlreadyAvailable(t *testing.T) { - movieSub := &model.Subscription{MediaType: "movie"} - if !subscriptionItemAlreadyAvailable(movieSub, LocalAvailability{LocalMediaCount: 1}, "Inception 2010 2160p") { - t.Fatal("movie already in library should be reported available") - } - if subscriptionItemAlreadyAvailable(movieSub, LocalAvailability{}, "Inception 2010 2160p") { - t.Fatal("empty library should not be reported available") - } - tvSub := &model.Subscription{MediaType: "tv"} - avail := LocalAvailability{LocalMediaCount: 1, ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 2): {}}} - if !subscriptionItemAlreadyAvailable(tvSub, avail, "Show S01E02 1080p") { - t.Fatal("existing episode should be reported available") - } - if subscriptionItemAlreadyAvailable(tvSub, avail, "Show S01E03 1080p") { - t.Fatal("missing episode should not be reported available") - } -} diff --git a/internal/service/subscription_run_rss_test.go b/internal/service/subscription_run_rss_test.go deleted file mode 100644 index 5170bc3..0000000 --- a/internal/service/subscription_run_rss_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSubscriptionRunOneDeduplicatesDuplicateRSSGUIDInSameFeed(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - Some Show S01E01 1080p - episode-1 - magnet:?xt=urn:btih:1111111111111111111111111111111111111111&dn=Some+Show+S01E01 - - - Some Show S01E01 1080p - episode-1 - magnet:?xt=urn:btih:1111111111111111111111111111111111111111&dn=Some+Show+S01E01 - -`)) - })) - defer rss.Close() - - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if atomic.LoadInt32(&addCalls) > 0 { - _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Some Show S01E01 1080p","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - - sub := &model.Subscription{ - Name: "Some Show 自动订阅", - FeedURL: rss.URL, - Filter: "Some Show", - MediaType: "tv", - SavePath: "/downloads/tv", - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 1 { - t.Fatalf("queued = %d, want 1", queued) - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } - rows, err := repos.Download.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 1 { - t.Fatalf("download rows = %d, want 1", len(rows)) - } -} - -func TestSubscriptionRunOneSkipsSameEpisodeAddedEarlierInFeed(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - Some Show S01E01 1080p - episode-1-a - magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=Some+Show+S01E01+1080p - - - Some Show S01E01 WEB-DL - episode-1-b - magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=Some+Show+S01E01+WEB-DL - -`)) - })) - defer rss.Close() - - var addCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if atomic.LoadInt32(&addCalls) > 0 { - _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Some Show S01E01 1080p","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - - sub := &model.Subscription{ - Name: "Some Show 自动订阅", - FeedURL: rss.URL, - Filter: "Some Show", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 12, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 1 { - t.Fatalf("queued = %d, want 1", queued) - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } - rows, err := repos.Download.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 1 { - t.Fatalf("download rows = %d, want 1", len(rows)) - } -} diff --git a/internal/service/subscription_run_wash_test.go b/internal/service/subscription_run_wash_test.go deleted file mode 100644 index 321c1f4..0000000 --- a/internal/service/subscription_run_wash_test.go +++ /dev/null @@ -1,267 +0,0 @@ -package service - -import ( - "fmt" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSubscriptionRunOneRSSWashQueuesOnlyBestMovieVariant(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - Dune 2021 1080p WEB-DL - dune-1080-web - magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&dn=Dune+2021+1080p+WEB-DL - - - Dune 2021 2160p UHD BluRay REMUX HDR - dune-2160-remux - magnet:?xt=urn:btih:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&dn=Dune+2021+2160p+REMUX - - - Dune 2021 720p HDTV - dune-720-hdtv - magnet:?xt=urn:btih:ffffffffffffffffffffffffffffffffffffffff&dn=Dune+2021+720p+HDTV - -`)) - })) - defer rss.Close() - - var addCalls int32 - var addedTitles []string - addedHashes := make([]string, 0, 3) - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if len(addedHashes) == 0 { - _, _ = w.Write([]byte(`[]`)) - return - } - var items []string - for _, hash := range addedHashes { - items = append(items, `{"hash":"`+hash+`","name":"Dune 2021","state":"downloading","progress":0.1}`) - } - _, _ = w.Write([]byte(`[` + strings.Join(items, ",") + `]`)) - case "/api/v2/torrents/add": - call := atomic.AddInt32(&addCalls, 1) - _ = r.ParseMultipartForm(10 << 20) - addedTitles = append(addedTitles, r.FormValue("urls")) - addedHashes = append(addedHashes, strings.Repeat(fmt.Sprintf("%x", call), 40)) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - - sub := &model.Subscription{ - Name: "Dune 自动订阅", - FeedURL: rss.URL, - Filter: "Dune 2021", - MediaType: "movie", - Resolution: "2160p", - Quality: "remux", - WashEnabled: true, - WashPriority: "resolution", - SavePath: "/downloads/movies", - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 1 { - t.Fatalf("queued = %d, want 1 best movie variant", queued) - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } - if len(addedTitles) != 1 || !strings.Contains(addedTitles[0], "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") { - t.Fatalf("added %#v, want 2160p REMUX variant only", addedTitles) - } -} - -func TestSubscriptionRunOneRSSDefaultQueuesOnlyBestEpisodeVariant(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - House of the Dragon S03E01 2160p BluRay H264 AAC - hotd-e01-bluray - magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=House+of+the+Dragon+S03E01+2160p+BluRay - - - House of the Dragon S03E01 1080p WEB-DL H264 AAC - hotd-e01-webdl - magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=House+of+the+Dragon+S03E01+1080p+WEB-DL - - - House of the Dragon S03E01 720p HDTV H264 AAC - hotd-e01-hdtv - magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&dn=House+of+the+Dragon+S03E01+720p+HDTV - -`)) - })) - defer rss.Close() - - var addCalls int32 - var addedURLs []string - addedHashes := make([]string, 0, 3) - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if len(addedHashes) == 0 { - _, _ = w.Write([]byte(`[]`)) - return - } - var items []string - for _, hash := range addedHashes { - items = append(items, `{"hash":"`+hash+`","name":"House of the Dragon S03E01","state":"downloading","progress":0.1}`) - } - _, _ = w.Write([]byte(`[` + strings.Join(items, ",") + `]`)) - case "/api/v2/torrents/add": - call := atomic.AddInt32(&addCalls, 1) - _ = r.ParseMultipartForm(10 << 20) - addedURLs = append(addedURLs, r.FormValue("urls")) - addedHashes = append(addedHashes, strings.Repeat(fmt.Sprintf("%x", call), 40)) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - - sub := &model.Subscription{ - Name: "House of the Dragon 自动订阅", - FeedURL: rss.URL, - Filter: "House of the Dragon", - MediaType: "tv", - SavePath: "/downloads/tv", - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 1 { - t.Fatalf("queued = %d, want one best episode variant", queued) - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } - if len(addedURLs) != 1 || !strings.Contains(addedURLs[0], "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") { - t.Fatalf("added %#v, want 1080p WEB-DL variant only", addedURLs) - } -} - -func TestSubscriptionRunOneRSSCustomExcludeStillSkipsIncompatibleVariants(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - House of the Dragon S03E01 2160p WEB-DL HEVC 10bit DoVi Atmos - hotd-e01-dovi - magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=House+of+the+Dragon+S03E01+2160p+DoVi - - - House of the Dragon S03E01 1080p WEB-DL H264 AAC - hotd-e01-webdl - magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=House+of+the+Dragon+S03E01+1080p+WEB-DL - -`)) - })) - defer rss.Close() - - var addCalls int32 - var addedURLs []string - addedHashes := make([]string, 0, 2) - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - if len(addedHashes) == 0 { - _, _ = w.Write([]byte(`[]`)) - return - } - var items []string - for _, hash := range addedHashes { - items = append(items, `{"hash":"`+hash+`","name":"House of the Dragon S03E01","state":"downloading","progress":0.1}`) - } - _, _ = w.Write([]byte(`[` + strings.Join(items, ",") + `]`)) - case "/api/v2/torrents/add": - call := atomic.AddInt32(&addCalls, 1) - _ = r.ParseMultipartForm(10 << 20) - addedURLs = append(addedURLs, r.FormValue("urls")) - addedHashes = append(addedHashes, strings.Repeat(fmt.Sprintf("%x", call), 40)) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - - sub := &model.Subscription{ - Name: "House of the Dragon 自动订阅", - FeedURL: rss.URL, - Filter: "House of the Dragon", - MediaType: "tv", - SavePath: "/downloads/tv", - ExcludeWords: "官中,无字幕", - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 1 { - t.Fatalf("queued = %d, want one compatible WEB-DL variant", queued) - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } - if len(addedURLs) != 1 || !strings.Contains(addedURLs[0], "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") { - t.Fatalf("added %#v, want compatible 1080p WEB-DL only", addedURLs) - } -} diff --git a/internal/service/subscription_score.go b/internal/service/subscription_score.go deleted file mode 100644 index 87090a6..0000000 --- a/internal/service/subscription_score.go +++ /dev/null @@ -1,167 +0,0 @@ -package service - -import ( - "regexp" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -var ( - dolbyVisionTokenRE = regexp.MustCompile(`\bdv\b`) - webDLTokenRE = regexp.MustCompile(`\bweb[\s._-]?dl\b`) - webRipTokenRE = regexp.MustCompile(`\bweb[\s._-]?rip\b`) - bluRayTokenRE = regexp.MustCompile(`\b(?:blu[\s._-]?ray|bdrip|bdremux|uhd[\s._-]?blu[\s._-]?ray)\b`) -) - -const ( - defaultSubscriptionFreePromotionScore = 50_000 - washSubscriptionFreePromotionScore = 25 -) - -func subscriptionCandidateScore(sub *model.Subscription, item SearchResult) int { - title := strings.ToLower(subscriptionSearchResultText(item)) - score := item.Seeders - if !subscriptionAllowsWash(sub) { - score += detectDefaultSubscriptionQualityScore(title)*1_000_000 + detectResolutionScore(title)*100_000 - if item.Free { - score += defaultSubscriptionFreePromotionScore - } - return score - } - resolutionScore := detectResolutionScore(title) - qualityScore := detectQualityScore(title) - effectScore := detectEffectScore(title) - - priority := "balanced" - if sub != nil && strings.TrimSpace(sub.WashPriority) != "" { - priority = strings.ToLower(strings.TrimSpace(sub.WashPriority)) - } - switch priority { - case "resolution": - score += resolutionScore*1000 + qualityScore*100 + effectScore*50 - case "quality": - score += qualityScore*1000 + resolutionScore*200 + effectScore*50 - case "effects": - score += effectScore*1000 + resolutionScore*200 + qualityScore*100 - case "seeders": - score += qualityScore*3 + resolutionScore*2 + effectScore - default: - score += resolutionScore*500 + qualityScore*300 + effectScore*150 - } - if item.Free { - score += washSubscriptionFreePromotionScore - } - return score -} - -func containsAnyEffect(titleFold, csv string) bool { - for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool { - return r == ',' || r == '/' || r == '|' || r == ';' || r == ',' - }) { - token = strings.TrimSpace(token) - if token == "" { - continue - } - switch token { - case "dolby-vision", "dolby vision", "dv": - if strings.Contains(titleFold, "dolby vision") || strings.Contains(titleFold, "dovi") || dolbyVisionTokenRE.MatchString(titleFold) { - return true - } - default: - if strings.Contains(titleFold, token) { - return true - } - } - } - return false -} - -func titleMatchesResolution(titleFold, resolution string) bool { - switch strings.ToLower(strings.TrimSpace(resolution)) { - case "2160p", "4k", "uhd": - return strings.Contains(titleFold, "2160p") || strings.Contains(titleFold, "4k") || strings.Contains(titleFold, "uhd") - case "1080p": - return strings.Contains(titleFold, "1080p") || strings.Contains(titleFold, "fhd") - case "720p": - return strings.Contains(titleFold, "720p") - default: - return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(resolution))) - } -} - -func titleMatchesQuality(titleFold, quality string) bool { - switch strings.ToLower(strings.TrimSpace(quality)) { - case "webdl", "web-dl": - return webDLTokenRE.MatchString(titleFold) - case "webrip", "web-rip": - return webRipTokenRE.MatchString(titleFold) - case "bluray", "blu-ray": - return bluRayTokenRE.MatchString(titleFold) - case "remux": - return strings.Contains(titleFold, "remux") - case "hdtv": - return strings.Contains(titleFold, "hdtv") - default: - return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(quality))) - } -} - -func detectDefaultSubscriptionQualityScore(titleFold string) int { - switch { - case titleMatchesQuality(titleFold, "web-dl"): - return 5 - case titleMatchesQuality(titleFold, "web-rip"): - return 4 - case titleMatchesQuality(titleFold, "bluray"), titleMatchesQuality(titleFold, "remux"): - return 3 - case titleMatchesQuality(titleFold, "hdtv"): - return 2 - default: - return 1 - } -} - -func detectResolutionScore(titleFold string) int { - switch { - case titleMatchesResolution(titleFold, "2160p"): - return 4 - case titleMatchesResolution(titleFold, "1080p"): - return 3 - case titleMatchesResolution(titleFold, "720p"): - return 2 - default: - return 1 - } -} - -func detectQualityScore(titleFold string) int { - switch { - case titleMatchesQuality(titleFold, "remux"): - return 5 - case titleMatchesQuality(titleFold, "bluray"): - return 4 - case titleMatchesQuality(titleFold, "web-dl"): - return 3 - case titleMatchesQuality(titleFold, "hdtv"): - return 2 - default: - return 1 - } -} - -func detectEffectScore(titleFold string) int { - score := 0 - if containsAnyEffect(titleFold, "dolby-vision") { - score += 4 - } - if strings.Contains(titleFold, "hdr10+") { - score += 3 - } else if strings.Contains(titleFold, "hdr") { - score += 2 - } - if strings.Contains(titleFold, "atmos") { - score += 2 - } - return score -} diff --git a/internal/service/subscription_site_search.go b/internal/service/subscription_site_search.go deleted file mode 100644 index 12b0244..0000000 --- a/internal/service/subscription_site_search.go +++ /dev/null @@ -1,241 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subscription) (int, error) { - if s.site == nil { - if s.log != nil { - s.log.Warn("site-search subscription service unavailable", subscriptionSiteSearchLogFields(sub, "")...) - } - return 0, errors.New("site search service unavailable") - } - keywords := siteSearchKeywords(sub) - keyword := "" - if len(keywords) > 0 { - keyword = keywords[0] - } - if keyword == "" { - if s.log != nil { - s.log.Warn("site-search subscription keyword missing", subscriptionSiteSearchLogFields(sub, "")...) - } - return 0, errors.New("site-search subscription keyword required") - } - if s.log != nil { - s.log.Info("site-search subscription run started", subscriptionSiteSearchLogFields(sub, keyword)...) - } - - results, err := s.searchSubscriptionSites(ctx, sub, keywords) - if err != nil { - return 0, err - } - if len(results) == 0 { - return s.finishSiteSearchNoResults(sub, keyword) - } - s.updateSubscriptionTotalEpisodes(ctx, sub, s.resolveSubscriptionTotalEpisodes(ctx, sub, inferSearchTotalEpisodes(results, sub))) - - guidKey, seen, seenSet := s.loadSiteSearchSeen(ctx, sub) - availability := mergeLocalAvailability( - SubscriptionLocalAvailability(ctx, s.repo, sub), - s.pendingDownloadAvailability(ctx, sub), - ) - candidates, selectionStats := selectSiteSearchCandidatesWithStats(results, sub, seenSet, availability) - if s.log != nil { - fields := subscriptionSiteSearchLogFields(sub, keyword) - fields = appendSiteSearchSelectionLogFields(fields, selectionStats) - fields = appendAvailabilityLogFields(fields, availability) - s.log.Info("site-search subscription selection summary", fields...) - } - runState := &siteSearchRunState{ - Keyword: keyword, - Seen: seen, - SeenSet: seenSet, - Availability: availability, - } - queueResult := s.enqueueSiteSearchCandidates(ctx, sub, candidates, runState) - availability = s.finishSiteSearchRun(ctx, sub, guidKey, runState) - return s.handleSiteSearchQueueResult(sub, keyword, queueResult, selectionStats, availability) -} - -func (s *SubscriptionService) finishSiteSearchRun(ctx context.Context, sub *model.Subscription, guidKey string, state *siteSearchRunState) LocalAvailability { - availability := s.finalizePendingAvailability(sub, state.Availability) - seen := trimSiteSearchSeen(state.Seen) - if err := s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n")); err != nil && s.log != nil { - s.log.Warn("site-search subscription seen state update failed", - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.Error(err)) - } - now := time.Now() - if err := s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error; err != nil && s.log != nil { - s.log.Warn("site-search subscription last_run_at update failed", - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.Error(err)) - } - if err := s.archiveCompletedSubscription(ctx, sub, availability); err != nil && s.log != nil { - s.log.Warn("site-search subscription archive check failed", - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.Error(err)) - } - return availability -} - -func (s *SubscriptionService) handleSiteSearchQueueResult(sub *model.Subscription, keyword string, queueResult siteSearchQueueResult, selectionStats siteSearchSelectionStats, availability LocalAvailability) (int, error) { - if queueResult.Queued > 0 { - if s.log != nil { - fields := subscriptionSiteSearchLogFields(sub, keyword) - fields = appendSiteSearchSelectionLogFields(fields, selectionStats) - fields = appendAvailabilityLogFields(fields, availability) - fields = append(fields, - zap.Int("queued", queueResult.Queued), - zap.Strings("resources", queueResult.Resources), - zap.Bool("archived", sub.ArchivedAt != nil), - zap.String("archive_reason", sub.ArchiveReason), - ) - s.log.Info("site-search subscription queued resources", fields...) - } - s.hub.Publish("subscription", map[string]any{ - "id": sub.ID, - "name": sub.Name, - "queued": queueResult.Queued, - "keyword": keyword, - "resources": queueResult.Resources, - }) - s.notifySubscriptionHit(sub, queueResult.Queued, queueResult.Resources) - return queueResult.Queued, nil - } - if queueResult.LastEnqueueErr != nil { - return 0, fmt.Errorf("找到 PT 资源但加入下载器失败: %w", queueResult.LastEnqueueErr) - } - if s.log != nil { - fields := subscriptionSiteSearchLogFields(sub, keyword) - fields = appendSiteSearchSelectionLogFields(fields, selectionStats) - fields = appendAvailabilityLogFields(fields, availability) - fields = append(fields, zap.Int("queued", queueResult.Queued)) - s.log.Info("site-search subscription no candidate queued", fields...) - } - return 0, nil -} - -func (s *SubscriptionService) searchSubscriptionSites(ctx context.Context, sub *model.Subscription, keywords []string) ([]SearchResult, error) { - var ( - results []SearchResult - lastSearchErr error - searchErrors int - ) - for _, searchKeyword := range keywords { - found, err := s.site.Search(ctx, searchKeyword) - if err != nil { - lastSearchErr = err - searchErrors++ - if s.log != nil { - fields := subscriptionSiteSearchLogFields(sub, searchKeyword) - fields = append(fields, zap.Error(err)) - s.log.Warn("site-search subscription search failed", fields...) - } - if subscriptionSiteSearchShouldStopOnError(err) { - return nil, err - } - continue - } - results = append(results, found...) - if hasUsableSubscriptionSearchResult(found, sub) { - break - } - } - results = dedupeSiteSearchResults(results) - if len(results) == 0 && lastSearchErr != nil && searchErrors == len(keywords) { - return nil, lastSearchErr - } - return results, nil -} - -func subscriptionSiteSearchShouldStopOnError(err error) bool { - if err == nil { - return false - } - var limited *siteAPIRateLimitError - if errors.As(err, &limited) { - return true - } - if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { - return true - } - var timeout interface{ Timeout() bool } - if errors.As(err, &timeout) && timeout.Timeout() { - return true - } - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "rate limit") || - strings.Contains(msg, "quota") || - strings.Contains(msg, "too many requests") || - strings.Contains(msg, "status 429") || - strings.Contains(msg, "context deadline exceeded") || - strings.Contains(msg, "tls handshake timeout") || - strings.Contains(msg, "i/o timeout") || - strings.Contains(msg, "unexpected eof") || - strings.Contains(msg, "connection reset") || - strings.Contains(msg, "connection refused") || - strings.Contains(msg, "no such host") || - strings.Contains(msg, "temporary failure") -} - -func hasUsableSubscriptionSearchResult(results []SearchResult, sub *model.Subscription) bool { - for _, item := range results { - if !subscriptionSearchResultMatchesQuery(sub, item) { - continue - } - if !matchesSubscriptionRules(sub, subscriptionSearchResultText(item)) { - continue - } - if strings.TrimSpace(firstNonEmpty(item.DownloadURL, item.TorrentURL)) != "" { - return true - } - } - return false -} - -func (s *SubscriptionService) finishSiteSearchNoResults(sub *model.Subscription, keyword string) (int, error) { - if s.log != nil { - fields := subscriptionSiteSearchLogFields(sub, keyword) - fields = append(fields, zap.Int("results_count", 0)) - s.log.Info("site-search subscription no results", fields...) - } - now := time.Now() - if err := s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error; err != nil && s.log != nil { - s.log.Warn("site-search subscription last_run_at update failed", - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.Error(err)) - } - return 0, nil -} - -func (s *SubscriptionService) loadSiteSearchSeen(ctx context.Context, sub *model.Subscription) (string, []string, map[string]struct{}) { - guidKey := fmt.Sprintf("subscription.%s.seen", sub.ID) - seenRaw, _ := s.repo.Setting.Get(ctx, guidKey) - seen := splitNonEmpty(seenRaw) - seenSet := make(map[string]struct{}, len(seen)) - for _, g := range seen { - seenSet[g] = struct{}{} - } - return guidKey, seen, seenSet -} - -func trimSiteSearchSeen(seen []string) []string { - if len(seen) <= 200 { - return seen - } - return seen[len(seen)-200:] -} diff --git a/internal/service/subscription_site_search_enqueue.go b/internal/service/subscription_site_search_enqueue.go deleted file mode 100644 index 8ff6a78..0000000 --- a/internal/service/subscription_site_search_enqueue.go +++ /dev/null @@ -1,164 +0,0 @@ -package service - -import ( - "context" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -type siteSearchRunState struct { - Keyword string - Seen []string - SeenSet map[string]struct{} - Availability LocalAvailability -} - -type siteSearchQueueResult struct { - Queued int - Resources []string - LastEnqueueErr error -} - -func (s *SubscriptionService) enqueueSiteSearchCandidates(ctx context.Context, sub *model.Subscription, candidates []siteSearchCandidate, state *siteSearchRunState) siteSearchQueueResult { - var result siteSearchQueueResult - for _, candidate := range candidates { - title, err := s.enqueueSiteSearchCandidate(ctx, sub, candidate, state) - if err != nil { - result.LastEnqueueErr = err - continue - } - if title == "" { - continue - } - result.Queued++ - result.Resources = append(result.Resources, title) - } - return result -} - -func (s *SubscriptionService) enqueueSiteSearchCandidate(ctx context.Context, sub *model.Subscription, candidate siteSearchCandidate, state *siteSearchRunState) (string, error) { - item := candidate.Item - matchText := subscriptionSearchResultText(item) - mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, matchText, item.Category) - if s.shouldSkipExistingTorrent(ctx, mediaType, candidate) { - state.markCandidateAvailable(candidate) - state.markCandidateSeen(candidate.GUID) - s.logSiteSearchCandidateSkipped(sub, state, candidate, "existing_torrent", mediaType, "", "") - return "", nil - } - - realURL := s.site.ResolveDownloadURL(ctx, candidate.Download) - savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory) - if s.downloadPathHasCandidate(ctx, sub, matchText, savePath) { - state.markCandidateAvailable(candidate) - state.markCandidateSeen(candidate.GUID) - s.logSiteSearchCandidateSkipped(sub, state, candidate, "download_path_has_candidate", mediaType, mediaCategory, savePath) - return "", nil - } - - if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, realURL, savePath, DownloadTaskMeta{ - SubscriptionID: sub.ID, - Title: firstNonEmpty(item.Title, sub.Name), - PosterURL: sub.PosterURL, - BackdropURL: sub.BackdropURL, - Overview: sub.Overview, - MediaType: mediaType, - MediaCategory: mediaCategory, - SourceCategory: item.Category, - AllowExistingLibrary: subscriptionAllowsWash(sub), - }); err != nil { - if IsDownloadDedupError(err) { - if s.subscriptionCandidateConfirmedAvailable(ctx, sub, candidate) { - state.markCandidateAvailable(candidate) - s.logSiteSearchCandidateSkipped(sub, state, candidate, "download_dedup", mediaType, mediaCategory, savePath) - return "", nil - } - s.logSiteSearchCandidateSkipped(sub, state, candidate, "download_dedup_unconfirmed", mediaType, mediaCategory, savePath) - return "", nil - } - s.logSiteSearchEnqueueFailed(sub, state, candidate, mediaType, mediaCategory, savePath, err) - return "", err - } - - state.markCandidateAvailable(candidate) - state.markCandidateSeen(candidate.GUID) - s.logSiteSearchCandidateQueued(sub, state, candidate, mediaType, mediaCategory, savePath) - return item.Title, nil -} - -func (state *siteSearchRunState) markCandidateAvailable(candidate siteSearchCandidate) { - addSiteSearchCandidateAvailability(candidate, &state.Availability) -} - -func (state *siteSearchRunState) markCandidateSeen(guid string) { - if strings.TrimSpace(guid) == "" { - return - } - state.Seen = append(state.Seen, guid) - if state.SeenSet != nil { - state.SeenSet[guid] = struct{}{} - } -} - -func (s *SubscriptionService) logSiteSearchCandidateSkipped(sub *model.Subscription, state *siteSearchRunState, candidate siteSearchCandidate, reason, mediaType, mediaCategory, savePath string) { - if s.log == nil { - return - } - fields := subscriptionSiteSearchLogFields(sub, state.Keyword) - fields = append(fields, zap.String("reason", reason)) - fields = appendSiteSearchCandidateLogFields(fields, candidate) - fields = append(fields, zap.String("media_type", mediaType)) - if mediaCategory != "" { - fields = append(fields, zap.String("media_category", mediaCategory)) - } - if savePath != "" { - fields = append(fields, zap.String("save_path", savePath)) - } - s.log.Info("site-search subscription candidate skipped", fields...) -} - -func (s *SubscriptionService) logSiteSearchCandidateQueued(sub *model.Subscription, state *siteSearchRunState, candidate siteSearchCandidate, mediaType, mediaCategory, savePath string) { - if s.log == nil { - return - } - fields := subscriptionSiteSearchLogFields(sub, state.Keyword) - fields = appendSiteSearchCandidateLogFields(fields, candidate) - fields = append(fields, - zap.Int("score", candidate.Score), - zap.String("media_type", mediaType), - zap.String("media_category", mediaCategory), - zap.String("save_path", savePath), - ) - s.log.Info("site-search subscription candidate queued", fields...) -} - -func (s *SubscriptionService) logSiteSearchEnqueueFailed(sub *model.Subscription, state *siteSearchRunState, candidate siteSearchCandidate, mediaType, mediaCategory, savePath string, err error) { - if s.log == nil { - return - } - fields := subscriptionSiteSearchLogFields(sub, state.Keyword) - fields = appendSiteSearchCandidateLogFields(fields, candidate) - fields = append(fields, - zap.String("media_type", mediaType), - zap.String("media_category", mediaCategory), - zap.String("save_path", savePath), - zap.Error(err), - ) - s.log.Warn("site-search subscription enqueue failed", fields...) -} - -func appendSiteSearchCandidateLogFields(fields []zap.Field, candidate siteSearchCandidate) []zap.Field { - item := candidate.Item - return append(fields, - zap.String("title", item.Title), - zap.String("subtitle", item.Subtitle), - zap.String("site", firstNonEmpty(item.SiteName, item.SiteID)), - zap.String("site_category", item.Category), - zap.Int("season", candidate.Season), - zap.Int("episode", candidate.Episode), - zap.Bool("pack", candidate.Pack), - ) -} diff --git a/internal/service/subscription_site_search_enqueue_test.go b/internal/service/subscription_site_search_enqueue_test.go deleted file mode 100644 index c43e41b..0000000 --- a/internal/service/subscription_site_search_enqueue_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package service - -import ( - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestEnqueueSiteSearchDedupMarksEnglishRangeAvailableForChineseSubscription(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - UserID: "u1", - Source: "qbittorrent", - URL: "https://pt.example/download?id=old", - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - SavePath: "/downloads/国产剧", - Status: "queued", - Progress: 0.1, - }); err != nil { - t.Fatal(err) - } - - site := NewSiteService(zap.NewNop(), repos, "") - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, site, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Base: model.Base{ID: "sub-nanyang"}, - UserID: "u1", - Name: "南部档案 自动订阅", - Filter: "南部档案 2026", - MediaType: "tv", - MediaCategory: "国产剧", - SavePath: "/downloads", - TotalEpisodes: 33, - } - state := &siteSearchRunState{ - Keyword: "南部档案 2026", - SeenSet: map[string]struct{}{}, - Availability: LocalAvailability{ - TotalEpisodes: 33, - ExistingEpisodeKeys: map[string]struct{}{}, - MissingEpisodeKeys: map[string]struct{}{}, - }, - } - candidate := siteSearchCandidate{ - Item: SearchResult{ - Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", - DownloadURL: "https://pt.example/download?id=new", - }, - Download: "https://pt.example/download?id=new", - GUID: "site|m-team|nanyang-7-8", - Season: 1, - Episode: 7, - Episodes: []int{7, 8}, - Pack: true, - } - - title, err := svc.enqueueSiteSearchCandidate(t.Context(), sub, candidate, state) - if err != nil { - t.Fatalf("enqueueSiteSearchCandidate returned %v, want dedup skip without error", err) - } - if title != "" { - t.Fatalf("title = %q, want empty because candidate was deduped", title) - } - for _, episode := range []int{7, 8} { - if _, ok := state.Availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok { - t.Fatalf("availability missing E%d after dedup range: %#v", episode, state.Availability.ExistingEpisodeKeys) - } - } - if _, ok := state.SeenSet[candidate.GUID]; ok { - t.Fatalf("deduped candidate should not be marked seen before the next availability scan") - } -} - -func TestEnqueueSiteSearchDedupLinksEnglishHistoryTaskToChineseSubscription(t *testing.T) { - db := newServiceTestDB(t, &model.DownloadTask{}, &model.Setting{}) - repos := repository.New(db) - if err := repos.Download.Create(t.Context(), &model.DownloadTask{ - UserID: "u1", - Source: "qbittorrent", - URL: "https://pt.example/download?id=history", - Title: "The Heir 2026 S01E33-E34 2160p WEB-DL", - SavePath: "/downloads/国产剧", - Status: "queued", - Progress: 0.2, - }); err != nil { - t.Fatal(err) - } - - site := NewSiteService(zap.NewNop(), repos, "") - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, site, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Base: model.Base{ID: "sub-heir"}, - UserID: "u1", - Name: "家业 自动订阅", - Filter: "家业 2026", - MediaType: "tv", - MediaCategory: "国产剧", - SavePath: "/downloads", - TotalEpisodes: 42, - } - state := &siteSearchRunState{ - Keyword: "家业 2026", - SeenSet: map[string]struct{}{}, - Availability: LocalAvailability{ - TotalEpisodes: 42, - ExistingEpisodeKeys: map[string]struct{}{}, - MissingEpisodeKeys: map[string]struct{}{}, - }, - } - candidate := siteSearchCandidate{ - Item: SearchResult{ - Title: "The Heir 2026 S01E33-E34 2160p WEB-DL", - DownloadURL: "https://pt.example/download?id=new", - }, - Download: "https://pt.example/download?id=new", - GUID: "site|m-team|heir-33-34", - Season: 1, - Episode: 33, - Episodes: []int{33, 34}, - Pack: true, - } - - title, err := svc.enqueueSiteSearchCandidate(t.Context(), sub, candidate, state) - if err != nil { - t.Fatalf("enqueueSiteSearchCandidate returned %v, want dedup skip without error", err) - } - if title != "" { - t.Fatalf("title = %q, want empty because candidate was deduped", title) - } - var rows []model.DownloadTask - if err := db.Find(&rows).Error; err != nil { - t.Fatal(err) - } - if len(rows) != 1 { - t.Fatalf("download rows = %d, want 1", len(rows)) - } - if rows[0].SubscriptionID != sub.ID { - t.Fatalf("history task subscription_id = %q, want %q", rows[0].SubscriptionID, sub.ID) - } - if rows[0].MediaType != "tv" || rows[0].MediaCategory != "国产剧" { - t.Fatalf("history task metadata = (%q, %q), want (tv, 国产剧)", rows[0].MediaType, rows[0].MediaCategory) - } - - availability := svc.pendingDownloadAvailability(t.Context(), sub) - for _, episode := range []int{33, 34} { - if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok { - t.Fatalf("linked history task missing E%d availability: %#v", episode, availability.ExistingEpisodeKeys) - } - } -} diff --git a/internal/service/subscription_site_search_helpers.go b/internal/service/subscription_site_search_helpers.go deleted file mode 100644 index c649124..0000000 --- a/internal/service/subscription_site_search_helpers.go +++ /dev/null @@ -1,203 +0,0 @@ -package service - -import ( - "context" - "net/url" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func subscriptionSiteSearchLogFields(sub *model.Subscription, keyword string) []zap.Field { - fields := []zap.Field{zap.String("keyword", keyword), zap.Strings("search_keywords", siteSearchKeywords(sub))} - if sub == nil { - return fields - } - fields = append(fields, - zap.String("subscription_id", sub.ID), - zap.String("subscription", sub.Name), - zap.String("filter", sub.Filter), - zap.String("media_type", sub.MediaType), - zap.String("media_category", sub.MediaCategory), - zap.String("search_mode", sub.SearchMode), - zap.String("imdb_id", sub.IMDBID), - zap.Bool("wash_enabled", sub.WashEnabled), - zap.String("wash_priority", sub.WashPriority), - zap.Int("total_episodes", sub.TotalEpisodes), - ) - return fields -} - -func appendSiteSearchSelectionLogFields(fields []zap.Field, stats siteSearchSelectionStats) []zap.Field { - return append(fields, - zap.Int("results_count", stats.Total), - zap.Int("query_mismatch_count", stats.QueryMismatch), - zap.Strings("query_mismatch_examples", stats.QueryMismatchExamples), - zap.Int("relaxed_query_match_count", stats.RelaxedQueryMatch), - zap.Int("rule_mismatch_count", stats.RuleMismatch), - zap.Int("missing_download_count", stats.MissingDownload), - zap.Int("seen_count", stats.Seen), - zap.Int("prepared_count", stats.Prepared), - zap.Int("selected_count", stats.Selected), - zap.Bool("local_already_satisfied", stats.LocalAlreadySatisfied), - zap.Bool("local_series_pack_present", stats.LocalSeriesPackPresent), - zap.Bool("series_complete", stats.SeriesComplete), - zap.Int("existing_episode_skipped_count", stats.ExistingEpisodeSkipped), - zap.Int("not_missing_episode_skipped_count", stats.NotMissingEpisodeSkipped), - zap.Int("no_episode_skipped_count", stats.NoEpisodeSkipped), - zap.Bool("pack_fallback_available", stats.PackFallbackAvailable), - zap.Bool("pack_fallback_used", stats.PackFallbackUsed), - ) -} - -func appendAvailabilityLogFields(fields []zap.Field, availability LocalAvailability) []zap.Field { - missingSample, missingMore := limitedEpisodeSample(availability.MissingEpisodes, 20) - return append(fields, - zap.Int("local_media_count", availability.LocalMediaCount), - zap.Bool("in_library", availability.InLibrary), - zap.Bool("has_series_pack", availability.HasSeriesPack), - zap.Int("downloaded_episodes", availability.DownloadedEpisodes), - zap.Int("availability_total_episodes", availability.TotalEpisodes), - zap.Int("missing_episode_count", len(availability.MissingEpisodes)), - zap.Ints("missing_episodes", missingSample), - zap.Int("missing_episodes_more", missingMore), - ) -} - -func limitedEpisodeSample(values []int, limit int) ([]int, int) { - if limit <= 0 || len(values) == 0 { - return nil, len(values) - } - if len(values) <= limit { - out := append([]int(nil), values...) - return out, 0 - } - out := append([]int(nil), values[:limit]...) - return out, len(values) - limit -} - -func (s *SubscriptionService) shouldSkipExistingTorrent(ctx context.Context, mediaType string, candidate siteSearchCandidate) bool { - if s == nil || s.downloads == nil { - return false - } - if isSubscriptionSeriesType(mediaType) && candidate.Episode > 0 { - return false - } - return s.downloads.TorrentExistsByName(ctx, candidate.Item.Title) -} - -func siteSearchKeywords(sub *model.Subscription) []string { - if sub == nil { - return nil - } - values := make([]string, 0, 8) - if strings.EqualFold(strings.TrimSpace(sub.SearchMode), "imdb") && strings.TrimSpace(sub.IMDBID) != "" { - values = append(values, strings.TrimSpace(sub.IMDBID)) - } - if u, err := url.Parse(sub.FeedURL); err == nil { - if keyword := strings.TrimSpace(u.Query().Get("keyword")); keyword != "" { - values = append(values, keyword) - } - } - if strings.TrimSpace(sub.Filter) != "" { - values = append(values, sub.Filter) - } - if len(values) == 0 && strings.TrimSpace(sub.Name) != "" { - values = append(values, sub.Name) - } - values = append(values, subscriptionFeedAliases(sub)...) - values = append(values, subscriptionMetadataAliases(sub)...) - for _, value := range append([]string(nil), values...) { - if cleaned := cleanAvailabilityTitle(value); cleaned != "" { - values = append(values, cleaned) - } - } - return compactUniqueStrings(values...) -} - -func siteSearchKeyword(sub *model.Subscription) string { - keywords := siteSearchKeywords(sub) - if len(keywords) == 0 { - return "" - } - return keywords[0] -} - -func subscriptionFeedAliases(sub *model.Subscription) []string { - if sub == nil { - return nil - } - u, err := url.Parse(sub.FeedURL) - if err != nil { - return nil - } - q := u.Query() - values := make([]string, 0, len(q["alias"])+2) - values = append(values, q["alias"]...) - for _, raw := range q["aliases"] { - for _, part := range strings.FieldsFunc(raw, func(r rune) bool { - return r == '|' || r == '\n' || r == '\r' || r == '\t' - }) { - values = append(values, part) - } - } - return compactUniqueStrings(values...) -} - -func subscriptionMetadataAliases(sub *model.Subscription) []string { - if sub == nil { - return nil - } - title := cleanAvailabilityTitle(firstNonEmpty(sub.Filter, sub.Name)) - return buildSubscribeAliases(title, sub.OriginalName, sub.Year) -} - -func compactUniqueStrings(values ...string) []string { - seen := map[string]struct{}{} - out := make([]string, 0, len(values)) - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" { - continue - } - key := normalizeAvailabilityComparable(value) - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - out = append(out, value) - } - return out -} - -func dedupeSiteSearchResults(results []SearchResult) []SearchResult { - if len(results) < 2 { - return results - } - seen := make(map[string]int, len(results)) - out := make([]SearchResult, 0, len(results)) - for _, item := range results { - download := strings.TrimSpace(item.DownloadURL) - if download == "" { - download = strings.TrimSpace(item.TorrentURL) - } - key := stableSiteSearchGUID(item, download) - if idx, ok := seen[key]; ok { - out[idx].SearchKeyword = mergeSearchResultTextHints(out[idx].SearchKeyword, item.SearchKeyword) - out[idx].Subtitle = mergeSearchResultTextHints(out[idx].Subtitle, item.Subtitle) - continue - } - seen[key] = len(out) - out = append(out, item) - } - return out -} - -func mergeSearchResultTextHints(current, next string) string { - return strings.Join(compactUniqueStrings(current, next), "\n") -} diff --git a/internal/service/subscription_site_search_keywords_test.go b/internal/service/subscription_site_search_keywords_test.go deleted file mode 100644 index 3b02d8e..0000000 --- a/internal/service/subscription_site_search_keywords_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package service - -import ( - "strings" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestSiteSearchKeywordCanUseIMDB(t *testing.T) { - sub := &model.Subscription{Name: "沙丘 自动订阅", Filter: "Dune 2021", SearchMode: "imdb", IMDBID: "tt1160419"} - if got := siteSearchKeyword(sub); got != "tt1160419" { - t.Fatalf("keyword = %q, want imdb id", got) - } -} - -func TestSiteSearchKeywordsIncludeAliasesAndCleanedKeywords(t *testing.T) { - sub := &model.Subscription{ - Name: "真人快打2 自动订阅", - FeedURL: "site-search://search?keyword=%E7%9C%9F%E4%BA%BA%E5%BF%AB%E6%89%932%202026&alias=Mortal%20Kombat%20II%202026", - Filter: "真人快打2 2026", - } - - got := siteSearchKeywords(sub) - for _, want := range []string{"真人快打2 2026", "Mortal Kombat II 2026", "真人快打2", "Mortal Kombat II"} { - if !containsString(got, want) { - t.Fatalf("keywords = %#v, missing %q", got, want) - } - } - if got[0] != "真人快打2 2026" { - t.Fatalf("primary keyword = %q, want feed keyword first", got[0]) - } -} - -func TestSiteSearchKeywordsUseCleanMetadataAliases(t *testing.T) { - sub := &model.Subscription{ - Name: "玩具总动员 4 自动订阅", - Filter: "玩具总动员 4 2019", - OriginalName: "Toy Story 4", - Year: 2019, - } - - got := siteSearchKeywords(sub) - for _, want := range []string{"玩具总动员 4 2019", "Toy Story 4", "Toy Story 4 2019", "玩具总动员 4"} { - if !containsString(got, want) { - t.Fatalf("keywords = %#v, missing %q", got, want) - } - } - for _, unwanted := range []string{"玩具总动员 4 自动订阅", "玩具总动员 4 自动订阅 2019", "玩具总动员 4 2019 2019"} { - if containsString(got, unwanted) { - t.Fatalf("keywords = %#v, should not contain %q", got, unwanted) - } - } -} - -func TestStableSiteSearchGUIDIgnoresPrivateTokenChanges(t *testing.T) { - item := SearchResult{ - SiteID: "mteam", - Title: "Some Show S01E01 1080p", - Category: "TV", - Size: 1024, - } - first := stableSiteSearchGUID(item, "https://pt.example/download?id=123&passkey=old") - second := stableSiteSearchGUID(item, "https://pt.example/download?id=123&passkey=new") - if first != second { - t.Fatalf("stableSiteSearchGUID changed with token: %q != %q", first, second) - } - if strings.Contains(first, "passkey") || strings.Contains(first, "old") || strings.Contains(first, "new") { - t.Fatalf("stableSiteSearchGUID leaked private token: %q", first) - } -} - -func TestSelectSiteSearchCandidatesWithStatsExplainsFiltering(t *testing.T) { - sub := &model.Subscription{Name: "Stats Show 自动订阅", Filter: "Stats Show", MediaType: "tv"} - seenItem := SearchResult{Title: "Stats Show S01E02 1080p", DownloadURL: "https://pt/download/seen", Seeders: 50} - seenGUID := stableSiteSearchGUID(seenItem, seenItem.DownloadURL) - results := []SearchResult{ - {Title: "Different Show S01E01 1080p", DownloadURL: "https://pt/download/wrong", Seeders: 90}, - {Title: "Stats Show S01E01 CAM", DownloadURL: "https://pt/download/cam", Seeders: 80}, - {Title: "Stats Show S01E02 1080p", Seeders: 70}, - seenItem, - {Title: "Stats Show S01E03 1080p", DownloadURL: "https://pt/download/right", Seeders: 60}, - } - - got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{seenGUID: {}}, LocalAvailability{}) - if len(got) != 1 || got[0].Download != "https://pt/download/right" { - t.Fatalf("selected %#v, want only unfiltered candidate", got) - } - if stats.Total != 5 || - stats.QueryMismatch != 1 || - stats.RuleMismatch != 1 || - stats.MissingDownload != 1 || - stats.Seen != 1 || - stats.Prepared != 1 || - stats.Selected != 1 { - t.Fatalf("unexpected stats: %#v", stats) - } - if len(stats.QueryMismatchExamples) != 1 || stats.QueryMismatchExamples[0] != "Different Show S01E01 1080p" { - t.Fatalf("query mismatch examples = %#v", stats.QueryMismatchExamples) - } -} - -func containsString(values []string, want string) bool { - for _, value := range values { - if value == want { - return true - } - } - return false -} diff --git a/internal/service/subscription_site_search_limit_test.go b/internal/service/subscription_site_search_limit_test.go deleted file mode 100644 index 38519e4..0000000 --- a/internal/service/subscription_site_search_limit_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package service - -import ( - "errors" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSearchSubscriptionSitesStopsAfterRateLimit(t *testing.T) { - var requests atomic.Int32 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - requests.Add(1) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`)) - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Site{}, &model.Setting{}) - repos := repository.New(db) - siteSvc := NewSiteService(zap.NewNop(), repos, "") - limiter := &staticSiteAPIRateLimiter{err: &siteAPIRateLimitError{ - Bucket: "torrent_search_24h", - Limit: 1500, - Window: 24 * time.Hour, - RetryAfter: time.Hour, - }} - siteSvc.apiRateLimiter = limiter - if err := siteSvc.Create(t.Context(), &model.Site{ - Name: "馒头", - Type: "mteam", - URL: upstream.URL, - AuthType: "api_key", - APIKey: "token-123", - Enabled: true, - Timeout: 5, - }); err != nil { - t.Fatal(err) - } - svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, siteSvc, NewHub(zap.NewNop())) - sub := &model.Subscription{Name: "问心2 自动订阅", Filter: "问心2", MediaType: "tv"} - - _, err := svc.searchSubscriptionSites(t.Context(), sub, []string{"问心2", "问心", "问心2 2023"}) - var limited *siteAPIRateLimitError - if !errors.As(err, &limited) { - t.Fatalf("searchSubscriptionSites error = %v, want siteAPIRateLimitError", err) - } - if limiter.calls != 1 { - t.Fatalf("rate limiter calls = %d, want 1 keyword attempt", limiter.calls) - } - if got := requests.Load(); got != 0 { - t.Fatalf("HTTP requests = %d, want 0 after local rate limit", got) - } -} - -func TestSubscriptionRunAllStopsSweepAfterRateLimit(t *testing.T) { - var requests atomic.Int32 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - requests.Add(1) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`)) - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Site{}, &model.Setting{}, &model.Subscription{}) - repos := repository.New(db) - siteSvc := NewSiteService(zap.NewNop(), repos, "") - limiter := &staticSiteAPIRateLimiter{err: &siteAPIRateLimitError{ - Bucket: "torrent_search_24h", - Limit: 1500, - Window: 24 * time.Hour, - RetryAfter: time.Hour, - }} - siteSvc.apiRateLimiter = limiter - if err := siteSvc.Create(t.Context(), &model.Site{ - Name: "馒头", - Type: "mteam", - URL: upstream.URL, - AuthType: "api_key", - APIKey: "token-123", - Enabled: true, - Timeout: 5, - }); err != nil { - t.Fatal(err) - } - for _, name := range []string{"问心2 自动订阅", "南部档案 自动订阅"} { - sub := &model.Subscription{ - Name: name, - FeedURL: "site-search://search?keyword=" + name, - Filter: name, - Enabled: true, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - } - svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, siteSvc, NewHub(zap.NewNop())) - - svc.runAll(t.Context()) - if limiter.calls != 1 { - t.Fatalf("rate limiter calls = %d, want sweep to stop after first quota failure", limiter.calls) - } - if got := requests.Load(); got != 0 { - t.Fatalf("HTTP requests = %d, want 0 after local rate limit", got) - } -} - -func TestSubscriptionSiteSearchStopsAfterTransientSiteErrors(t *testing.T) { - for _, errText := range []string{ - `search: Post "https://api.m-team.cc/api/torrent/search": context deadline exceeded`, - `search: Post "https://api.m-team.cc/api/torrent/search": net/http: TLS handshake timeout`, - `search: Post "https://api.m-team.cc/api/torrent/search": unexpected EOF`, - `search: Post "https://api.m-team.cc/api/torrent/search": read tcp 127.0.0.1: connection reset by peer`, - } { - if !subscriptionSiteSearchShouldStopOnError(errors.New(errText)) { - t.Fatalf("subscriptionSiteSearchShouldStopOnError(%q) = false, want true", errText) - } - } - if subscriptionSiteSearchShouldStopOnError(errors.New("temporary parser warning: no matching torrent rows")) { - t.Fatal("non-upstream-failure errors should not stop alias search") - } -} diff --git a/internal/service/subscription_test.go b/internal/service/subscription_test.go deleted file mode 100644 index a2b50f1..0000000 --- a/internal/service/subscription_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestDeleteSubscriptionRemovesDownloaderTaskAndSeenState(t *testing.T) { - const title = "Delete Subscription Show S01E01 1080p" - const hash = "abcdef1234567890abcdef1234567890abcdef12" - var deleteCalls atomic.Int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[{"hash":"` + hash + `","name":"` + title + `","state":"downloading","progress":0.2}]`)) - case "/api/v2/torrents/delete": - deleteCalls.Add(1) - if got := r.FormValue("deleteFiles"); got != "false" { - t.Fatalf("deleteFiles = %q, want false", got) - } - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - if err := downloads.ReloadConfig(t.Context()); err != nil { - t.Fatal(err) - } - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - sub := &model.Subscription{Name: "Delete Subscription Show 自动订阅", Filter: "Delete Subscription Show", FeedURL: "https://rss.example/feed", UserID: "u1", SavePath: "/downloads/tv"} - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - task := &model.DownloadTask{ - UserID: "u1", - SubscriptionID: sub.ID, - Source: "qbittorrent", - URL: "https://pt.example/download?id=1", - Title: title, - SavePath: "/downloads/tv", - Status: "downloading", - Progress: 0.2, - } - if err := repos.Download.Create(t.Context(), task); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "subscription."+sub.ID+".seen", "guid-1"); err != nil { - t.Fatal(err) - } - - if err := svc.Delete(t.Context(), sub.ID); err != nil { - t.Fatalf("delete subscription: %v", err) - } - if got := deleteCalls.Load(); got != 1 { - t.Fatalf("qb delete calls = %d, want 1", got) - } - var updated model.DownloadTask - if err := db.Where("id = ?", task.ID).First(&updated).Error; err != nil { - t.Fatal(err) - } - if updated.Status != "deleted" { - t.Fatalf("download task status = %q, want deleted", updated.Status) - } - seen, err := repos.Setting.Get(t.Context(), "subscription."+sub.ID+".seen") - if err != nil { - t.Fatal(err) - } - if seen != "" { - t.Fatalf("seen state = %q, want cleared", seen) - } - var count int64 - if err := db.Model(&model.Subscription{}).Where("id = ?", sub.ID).Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 0 { - t.Fatalf("active subscription count = %d, want 0", count) - } - var deleted model.Subscription - if err := db.Unscoped().Where("id = ?", sub.ID).First(&deleted).Error; err != nil { - t.Fatal(err) - } - if deleted.Enabled { - t.Fatal("deleted subscription stayed enabled; active legacy compatibility would show it again") - } - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 0 { - t.Fatalf("active subscriptions = %#v, want deleted subscription hidden", active) - } -} - -func TestDeletedDownloadTaskDoesNotBlockSubscriptionReadd(t *testing.T) { - if downloadTaskBlocksReadd("deleted") { - t.Fatal("deleted download task must not block subscription re-add") - } - if downloadTaskBlocksReadd("removed") { - t.Fatal("removed download task must not block subscription re-add") - } -} - -func TestListIncludesEnabledSoftDeletedActiveSubscription(t *testing.T) { - db := newServiceTestDB(t, &model.Subscription{}) - repos := repository.New(db) - sub := &model.Subscription{ - Name: "Hidden Active 自动订阅", - FeedURL: "site-search://search?keyword=Hidden%20Active", - Filter: "Hidden Active", - Enabled: true, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - if err := db.Where("id = ?", sub.ID).Delete(&model.Subscription{}).Error; err != nil { - t.Fatal(err) - } - - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 1 || active[0].ID != sub.ID { - t.Fatalf("active subscriptions = %#v, want soft-deleted enabled subscription recovered", active) - } -} - -func TestDeleteRecoveredSoftDeletedSubscriptionClearsSeenAndHidesIt(t *testing.T) { - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}) - repos := repository.New(db) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Name: "Recovered Hidden 自动订阅", - FeedURL: "site-search://search?keyword=Recovered%20Hidden", - Filter: "Recovered Hidden", - Enabled: true, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "subscription."+sub.ID+".seen", "old-guid"); err != nil { - t.Fatal(err) - } - if err := db.Where("id = ?", sub.ID).Delete(&model.Subscription{}).Error; err != nil { - t.Fatal(err) - } - - if err := svc.Delete(t.Context(), sub.ID); err != nil { - t.Fatal(err) - } - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 0 { - t.Fatalf("active subscriptions = %#v, want recovered deleted subscription hidden", active) - } - seen, err := repos.Setting.Get(t.Context(), "subscription."+sub.ID+".seen") - if err != nil { - t.Fatal(err) - } - if seen != "" { - t.Fatalf("seen state = %q, want cleared", seen) - } -} diff --git a/internal/service/subscription_torrent_rules.go b/internal/service/subscription_torrent_rules.go deleted file mode 100644 index 3d790ef..0000000 --- a/internal/service/subscription_torrent_rules.go +++ /dev/null @@ -1,56 +0,0 @@ -package service - -import ( - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -const bytesPerGiB = 1024 * 1024 * 1024 - -func matchesSubscriptionTorrentRules(sub *model.Subscription, item SearchResult) bool { - if sub == nil { - return true - } - if sub.MinSeeders > 0 && item.Seeders < sub.MinSeeders { - return false - } - if sub.MaxSeeders > 0 && item.Seeders > sub.MaxSeeders { - return false - } - if !subscriptionSizeInRange(item.Size, sub.MinSizeGB, sub.MaxSizeGB) { - return false - } - if sub.FreeOnly && !subscriptionResultIsFree(item) { - return false - } - return true -} - -func subscriptionSizeInRange(sizeBytes int64, minGB, maxGB float64) bool { - if minGB <= 0 && maxGB <= 0 { - return true - } - if sizeBytes <= 0 { - return false - } - sizeGB := float64(sizeBytes) / bytesPerGiB - if minGB > 0 && sizeGB < minGB { - return false - } - if maxGB > 0 && sizeGB > maxGB { - return false - } - return true -} - -func subscriptionResultIsFree(item SearchResult) bool { - if item.Free { - return true - } - text := strings.ToLower(subscriptionSearchResultText(item)) - return strings.Contains(text, "freeleech") || - strings.Contains(text, "2xfree") || - strings.Contains(text, "免费") || - matchesWordBoundary(text, "free") -} diff --git a/internal/service/subscription_torrent_rules_test.go b/internal/service/subscription_torrent_rules_test.go deleted file mode 100644 index 55e9372..0000000 --- a/internal/service/subscription_torrent_rules_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package service - -import ( - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestSelectSiteSearchCandidatesAppliesSeederSizeAndFreeRules(t *testing.T) { - sub := &model.Subscription{ - Name: "Some Show 自动订阅", - Filter: "Some Show", - MediaType: "tv", - MinSeeders: 10, - MaxSeeders: 100, - MinSizeGB: 1, - MaxSizeGB: 8, - FreeOnly: true, - } - results := []SearchResult{ - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/low-seed", Seeders: 3, Size: 2 * bytesPerGiB, Free: true}, - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/hot", Seeders: 500, Size: 2 * bytesPerGiB, Free: true}, - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/small", Seeders: 50, Size: bytesPerGiB / 2, Free: true}, - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/nonfree", Seeders: 50, Size: 2 * bytesPerGiB}, - {Title: "Some Show S01E01 1080p WEB-DL H264 AAC", DownloadURL: "https://pt/download/right", Seeders: 50, Size: 2 * bytesPerGiB, Free: true}, - } - - got, stats := selectSiteSearchCandidatesWithStats(results, sub, nil, LocalAvailability{}) - if len(got) != 1 || got[0].Download != "https://pt/download/right" { - t.Fatalf("selected %#v, want only torrent matching seed/size/free rules", got) - } - if stats.RuleMismatch != 4 || stats.Prepared != 1 || stats.Selected != 1 { - t.Fatalf("stats = %#v, want four rule mismatches and one selected", stats) - } -} - -func TestSubscriptionTorrentRulesRecognizeFreeLabels(t *testing.T) { - sub := &model.Subscription{FreeOnly: true} - for _, item := range []SearchResult{ - {Title: "Some Movie 2026 1080p WEB-DL FREE"}, - {Title: "Some Movie 2026 1080p WEB-DL FreeLeech"}, - {Title: "Some Movie 2026 1080p WEB-DL 免费"}, - {Title: "Some Movie 2026 1080p WEB-DL", Free: true}, - } { - if !matchesSubscriptionTorrentRules(sub, item) { - t.Fatalf("expected free rule to accept %#v", item) - } - } - if matchesSubscriptionTorrentRules(sub, SearchResult{Title: "Some Movie 2026 1080p WEB-DL"}) { - t.Fatal("free-only rule accepted non-free result") - } -} - -func TestSubscriptionTorrentRulesRejectUnknownSizeWhenSizeRangeConfigured(t *testing.T) { - sub := &model.Subscription{MinSizeGB: 1} - if matchesSubscriptionTorrentRules(sub, SearchResult{Title: "Some Movie 2026 1080p WEB-DL"}) { - t.Fatal("size range accepted result without size metadata") - } -} diff --git a/internal/service/subtitle.go b/internal/service/subtitle.go index f7ac854..8ddf4cf 100644 --- a/internal/service/subtitle.go +++ b/internal/service/subtitle.go @@ -31,24 +31,13 @@ import ( // SubtitleService is the discovery + conversion entry point. type SubtitleService struct { - log *zap.Logger - repo *repository.Container - storage *StorageConfigService + log *zap.Logger + repo *repository.Container } // NewSubtitleService is the constructor. -func NewSubtitleService(log *zap.Logger, repo *repository.Container, storage ...*StorageConfigService) *SubtitleService { - s := &SubtitleService{log: log, repo: repo} - if len(storage) > 0 { - s.storage = storage[0] - } - return s -} - -func (s *SubtitleService) SetStorageConfig(storage *StorageConfigService) { - if s != nil { - s.storage = storage - } +func NewSubtitleService(log *zap.Logger, repo *repository.Container) *SubtitleService { + return &SubtitleService{log: log, repo: repo} } // SubtitleTrack describes one external subtitle file. @@ -79,9 +68,6 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti if m == nil { return nil, errors.New("media not found") } - if strings.HasPrefix(strings.ToLower(strings.TrimSpace(m.Path)), "cloud://") { - return discoverCloudSubtitles(ctx, s, *m), nil - } dir := filepath.Dir(m.Path) base := strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path)) @@ -148,9 +134,6 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W if err != nil || m == nil { return errors.New("media not found") } - if typ, ref, name, ok := parseCloudSubtitlePath(sub); ok { - return serveCloudSubtitle(ctx, s, *m, typ, ref, name, w) - } abs, err := filepath.Abs(sub) if err != nil { return err @@ -195,9 +178,6 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i if err != nil || m == nil { return errors.New("media not found") } - if typ, ref, name, ok := parseCloudSubtitlePath(sub); ok { - return serveCloudSubtitleRaw(ctx, s, *m, typ, ref, name, w) - } abs, err := filepath.Abs(sub) if err != nil { return err diff --git a/internal/service/subtitle_cloud.go b/internal/service/subtitle_cloud.go deleted file mode 100644 index 242ba0d..0000000 --- a/internal/service/subtitle_cloud.go +++ /dev/null @@ -1,208 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "io" - "net/url" - "path/filepath" - "strings" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" -) - -func discoverCloudSubtitles(ctx context.Context, s *SubtitleService, m model.Media) []SubtitleTrack { - if s == nil || s.storage == nil { - return []SubtitleTrack{} - } - typ, mediaRef, ok := cloudSubtitleMediaRef(m) - if !ok { - return []SubtitleTrack{} - } - dirRef, mediaName := splitCloudRef(mediaRef) - if mediaName == "" { - return []SubtitleTrack{} - } - base := strings.TrimSuffix(mediaName, filepath.Ext(mediaName)) - entries, err := s.storage.CloudList(ctx, typ, dirRef) - if err != nil { - if s.log != nil { - s.log.Debug("list cloud subtitles failed", zap.String("provider", typ), zap.String("dir", dirRef), zap.Error(err)) - } - return []SubtitleTrack{} - } - tracks := cloudSubtitleTracks(typ, entries, base, false) - for _, entry := range entries { - if !entry.IsDir || !isSubtitleDirectory(entry.Name) || strings.TrimSpace(entry.ID) == "" { - continue - } - subEntries, err := s.storage.CloudList(ctx, typ, entry.ID) - if err != nil { - continue - } - tracks = append(tracks, cloudSubtitleTracks(typ, subEntries, base, true)...) - } - return tracks -} - -func cloudSubtitleTracks(typ string, entries []cloud.FileEntry, base string, subdir bool) []SubtitleTrack { - tracks := make([]SubtitleTrack, 0) - baseLower := strings.ToLower(base) - for _, entry := range entries { - if entry.IsDir { - continue - } - ext := strings.ToLower(filepath.Ext(entry.Name)) - codec, ok := extToCodec[ext] - if !ok { - continue - } - fullName := strings.TrimSuffix(entry.Name, ext) - if !subdir && !strings.HasPrefix(strings.ToLower(fullName), baseLower) { - continue - } - ref := cloudEntryRef(typ, entry.ID, entry.PickCode) - if ref == "" { - continue - } - lang := detectLang(fullName, base) - tracks = append(tracks, SubtitleTrack{ - Lang: lang, - Label: lang, - Path: buildCloudSubtitlePath(typ, ref, entry.Name), - Codec: codec, - }) - } - return tracks -} - -func cloudSubtitleMediaRef(m model.Media) (typ, ref string, ok bool) { - if info, parsed := ParseCloudLibraryMount(m.Path); parsed && strings.TrimSpace(info.DisplayDir) != "" { - return info.Provider, info.DisplayDir, true - } - if typ, ref, parsed := parseCloudMediaPlaybackURL(m.STRMURL); parsed { - return typ, ref, true - } - return "", "", false -} - -func splitCloudRef(ref string) (dir, name string) { - ref = strings.Trim(strings.ReplaceAll(strings.TrimSpace(ref), "\\", "/"), "/") - if ref == "" { - return "", "" - } - idx := strings.LastIndex(ref, "/") - if idx < 0 { - return "", ref - } - return ref[:idx], ref[idx+1:] -} - -func isSubtitleDirectory(name string) bool { - switch strings.ToLower(strings.TrimSpace(name)) { - case "subs", "sub", ".sub", "subtitles", "subtitle": - return true - default: - return false - } -} - -func buildCloudSubtitlePath(typ, ref, name string) string { - u := url.URL{ - Scheme: "cloud", - Host: strings.TrimSpace(typ), - Path: "/" + strings.TrimLeft(strings.TrimSpace(ref), "/"), - } - q := u.Query() - q.Set("name", strings.TrimSpace(name)) - u.RawQuery = q.Encode() - return u.String() -} - -func parseCloudSubtitlePath(raw string) (typ, ref, name string, ok bool) { - u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || strings.ToLower(u.Scheme) != "cloud" || strings.TrimSpace(u.Host) == "" { - return "", "", "", false - } - ref = strings.TrimLeft(u.EscapedPath(), "/") - if decoded, err := url.PathUnescape(ref); err == nil { - ref = decoded - } - return strings.TrimSpace(u.Host), strings.TrimSpace(ref), strings.TrimSpace(u.Query().Get("name")), ref != "" -} - -func serveCloudSubtitle(ctx context.Context, s *SubtitleService, m model.Media, typ, ref, name string, w io.Writer) error { - if s == nil || s.storage == nil { - return errors.New("cloud storage service unavailable") - } - mediaTyp, _, ok := cloudSubtitleMediaRef(m) - if !ok || mediaTyp != typ { - return ErrCloudPlaybackUnavailable - } - allowed := false - for _, track := range discoverCloudSubtitles(ctx, s, m) { - if track.Path == buildCloudSubtitlePath(typ, ref, name) { - allowed = true - break - } - } - if !allowed { - return fmt.Errorf("path escape") - } - body, err := s.storage.CloudReadText(ctx, typ, ref, 8<<20) - if err != nil { - return err - } - ext := strings.ToLower(filepath.Ext(firstNonEmpty(name, ref))) - switch ext { - case ".vtt": - _, err = io.WriteString(w, body) - case ".srt": - _, err = io.WriteString(w, srtToVTT(body)) - case ".ass", ".ssa": - _, err = io.WriteString(w, assToVTT(body)) - default: - return errors.New("unsupported subtitle format") - } - return err -} - -// serveCloudSubtitleRaw streams a cloud subtitle in its original format -// without WebVTT conversion, for Emby clients that parse by the advertised -// Codec. Shares the same access-control (discovery whitelist) checks as -// serveCloudSubtitle. -func serveCloudSubtitleRaw(ctx context.Context, s *SubtitleService, m model.Media, typ, ref, name string, w io.Writer) error { - if s == nil || s.storage == nil { - return errors.New("cloud storage service unavailable") - } - mediaTyp, _, ok := cloudSubtitleMediaRef(m) - if !ok || mediaTyp != typ { - return ErrCloudPlaybackUnavailable - } - allowed := false - for _, track := range discoverCloudSubtitles(ctx, s, m) { - if track.Path == buildCloudSubtitlePath(typ, ref, name) { - allowed = true - break - } - } - if !allowed { - return fmt.Errorf("path escape") - } - body, err := s.storage.CloudReadText(ctx, typ, ref, 8<<20) - if err != nil { - return err - } - ext := strings.ToLower(filepath.Ext(firstNonEmpty(name, ref))) - switch ext { - case ".vtt", ".srt", ".ass", ".ssa": - _, err = io.WriteString(w, body) - default: - return errors.New("unsupported subtitle format") - } - return err -} diff --git a/internal/service/telegram_admin_codes.go b/internal/service/telegram_admin_codes.go deleted file mode 100644 index 9a86057..0000000 --- a/internal/service/telegram_admin_codes.go +++ /dev/null @@ -1,133 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) replyCapacity(ctx context.Context) telegramCommandReply { - c := s.loadCapacity(ctx) - quota := "未开放" - if c.OpenRegOn { - if c.OpenRegLimit > 0 { - quota = fmt.Sprintf("已开放(%d/%d 名额)", c.OpenRegUsed, c.OpenRegLimit) - } else { - quota = "已开放(不限名额,受授权上限约束)" - } - } - text := fmt.Sprintf("容量 / 状态\n\n授权上限:%d 人(随凭证授权实时变化)\n已用:%d 人\n剩余可注册:%d 人\n开注状态:%s", - c.MaxUsers, c.UsedUsers, c.Remaining(), quota) - return telegramCommandReply{Text: text, Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}} -} - -func (s *TelegramBotService) replyOpenRegMenu(ctx context.Context) telegramCommandReply { - c := s.loadCapacity(ctx) - state := "未开放" - if c.OpenRegOn { - state = fmt.Sprintf("已开放(%d/%d)", c.OpenRegUsed, c.OpenRegLimit) - } - return telegramCommandReply{ - Text: "开注设置\n当前:" + state + "\n选择要开放的名额:", - Buttons: [][]telegramInlineButton{ - {{Text: "5 个", Data: "adm_openreg_set:5"}, {Text: "10 个", Data: "adm_openreg_set:10"}, {Text: "20 个", Data: "adm_openreg_set:20"}}, - {{Text: "不限名额", Data: "adm_openreg_set:0"}, {Text: "关闭注册", Data: "adm_openreg_close"}}, - {{Text: "⬅️ 返回菜单", Data: "menu_main"}}, - }, - } -} - -func (s *TelegramBotService) replyGenCodeMenu() telegramCommandReply { - return telegramCommandReply{ - Text: "生成兑换码\n选择类型与时长:", - Buttons: [][]telegramInlineButton{ - {{Text: "注册码·30天", Data: "gc:register:30"}, {Text: "注册码·永久", Data: "gc:register:0"}}, - {{Text: "续期码·30天", Data: "gc:renew:30"}, {Text: "续期码·90天", Data: "gc:renew:90"}}, - {{Text: "⬅️ 返回菜单", Data: "menu_main"}}, - }, - } -} - -func (s *TelegramBotService) replyGenCode(ctx context.Context, msg *TelegramMessage, data string) telegramCommandReply { - parts := strings.Split(data, ":") // gc:: - if len(parts) != 3 { - return telegramCommandReply{Text: "参数错误。"} - } - kind := parts[1] - days, _ := strconv.Atoi(parts[2]) - createdBy := "" - if u := s.boundUser(ctx, msg.From.ID); u != nil { - createdBy = u.ID - } - code, err := s.generateCode(ctx, kind, days, 0, createdBy) - if err != nil { - return telegramCommandReply{Text: "生成失败:" + err.Error()} - } - kindLabel := map[string]string{model.RegistrationCodeRegister: "注册码", model.RegistrationCodeRenew: "续期码"}[code.Kind] - dur := "永久" - if days > 0 { - dur = fmt.Sprintf("%d 天", days) - } - return telegramCommandReply{ - Text: fmt.Sprintf("已生成%s(%s):\n\n%s\n\n发给用户在 Bot 中兑换即可。", kindLabel, dur, code.Code), - Buttons: [][]telegramInlineButton{{{Text: "再生成一个", Data: "adm_gencode"}, {Text: "⬅️ 返回菜单", Data: "menu_main"}}}, - } -} - -func (s *TelegramBotService) cmdGenCode(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { - if len(args) < 2 { - return telegramCommandReply{Text: "用法:/gencode register|renew 天数 [有效天数] [可用次数]\n示例:/gencode register 30、/gencode renew 90 7 5"} - } - kind := strings.ToLower(strings.TrimSpace(args[0])) - switch kind { - case "reg", "register", "注册码": - kind = model.RegistrationCodeRegister - case "renew", "续期", "续期码": - kind = model.RegistrationCodeRenew - default: - return telegramCommandReply{Text: "类型无效,只支持 register / renew。"} - } - days, err := strconv.Atoi(args[1]) - if err != nil || days < 0 { - return telegramCommandReply{Text: "天数必须是非负整数,0 表示永久。"} - } - validDays := 0 - if len(args) > 2 { - validDays, err = strconv.Atoi(args[2]) - if err != nil || validDays < 0 { - return telegramCommandReply{Text: "有效天数必须是非负整数。"} - } - } - maxUses := 1 - if len(args) > 3 { - maxUses, err = strconv.Atoi(args[3]) - if err != nil || maxUses <= 0 { - return telegramCommandReply{Text: "可用次数必须是正整数。"} - } - } - createdBy := "" - if u := s.boundUser(ctx, msg.From.ID); u != nil { - createdBy = u.ID - } - code, err := s.generateCodeWithUses(ctx, kind, days, validDays, maxUses, createdBy) - if err != nil { - return telegramCommandReply{Text: "生成失败:" + err.Error()} - } - kindLabel := map[string]string{model.RegistrationCodeRegister: "注册码", model.RegistrationCodeRenew: "续期码"}[code.Kind] - dur := "永久" - if days > 0 { - dur = fmt.Sprintf("%d 天", days) - } - valid := "长期有效" - if validDays > 0 && code.ExpiresAt != nil { - valid = "有效至 " + code.ExpiresAt.Format("2006-01-02 15:04") - } - uses := "单次使用" - if code.EffectiveMaxUses() > 1 { - uses = fmt.Sprintf("最多 %d 次", code.EffectiveMaxUses()) - } - return telegramCommandReply{Text: fmt.Sprintf("已生成%s(%s,%s,%s):\n\n%s", kindLabel, dur, valid, uses, code.Code)} -} diff --git a/internal/service/telegram_admin_users.go b/internal/service/telegram_admin_users.go deleted file mode 100644 index 2653af2..0000000 --- a/internal/service/telegram_admin_users.go +++ /dev/null @@ -1,186 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" -) - -func (s *TelegramBotService) replyUserList(ctx context.Context) telegramCommandReply { - users, err := s.repo.User.List(ctx) - if err != nil { - return telegramCommandReply{Text: "读取用户失败:" + err.Error()} - } - if len(users) == 0 { - return telegramCommandReply{Text: "暂无用户。"} - } - var rows [][]telegramInlineButton - limit := len(users) - if limit > 12 { - limit = 12 - } - for i := 0; i < limit; i++ { - u := users[i] - flag := "" - if !u.IsActive { - flag = "🚫" - } - if u.Role == "admin" { - flag = "👑" - } - rows = append(rows, []telegramInlineButton{{Text: flag + " " + u.Username, Data: "usr:" + u.ID}}) - } - rows = append(rows, []telegramInlineButton{{Text: "⬅️ 返回菜单", Data: "menu_main"}}) - return telegramCommandReply{Text: fmt.Sprintf("用户管理(共 %d 人,显示前 %d)\n点击用户进行操作:", len(users), limit), Buttons: rows} -} - -func (s *TelegramBotService) replyUserActions(ctx context.Context, userID string) telegramCommandReply { - u, err := s.repo.User.FindByID(ctx, userID) - if err != nil || u == nil { - return telegramCommandReply{Text: "用户不存在。"} - } - protected := UserIsProtectedAccount(ctx, s.repo, u) - text := fmt.Sprintf("%s\n角色:%s\n状态:%s\n到期:%s\n防共享警告:%d 次", - u.Username, u.Role, map[bool]string{true: "正常", false: "已禁用"}[u.IsActive], formatExpiry(u.ExpiredAt), u.ShareWarnings) - if protected { - return telegramCommandReply{Text: text + "\n\n(受保护账号,不可禁用/删除)", Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回", Data: "adm_users"}}}} - } - banBtn := telegramInlineButton{Text: "🚫 禁用", Data: "uban:" + u.ID} - if !u.IsActive { - banBtn = telegramInlineButton{Text: "✅ 解禁", Data: "uunban:" + u.ID} - } - return telegramCommandReply{ - Text: text, - Buttons: [][]telegramInlineButton{ - {banBtn, {Text: "⏳ 续期30天", Data: "urenew:" + u.ID + ":30"}}, - {{Text: "🗑 删除用户", Data: "udel:" + u.ID}}, - {{Text: "⬅️ 返回", Data: "adm_users"}}, - }, - } -} - -func (s *TelegramBotService) replyUserBan(ctx context.Context, userID string, unban bool) telegramCommandReply { - if !unban { - if reason := s.protectReason(ctx, userID); reason != "" { - return telegramCommandReply{Text: reason} - } - } - updates := map[string]any{"is_active": unban} - if unban { - updates["share_warnings"] = 0 - updates["last_share_warn_at"] = nil - } - if err := s.repo.User.UpdateFields(ctx, userID, updates); err != nil { - return telegramCommandReply{Text: "操作失败:" + err.Error()} - } - if unban { - _ = s.repo.UserDevice.SetKickedByUser(ctx, userID, false) - } - return s.replyUserActions(ctx, userID) -} - -func (s *TelegramBotService) replyUserDelete(ctx context.Context, userID string) telegramCommandReply { - if reason := s.protectReason(ctx, userID); reason != "" { - return telegramCommandReply{Text: reason} - } - u, _ := s.repo.User.FindByID(ctx, userID) - _ = s.repo.UserDevice.DeleteByUser(ctx, userID) - if err := s.repo.User.Delete(ctx, userID); err != nil { - return telegramCommandReply{Text: "删除失败:" + err.Error()} - } - name := userID - if u != nil { - name = u.Username - } - return telegramCommandReply{Text: fmt.Sprintf("已删除用户 %s。", name), Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回", Data: "adm_users"}}}} -} - -func (s *TelegramBotService) replyUserRenew(ctx context.Context, payload string) telegramCommandReply { - parts := strings.Split(payload, ":") // : - if len(parts) != 2 { - return telegramCommandReply{Text: "参数错误。"} - } - days, _ := strconv.Atoi(parts[1]) - if err := s.applyRenewal(ctx, parts[0], days); err != nil { - return telegramCommandReply{Text: "续期失败:" + err.Error()} - } - return s.replyUserActions(ctx, parts[0]) -} - -func (s *TelegramBotService) cmdUserRenew(ctx context.Context, args []string) telegramCommandReply { - if len(args) < 2 { - return telegramCommandReply{Text: "用法:/renew_user 用户名 天数,天数 0 表示永久。"} - } - user, _ := s.repo.User.FindByUsername(ctx, args[0]) - if user == nil { - user, _ = s.repo.User.FindByID(ctx, args[0]) - } - if user == nil { - return telegramCommandReply{Text: "未找到用户。"} - } - days, err := strconv.Atoi(args[1]) - if err != nil || days < 0 { - return telegramCommandReply{Text: "天数必须是非负整数。"} - } - if err := s.applyRenewal(ctx, user.ID, days); err != nil { - return telegramCommandReply{Text: "续期失败:" + err.Error()} - } - return s.replyUserActions(ctx, user.ID) -} - -func (s *TelegramBotService) cmdUserDelete(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 { - return telegramCommandReply{Text: "用法:/delete_user 用户名 confirm\n为避免误删,最后一个参数必须是 confirm。"} - } - if len(args) < 2 || !strings.EqualFold(args[len(args)-1], "confirm") { - return telegramCommandReply{Text: "删除用户需要确认:/delete_user 用户名 confirm"} - } - user, _ := s.repo.User.FindByUsername(ctx, args[0]) - if user == nil { - user, _ = s.repo.User.FindByID(ctx, args[0]) - } - if user == nil { - return telegramCommandReply{Text: "未找到用户。"} - } - return s.replyUserDelete(ctx, user.ID) -} - -// protectReason returns a non-empty message when a user must not be -// disabled/deleted (admins, default admin and protected-list users). -func (s *TelegramBotService) protectReason(ctx context.Context, userID string) string { - u, err := s.repo.User.FindByID(ctx, userID) - if err != nil || u == nil { - return "用户不存在。" - } - if u.Role == "admin" { - return "管理员账号受保护,不可禁用/删除。" - } - if first, _ := s.repo.User.FirstAdmin(ctx); first != nil && first.ID == u.ID { - return "默认管理员账号受保护,不可禁用/删除。" - } - if _, ok := ProtectedUserIDSet(ctx, s.repo)[u.ID]; ok { - return "该账号在 Bot 保护名单中,不可禁用/删除。" - } - if s.device != nil && s.device.UserRecentlyActive(ctx, u.ID, realtimeSessionTTL) { - return "该账号最近仍有实时活跃会话,为避免误删/误禁用,请先确认用户已下线。" - } - return "" -} - -func (s *TelegramBotService) cmdUserBan(ctx context.Context, args []string, unban bool) telegramCommandReply { - if len(args) == 0 { - if unban { - return telegramCommandReply{Text: "用法:/unban 用户名"} - } - return telegramCommandReply{Text: "用法:/ban 用户名"} - } - user, _ := s.repo.User.FindByUsername(ctx, args[0]) - if user == nil { - user, _ = s.repo.User.FindByID(ctx, args[0]) - } - if user == nil { - return telegramCommandReply{Text: "未找到用户。"} - } - return s.replyUserBan(ctx, user.ID, unban) -} diff --git a/internal/service/telegram_api.go b/internal/service/telegram_api.go deleted file mode 100644 index 6ea8383..0000000 --- a/internal/service/telegram_api.go +++ /dev/null @@ -1,232 +0,0 @@ -package service - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/url" - "strings" - "time" -) - -func telegramPostForm(ctx context.Context, cfg map[string]string, method string, form url.Values, timeout time.Duration) error { - apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method) - if err != nil { - return err - } - return telegramDoWithFallback(ctx, cfg, http.MethodPost, apiURL, form.Encode(), "application/x-www-form-urlencoded", timeout) -} - -func telegramPostJSON(ctx context.Context, cfg map[string]string, method string, payload any, timeout time.Duration) error { - apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method) - if err != nil { - return err - } - body, err := json.Marshal(payload) - if err != nil { - return err - } - return telegramDoWithFallback(ctx, cfg, http.MethodPost, apiURL, string(body), "application/json", timeout) -} - -func telegramPostMultipart(ctx context.Context, cfg map[string]string, method string, fields map[string]string, fileField, fileName string, file []byte, timeout time.Duration) error { - apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method) - if err != nil { - return err - } - var body bytes.Buffer - writer := multipart.NewWriter(&body) - for key, value := range fields { - if strings.TrimSpace(value) == "" { - continue - } - if err := writer.WriteField(key, value); err != nil { - _ = writer.Close() - return err - } - } - part, err := writer.CreateFormFile(fileField, fileName) - if err != nil { - _ = writer.Close() - return err - } - if _, err := part.Write(file); err != nil { - _ = writer.Close() - return err - } - if err := writer.Close(); err != nil { - return err - } - return telegramDoWithFallback(ctx, cfg, http.MethodPost, apiURL, body.String(), writer.FormDataContentType(), timeout) -} - -func telegramFetchRemotePhoto(ctx context.Context, cfg map[string]string, rawURL string, timeout time.Duration) ([]byte, string, error) { - rawURL = strings.TrimSpace(rawURL) - if rawURL == "" { - return nil, "", errors.New("telegram photo url required") - } - var lastErr error - for _, client := range telegramHTTPClients(timeout, cfg) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) - if err != nil { - return nil, "", err - } - req.Header.Set("User-Agent", "MediaStationGo/1.0") - resp, err := client.Do(req) - if err != nil { - lastErr = sanitizeTelegramError(err) - continue - } - body, readErr := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024+1)) - _ = resp.Body.Close() - if readErr != nil { - lastErr = readErr - continue - } - if resp.StatusCode >= 400 { - lastErr = fmt.Errorf("photo fetch error %d", resp.StatusCode) - continue - } - if len(body) == 0 { - lastErr = errors.New("photo fetch returned empty body") - continue - } - if len(body) > 10*1024*1024 { - lastErr = errors.New("photo too large") - continue - } - contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) - return body, contentType, nil - } - if lastErr != nil { - return nil, "", lastErr - } - return nil, "", errors.New("photo fetch failed") -} - -func deleteTelegramWebhook(ctx context.Context, cfg map[string]string) error { - payload := map[string]any{ - "drop_pending_updates": false, - } - return telegramPostJSON(ctx, cfg, "deleteWebhook", payload, 15*time.Second) -} - -func telegramDo(client *http.Client, req *http.Request) error { - resp, err := client.Do(req) - if err != nil { - return sanitizeTelegramError(err) - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(body))) - } - return nil -} - -func telegramDoWithFallback(ctx context.Context, cfg map[string]string, method, apiURL, body, contentType string, timeout time.Duration) error { - var lastErr error - for _, client := range telegramHTTPClients(timeout, cfg) { - req, err := http.NewRequestWithContext(ctx, method, apiURL, strings.NewReader(body)) - if err != nil { - return err - } - if contentType != "" { - req.Header.Set("Content-Type", contentType) - } - if err := telegramDo(client, req); err != nil { - lastErr = err - continue - } - return nil - } - if lastErr != nil { - return lastErr - } - return errors.New("telegram request failed") -} - -func telegramPostJSONDecode(ctx context.Context, cfg map[string]string, method string, payload any, timeout time.Duration, out any) error { - apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method) - if err != nil { - return err - } - body, err := json.Marshal(payload) - if err != nil { - return err - } - var lastErr error - for _, client := range telegramHTTPClients(timeout, cfg) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(string(body))) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - resp, err := client.Do(req) - if err != nil { - lastErr = sanitizeTelegramError(err) - continue - } - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - _ = resp.Body.Close() - if resp.StatusCode >= 400 { - lastErr = fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(respBody))) - continue - } - if out != nil { - return json.Unmarshal(respBody, out) - } - return nil - } - if lastErr != nil { - return lastErr - } - return errors.New("telegram request failed") -} - -func telegramGetJSONDecode(ctx context.Context, cfg map[string]string, method string, timeout time.Duration, out any) error { - apiURL, err := telegramMethodURL(cfg, cfg["bot_token"], method) - if err != nil { - return err - } - var lastErr error - for _, client := range telegramHTTPClients(timeout, cfg) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) - if err != nil { - return err - } - resp, err := client.Do(req) - if err != nil { - lastErr = sanitizeTelegramError(err) - continue - } - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - _ = resp.Body.Close() - if resp.StatusCode >= 400 { - lastErr = fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(respBody))) - continue - } - if out != nil { - return json.Unmarshal(respBody, out) - } - return nil - } - if lastErr != nil { - return lastErr - } - return errors.New("telegram request failed") -} - -func telegramStringConfigFromAny(cfg map[string]any) map[string]string { - out := make(map[string]string, len(cfg)) - for key, value := range cfg { - out[key] = str(value) - } - normalizeTelegramConfig(out) - return out -} diff --git a/internal/service/telegram_api_client.go b/internal/service/telegram_api_client.go deleted file mode 100644 index 3a88709..0000000 --- a/internal/service/telegram_api_client.go +++ /dev/null @@ -1,124 +0,0 @@ -package service - -import ( - "errors" - "fmt" - "net/http" - "net/url" - "os" - "regexp" - "strings" - "time" -) - -const defaultTelegramAPIBaseURL = "https://api.telegram.org" - -var telegramTokenPattern = regexp.MustCompile(`bot[0-9]+:[^/\s"'?]+`) - -func telegramAPIBaseURL(cfg map[string]string) string { - base := strings.TrimSpace(cfg["api_base_url"]) - if base == "" { - base = strings.TrimSpace(os.Getenv("MEDIASTATION_TELEGRAM_API_BASE_URL")) - } - if base == "" { - base = defaultTelegramAPIBaseURL - } - return strings.TrimRight(base, "/") -} - -func telegramMethodURL(cfg map[string]string, botToken, method string) (string, error) { - botToken = strings.TrimSpace(botToken) - method = strings.TrimSpace(method) - if botToken == "" { - return "", errors.New("telegram bot_token required") - } - if method == "" { - return "", errors.New("telegram method required") - } - base := telegramAPIBaseURL(cfg) - if _, err := url.ParseRequestURI(base); err != nil { - return "", fmt.Errorf("telegram api_base_url invalid") - } - return fmt.Sprintf("%s/bot%s/%s", base, botToken, method), nil -} - -func telegramHTTPClient(timeout time.Duration, cfg map[string]string) *http.Client { - clients := telegramHTTPClients(timeout, cfg) - return clients[0] -} - -func telegramHTTPClients(timeout time.Duration, cfg map[string]string) []*http.Client { - clients := []*http.Client{} - seen := map[string]bool{} - customAPIBase := telegramUsesCustomAPIBase(cfg) - for _, proxyRaw := range telegramProxyCandidates(cfg) { - proxyURL, err := normalizeProxyURL(proxyRaw, "http") - if err != nil || proxyURL == nil { - continue - } - key := proxyURL.String() - if seen[key] { - continue - } - seen[key] = true - transport := NewExternalTransport() - transport.Proxy = http.ProxyURL(proxyURL) - clients = append(clients, &http.Client{Timeout: timeout, Transport: transport}) - } - transport := NewExternalTransport() - if customAPIBase { - transport = NewInternalTransport() - } - clients = append(clients, &http.Client{Timeout: timeout, Transport: transport}) - return clients -} - -func telegramProxyCandidates(cfg map[string]string) []string { - out := []string{} - for _, value := range []string{ - cfg["proxy_url"], - os.Getenv("MEDIASTATION_TELEGRAM_PROXY_URL"), - } { - if strings.TrimSpace(value) != "" { - out = append(out, value) - } - } - if len(out) > 0 { - return out - } - if telegramUsesCustomAPIBase(cfg) { - return out - } - for _, value := range []string{ - "http://127.0.0.1:10808", - "http://127.0.0.1:10809", - "http://127.0.0.1:7890", - "http://127.0.0.1:7891", - "http://host.docker.internal:7890", - "http://host.docker.internal:10808", - "http://172.17.0.1:7890", - "http://172.17.0.1:10808", - } { - out = append(out, value) - } - return out -} - -func telegramUsesCustomAPIBase(cfg map[string]string) bool { - return telegramAPIBaseURL(cfg) != defaultTelegramAPIBaseURL -} - -func sanitizeTelegramError(err error) error { - if err == nil { - return nil - } - msg := sanitizeTelegramText(err.Error()) - if strings.Contains(msg, "Client.Timeout exceeded") || strings.Contains(msg, "context deadline exceeded") { - return errors.New("telegram request timeout: 请检查 NAS/Docker 到 Telegram API 的代理、反代或网络连通性") - } - return errors.New(msg) -} - -func sanitizeTelegramText(text string) string { - return telegramTokenPattern.ReplaceAllString(text, "bot") -} diff --git a/internal/service/telegram_api_commands_test.go b/internal/service/telegram_api_commands_test.go deleted file mode 100644 index 302806a..0000000 --- a/internal/service/telegram_api_commands_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package service - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestRegisterTelegramBotCommands(t *testing.T) { - var gotPath string - var payloads []struct { - Commands []telegramBotCommand `json:"commands"` - Scope map[string]any `json:"scope"` - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - var payload struct { - Commands []telegramBotCommand `json:"commands"` - Scope map[string]any `json:"scope"` - } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("decode payload: %v", err) - } - payloads = append(payloads, payload) - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer server.Close() - - err := registerTelegramBotCommands(t.Context(), map[string]string{ - "bot_token": "123456:ABC", - "api_base_url": server.URL, - }) - if err != nil { - t.Fatal(err) - } - if gotPath != "/bot123456:ABC/setMyCommands" { - t.Fatalf("path = %q", gotPath) - } - if len(payloads) < 3 { - t.Fatalf("expected default/private/group command registrations, got %d", len(payloads)) - } - if len(payloads[0].Commands) == 0 || payloads[0].Commands[0].Command != "start" { - t.Fatalf("commands not registered: %#v", payloads[0].Commands) - } - var groupCommands []telegramBotCommand - for _, payload := range payloads { - if payload.Scope["type"] == "all_group_chats" { - groupCommands = payload.Commands - break - } - } - if len(groupCommands) == 0 { - t.Fatal("group command scope was not registered") - } - for _, command := range groupCommands { - if command.Command == "users" || command.Command == "status" || command.Command == "cleanup" || command.Command == "register" || command.Command == "redeem" { - t.Fatalf("group commands must not expose private/admin command %q", command.Command) - } - } -} - -func TestDeleteTelegramWebhookBeforePolling(t *testing.T) { - var gotPath string - var payload map[string]any - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("decode payload: %v", err) - } - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer server.Close() - - err := deleteTelegramWebhook(t.Context(), map[string]string{ - "bot_token": "123456:ABC", - "api_base_url": server.URL, - }) - if err != nil { - t.Fatal(err) - } - if gotPath != "/bot123456:ABC/deleteWebhook" { - t.Fatalf("path = %q", gotPath) - } - if got := payload["drop_pending_updates"]; got != false { - t.Fatalf("drop_pending_updates = %#v, want false", got) - } -} - -func TestTelegramCommandMenusSeparateGroupAndAdminCommands(t *testing.T) { - privateNames := telegramCommandNames(telegramPrivateBotCommandMenu()) - for _, required := range []string{"setname", "setpass"} { - if !privateNames[required] { - t.Fatalf("private menu should include %s", required) - } - } - for _, hiddenAlias := range []string{"myinfo", "count"} { - if privateNames[hiddenAlias] { - t.Fatalf("private menu should hide compatibility alias %s", hiddenAlias) - } - if !telegramSupportedCommand("/" + hiddenAlias) { - t.Fatalf("compatibility alias /%s should remain executable", hiddenAlias) - } - } - - groupNames := telegramCommandNames(telegramGroupBotCommandMenu()) - for _, forbidden := range []string{"status", "search", "downloads", "stats", "users", "cleanup", "cleanup_rule", "register", "redeem"} { - if groupNames[forbidden] { - t.Fatalf("group menu should not expose %s", forbidden) - } - } - for _, required := range []string{"start", "menu", "help", "account", "signin", "devices", "kick", "hideadult"} { - if !groupNames[required] { - t.Fatalf("group menu should include %s", required) - } - } - adminCommands := telegramAdminBotCommandMenu() - adminNames := telegramCommandNames(adminCommands) - for _, required := range []string{"users", "status", "cleanup_mode", "cleanup_rule", "ucr", "uinfo", "rmemby", "only_rm_record", "renewall", "userip", "auditip", "auditdevice", "auditclient", "udeviceid", "syncunbound", "syncgroupm", "check_ex", "deleted", "embyadmin", "banall", "unbanall", "prouser", "revuser", "embylibs_blockall", "embylibs_unblockall", "proadmin", "revadmin", "backup_db", "restore_from_db"} { - if !adminNames[required] { - t.Fatalf("admin menu should include %s", required) - } - } - for _, hiddenAlias := range []string{"myinfo", "count", "low_activity", "urm", "only_rm_emby", "extraembylibs_blockall", "extraembylibs_unblockall"} { - if adminNames[hiddenAlias] { - t.Fatalf("admin menu should hide compatibility alias %s", hiddenAlias) - } - } - for _, command := range adminCommands { - if strings.Contains(command.Description, "Mgo 兼容") { - t.Fatalf("admin menu command %s should use native Mgo wording: %q", command.Command, command.Description) - } - } - help := telegramMgoAdminCommandHelp() - for _, want := range []string{"用户:", "审计:", "清理:", "权限:", "运维:"} { - if !strings.Contains(help, want) { - t.Fatalf("mgo admin help should include category %q in %q", want, help) - } - } - if strings.Contains(help, "/setpass") { - t.Fatalf("mgo admin help should not include user self-service command /setpass") - } -} - -func telegramCommandNames(commands []telegramBotCommand) map[string]bool { - names := make(map[string]bool, len(commands)) - for _, command := range commands { - names[command.Command] = true - } - return names -} - -func TestTelegramCommandFiltering(t *testing.T) { - if telegramIsCommandText("今天看什么") { - t.Fatal("plain chat message should not be treated as command") - } - if !telegramIsCommandText("/start user pass") { - t.Fatal("/start should be treated as command") - } - if got := telegramCommandName("/hideadult@MediaStationGoBot on"); got != "/hideadult" { - t.Fatalf("telegramCommandName = %q, want /hideadult", got) - } - if telegramSupportedCommand("/签到") { - t.Fatal("unrelated group bot command should not be handled") - } - for _, cmd := range []string{"/signin", "/redeem", "/gencode", "/users", "/renew_user", "/delete_user", "/cleanup_rule"} { - if !telegramSupportedCommand(cmd) { - t.Fatalf("%s should be supported so group slash commands get feedback", cmd) - } - } - for _, cmd := range []string{"/restart", "/update_bot", "/coins", "/red", "/white_channel", "/config"} { - if telegramSupportedCommand(cmd) { - t.Fatalf("%s should not be treated as supported until it has a real Mgo implementation", cmd) - } - } -} - -func TestTelegramSupportedCommandSetMatchesRegistry(t *testing.T) { - _, bot := newBotTestService(t) - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "admin"}, Chat: TelegramChat{ID: 9001, Type: "private"}} - for _, def := range bot.telegramCommandDefinitions(t.Context(), channel, msg) { - for _, alias := range def.Aliases { - if !telegramSupportedCommand(alias) { - t.Fatalf("registered command %s must be in telegramSupportedCommandSet", alias) - } - } - } -} diff --git a/internal/service/telegram_api_proxy_test.go b/internal/service/telegram_api_proxy_test.go deleted file mode 100644 index f7ac75c..0000000 --- a/internal/service/telegram_api_proxy_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package service - -import ( - "net/http" - "strings" - "testing" - "time" -) - -func TestTelegramProxyCandidatesDefaultLocalFallbacks(t *testing.T) { - got := telegramProxyCandidates(map[string]string{}) - joined := strings.Join(got, ",") - for _, want := range []string{"127.0.0.1:10808", "127.0.0.1:7890", "host.docker.internal:7890", "172.17.0.1:7890"} { - if !strings.Contains(joined, want) { - t.Fatalf("default proxy candidates %q missing %q", joined, want) - } - } -} - -func TestTelegramHTTPClientsCustomAPIBaseSkipsDefaultProxyFallback(t *testing.T) { - clients := telegramHTTPClients(time.Second, map[string]string{ - "api_base_url": "http://127.0.0.1:18080", - }) - if len(clients) != 1 { - t.Fatalf("clients = %d, want direct client only", len(clients)) - } - if got := telegramClientProxyString(t, clients[0]); got != "" { - t.Fatalf("custom api_base_url proxy = %q, want direct", got) - } -} - -func TestTelegramHTTPClientsPreferConfiguredProxy(t *testing.T) { - clients := telegramHTTPClients(time.Second, map[string]string{ - "proxy_url": "http://proxy.example:7890", - }) - if len(clients) == 0 { - t.Fatal("expected telegram clients") - } - if got := telegramClientProxyString(t, clients[0]); got != "http://proxy.example:7890" { - t.Fatalf("first client proxy = %q, want configured proxy", got) - } -} - -func telegramClientProxyString(t *testing.T, client *http.Client) string { - t.Helper() - transport, ok := client.Transport.(*http.Transport) - if !ok || transport.Proxy == nil { - return "" - } - req, err := http.NewRequest(http.MethodGet, defaultTelegramAPIBaseURL, nil) - if err != nil { - t.Fatal(err) - } - proxyURL, err := transport.Proxy(req) - if err != nil { - t.Fatal(err) - } - if proxyURL == nil { - return "" - } - return proxyURL.String() -} diff --git a/internal/service/telegram_api_test.go b/internal/service/telegram_api_test.go deleted file mode 100644 index b6c944b..0000000 --- a/internal/service/telegram_api_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package service - -import ( - "errors" - "strings" - "testing" -) - -func TestTelegramMethodURLUsesCustomAPIBase(t *testing.T) { - got, err := telegramMethodURL(map[string]string{ - "api_base_url": "https://tg.example.com/", - }, "123456:ABC-def", "sendMessage") - if err != nil { - t.Fatalf("telegramMethodURL returned error: %v", err) - } - want := "https://tg.example.com/bot123456:ABC-def/sendMessage" - if got != want { - t.Fatalf("got %q, want %q", got, want) - } -} - -func TestSanitizeTelegramErrorRedactsBotToken(t *testing.T) { - err := sanitizeTelegramError(errors.New(`Post "https://api.telegram.org/bot123456:SECRET/sendMessage": context deadline exceeded`)) - if err == nil { - t.Fatal("expected error") - } - msg := err.Error() - if strings.Contains(msg, "SECRET") || strings.Contains(msg, "123456:") { - t.Fatalf("telegram token leaked in error: %s", msg) - } - if !strings.Contains(msg, "timeout") { - t.Fatalf("expected timeout hint, got: %s", msg) - } -} - -func TestValidateTelegramChannelDoesNotRequireLegacyChatID(t *testing.T) { - err := validateChannel(ChannelInput{ - Name: "Telegram", - Type: "telegram", - Config: map[string]any{ - "bot_token": "123456:ABC-def", - "admin_user_ids": "10001", - }, - }) - if err != nil { - t.Fatalf("validateChannel returned error: %v", err) - } -} - -func TestTelegramTargetChatIDsFallsBackToAdmins(t *testing.T) { - got := telegramTargetChatIDs(map[string]string{ - "admin_user_ids": "10001, 10002", - }) - if len(got) != 2 || got[0] != "10001" || got[1] != "10002" { - t.Fatalf("got %#v, want admin user ids", got) - } -} - -func TestNormalizeTelegramChannelMigratesLegacyChatID(t *testing.T) { - input := ChannelInput{ - Name: "Telegram", - Type: "telegram", - Config: map[string]any{ - "chat_id": "-10001", - }, - } - normalizeChannelInput(&input) - if got := str(input.Config["group_chat_id"]); got != "-10001" { - t.Fatalf("group_chat_id = %q, want -10001", got) - } -} - -func TestNormalizeTelegramChannelMigratesLegacyPrivateChatIDToAdmin(t *testing.T) { - cfg := map[string]string{"chat_id": "5812333517"} - normalizeTelegramConfig(cfg) - if got := cfg["admin_user_ids"]; got != "5812333517" { - t.Fatalf("admin_user_ids = %q, want legacy chat_id", got) - } -} - -func TestTelegramTargetChatIDsUsesLegacyPrivateChatID(t *testing.T) { - got := telegramTargetChatIDs(map[string]string{ - "chat_id": "5812333517", - }) - if len(got) != 1 || got[0] != "5812333517" { - t.Fatalf("got %#v, want legacy private chat target", got) - } -} diff --git a/internal/service/telegram_api_webhook_test.go b/internal/service/telegram_api_webhook_test.go deleted file mode 100644 index 81ca0a9..0000000 --- a/internal/service/telegram_api_webhook_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "go.uber.org/zap" -) - -func TestTelegramReplyAutoDeletesSentMessage(t *testing.T) { - requests := make(chan string, 4) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case strings.HasSuffix(r.URL.Path, "/sendMessage"): - requests <- "sendMessage" - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":777}}`)) - case strings.HasSuffix(r.URL.Path, "/deleteMessage"): - requests <- "deleteMessage" - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true,"result":true}`)) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - cfg, _ := json.Marshal(map[string]string{ - "bot_token": "123456:ABC-def", - "api_base_url": server.URL, - "auto_delete_seconds": "0", - }) - _, bot := newBotTestService(t) - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfg)} - if err := bot.reply(context.Background(), channel, 42, telegramCommandReply{Text: "hello"}); err != nil { - t.Fatalf("reply: %v", err) - } - waitForTelegramMethod(t, requests, "sendMessage") - waitForTelegramMethod(t, requests, "deleteMessage") -} - -func TestTelegramGroupCommandSendsPanelInGroup(t *testing.T) { - var payloads []struct { - ChatID any `json:"chat_id"` - Text string `json:"text"` - ReplyMarkup map[string]any `json:"reply_markup"` - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.HasSuffix(r.URL.Path, "/sendMessage") { - http.NotFound(w, r) - return - } - var payload struct { - ChatID any `json:"chat_id"` - Text string `json:"text"` - ReplyMarkup map[string]any `json:"reply_markup"` - } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("decode sendMessage: %v", err) - } - payloads = append(payloads, payload) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":777}}`)) - })) - defer server.Close() - - repos, bot := newBotTestService(t) - cfg, _ := json.Marshal(map[string]string{ - "bot_token": "123456:ABC-def", - "api_base_url": server.URL, - "group_chat_id": "-100123", - "auto_delete_seconds": "-1", - }) - if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfg)}).Error; err != nil { - t.Fatalf("create channel: %v", err) - } - update, _ := json.Marshal(TelegramUpdate{ - UpdateID: 1, - Message: &TelegramMessage{ - MessageID: 55, - From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"}, - Chat: TelegramChat{ID: -100123, Type: "supergroup"}, - Text: "/menu", - }, - }) - if err := bot.HandleWebhook(t.Context(), update); err != nil { - t.Fatalf("handle webhook: %v", err) - } - if len(payloads) != 1 { - t.Fatalf("sendMessage count = %d, payloads=%#v", len(payloads), payloads) - } - if got := fmt.Sprint(payloads[0].ChatID); got != "-100123" { - t.Fatalf("message should stay in group, chat_id=%s payload=%#v", got, payloads[0]) - } - if strings.Contains(payloads[0].Text, "管理员入口") { - t.Fatalf("normal group user must not see admin panel: %#v", payloads[0]) - } -} - -func TestTelegramGroupCallbackIsRejected(t *testing.T) { - var callbackPayloads []struct { - CallbackID string `json:"callback_query_id"` - Text string `json:"text"` - ShowAlert bool `json:"show_alert"` - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.HasSuffix(r.URL.Path, "/answerCallbackQuery") { - http.NotFound(w, r) - return - } - var payload struct { - CallbackID string `json:"callback_query_id"` - Text string `json:"text"` - ShowAlert bool `json:"show_alert"` - } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("decode answerCallbackQuery: %v", err) - } - callbackPayloads = append(callbackPayloads, payload) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true,"result":true}`)) - })) - defer server.Close() - - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - user, _, err := auth.Register(ctx, "viewer", "secret-pass") - if err != nil { - t.Fatalf("register user: %v", err) - } - if err := repos.DB.AutoMigrate(&model.NotifyChannel{}); err != nil { - t.Fatalf("migrate notify channel: %v", err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 9002, - TelegramName: "@viewer", - ChatID: 9002, - UserID: user.ID, - }).Error; err != nil { - t.Fatalf("create binding: %v", err) - } - cfg, _ := json.Marshal(map[string]string{ - "bot_token": "123456:ABC-def", - "api_base_url": server.URL, - "group_chat_id": "-100123", - "auto_delete_seconds": "-1", - }) - if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfg)}).Error; err != nil { - t.Fatalf("create channel: %v", err) - } - before, _ := repos.User.FindByID(ctx, user.ID) - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - update, _ := json.Marshal(TelegramUpdate{ - UpdateID: 2, - CallbackQuery: &TelegramCallbackQuery{ - ID: "cb-group", - From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"}, - Message: &TelegramMessage{MessageID: 56, Chat: TelegramChat{ID: -100123, Type: "supergroup"}}, - Data: "adult_toggle", - }, - }) - if err := bot.HandleWebhook(ctx, update); err != nil { - t.Fatalf("handle webhook: %v", err) - } - if len(callbackPayloads) != 1 { - t.Fatalf("answerCallbackQuery count = %d", len(callbackPayloads)) - } - if !callbackPayloads[0].ShowAlert || !strings.Contains(callbackPayloads[0].Text, "群组内按钮面板已禁用") { - t.Fatalf("unexpected callback answer: %#v", callbackPayloads[0]) - } - after, _ := repos.User.FindByID(ctx, user.ID) - if before == nil || after == nil || before.HideAdult != after.HideAdult { - t.Fatalf("group callback should not mutate user adult visibility: before=%#v after=%#v", before, after) - } -} - -func waitForTelegramMethod(t *testing.T, requests <-chan string, want string) { - t.Helper() - deadline := time.After(2 * time.Second) - for { - select { - case got := <-requests: - if got == want { - return - } - case <-deadline: - t.Fatalf("timed out waiting for telegram %s", want) - } - } -} diff --git a/internal/service/telegram_binding.go b/internal/service/telegram_binding.go deleted file mode 100644 index cb16c66..0000000 --- a/internal/service/telegram_binding.go +++ /dev/null @@ -1,139 +0,0 @@ -package service - -import ( - "context" - "errors" - "strings" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) telegramBinding(ctx context.Context, telegramUserID int) *model.TelegramBinding { - if telegramUserID == 0 { - return nil - } - var binding model.TelegramBinding - err := s.repo.DB.WithContext(ctx).Where("telegram_user_id = ?", int64(telegramUserID)).First(&binding).Error - if err != nil { - return nil - } - return &binding -} - -func (s *TelegramBotService) unbindTelegramUser(ctx context.Context, telegramUserID int) error { - if s == nil || s.repo == nil || s.repo.DB == nil || telegramUserID == 0 { - return nil - } - return s.repo.DB.WithContext(ctx).Unscoped(). - Where("telegram_user_id = ?", int64(telegramUserID)). - Delete(&model.TelegramBinding{}).Error -} - -func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *TelegramMessage, userID string) error { - name := strings.TrimSpace(msg.From.FirstName) - if msg.From.Username != "" { - name = "@" + strings.TrimSpace(msg.From.Username) - } - telegramUserID := int64(msg.From.ID) - return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var existing model.TelegramBinding - err := tx.Where("telegram_user_id = ?", telegramUserID).First(&existing).Error - if err == nil { - if err := s.replaceTelegramAccountBindingTx(ctx, tx, userID, telegramUserID); err != nil { - return err - } - if err := tx.Model(&existing).Updates(map[string]any{ - "telegram_name": name, - "chat_id": telegramBindingChatIDForMessage(msg, &existing), - "user_id": userID, - }).Error; telegramBindingUniqueErr(err) { - return errTelegramAccountAlreadyBound - } else if err != nil { - return err - } - return nil - } - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - if err := tx.Unscoped().Where("telegram_user_id = ?", telegramUserID).Delete(&model.TelegramBinding{}).Error; err != nil { - return err - } - if err := s.replaceTelegramAccountBindingTx(ctx, tx, userID, telegramUserID); err != nil { - return err - } - err = tx.Create(&model.TelegramBinding{ - TelegramUserID: telegramUserID, - TelegramName: name, - ChatID: telegramBindingChatIDForMessage(msg, nil), - UserID: userID, - }).Error - if telegramBindingUniqueErr(err) { - return errTelegramAccountAlreadyBound - } - return err - }) -} - -func telegramBindingChatIDForMessage(msg *TelegramMessage, existing *model.TelegramBinding) int64 { - if msg == nil { - if existing != nil { - return existing.ChatID - } - return 0 - } - if msg.Chat.Type == "" || msg.Chat.Type == "private" { - return int64(msg.Chat.ID) - } - if existing != nil && existing.ChatID > 0 { - return existing.ChatID - } - return int64(msg.From.ID) -} - -func telegramPrivateChatIDFromBinding(binding model.TelegramBinding) int64 { - if binding.ChatID > 0 { - return binding.ChatID - } - return binding.TelegramUserID -} - -func (s *TelegramBotService) replaceTelegramAccountBindingTx(ctx context.Context, tx *gorm.DB, userID string, telegramUserID int64) error { - return tx.WithContext(ctx).Unscoped(). - Where("user_id = ? AND telegram_user_id <> ?", userID, telegramUserID). - Delete(&model.TelegramBinding{}).Error -} - -func telegramBindingUniqueErr(err error) bool { - if err == nil { - return false - } - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "idx_telegram_bindings_user_id_active") || - strings.Contains(msg, "telegram_bindings.user_id") || - (strings.Contains(msg, "unique") && strings.Contains(msg, "telegram_bindings")) -} - -func parseStartCredentials(args []string) (string, string) { - if len(args) >= 2 { - return strings.TrimSpace(args[0]), strings.TrimSpace(strings.Join(args[1:], " ")) - } - if len(args) == 1 { - raw := strings.TrimSpace(args[0]) - for _, sep := range []string{"-", ":", ":"} { - if parts := strings.SplitN(raw, sep, 2); len(parts) == 2 { - return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) - } - } - } - return "", "" -} - -func userNameOrFallback(user *model.User) string { - if user == nil || strings.TrimSpace(user.Username) == "" { - return "未知用户" - } - return user.Username -} diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go deleted file mode 100644 index f98a22e..0000000 --- a/internal/service/telegram_bot.go +++ /dev/null @@ -1,298 +0,0 @@ -// Package service — Telegram Bot 交互命令服务。 -// -// 处理通过 Telegram Bot API 接收的用户命令,提供系统状态查询、 -// 媒体搜索、下载管理等功能。同时支持 Webhook 和 Long Polling 两种模式。 -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strconv" - "strings" - "sync" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -// TelegramUpdate 是 Telegram Bot API 推送的 update 对象。 -type TelegramUpdate struct { - UpdateID int `json:"update_id"` - Message *TelegramMessage `json:"message,omitempty"` - CallbackQuery *TelegramCallbackQuery `json:"callback_query,omitempty"` -} - -// TelegramMessage 是 Telegram 消息对象。 -type TelegramMessage struct { - MessageID int `json:"message_id"` - From TelegramUser `json:"from"` - Chat TelegramChat `json:"chat"` - Text string `json:"text,omitempty"` - Date int `json:"date"` -} - -type TelegramCallbackQuery struct { - ID string `json:"id"` - From TelegramUser `json:"from"` - Message *TelegramMessage `json:"message,omitempty"` - Data string `json:"data,omitempty"` -} - -// TelegramUser 是 Telegram 用户对象。 -type TelegramUser struct { - ID int `json:"id"` - FirstName string `json:"first_name"` - Username string `json:"username,omitempty"` -} - -// TelegramChat 是 Telegram 聊天对象。 -type TelegramChat struct { - ID int `json:"id"` - Type string `json:"type"` -} - -type telegramCommandReply struct { - Text string - Buttons [][]telegramInlineButton -} - -type telegramInlineButton struct { - Text string `json:"text"` - Data string `json:"callback_data"` -} - -// TelegramBotService 处理 Telegram Bot 的交互命令。 -type TelegramBotService struct { - log *zap.Logger - repo *repository.Container - crypto *CryptoService - auth *AuthService - device *DeviceService - backup *BackupService - - pollingMu sync.Mutex - pollingCancel map[string]context.CancelFunc // bot_token -> cancel - - pendingMu sync.Mutex - pending map[int64]pendingInput // telegram_user_id -> awaited text input -} - -// pendingInput tracks a button-initiated action that awaits the user's next -// text message (e.g. tapping「注册」then sending "用户名 密码"). -type pendingInput struct { - Kind string // register / redeem_register / redeem_renew / setname / setpass / openreg_limit / gencode_user - CreatedAt time.Time -} - -// SetDeviceService wires the device-management service used by the device -// menu (list / kick) and enforcement notifications. -func (s *TelegramBotService) SetDeviceService(d *DeviceService) { s.device = d } - -// SetBackupService wires database backup/restore commands. -func (s *TelegramBotService) SetBackupService(b *BackupService) { s.backup = b } - -// NotifyUserByID sends a Telegram message to the local user identified by -// userID, resolved through their Telegram binding. Used by enforcement to warn -// users before destructive actions. No-op when the user has no binding. -func (s *TelegramBotService) NotifyUserByID(ctx context.Context, userID, text string) { - if userID == "" || strings.TrimSpace(text) == "" { - return - } - var binding model.TelegramBinding - if err := s.repo.DB.WithContext(ctx).Where("user_id = ?", userID).First(&binding).Error; err != nil { - return - } - targetChatID := telegramPrivateChatIDFromBinding(binding) - if targetChatID == 0 { - return - } - channel := s.findChannelByChatID(ctx, int(binding.ChatID)) - if channel == nil { - channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram") - if err != nil || len(channels) == 0 { - return - } - channel = &channels[0] - } - _ = s.reply(ctx, channel, int(targetChatID), telegramCommandReply{Text: text}) -} - -// NewTelegramBotService 创建 Telegram Bot 服务。 -func NewTelegramBotService(log *zap.Logger, repo *repository.Container, crypto *CryptoService, auth *AuthService) *TelegramBotService { - return &TelegramBotService{ - log: log, - repo: repo, - crypto: crypto, - auth: auth, - pollingCancel: make(map[string]context.CancelFunc), - pending: make(map[int64]pendingInput), - } -} - -// TelegramRegistrationSettingKey 控制普通用户是否可以通过 Bot 注册新账号。 -// 默认关闭,只有管理员在系统设置 / Bot 管理命令中显式开启后才允许注册。 -const TelegramRegistrationSettingKey = "telegram.registration_enabled" - -var errTelegramAccountAlreadyBound = errors.New("该媒体账号已绑定其他 Telegram,请联系管理员解绑") - -// registrationEnabled 读取注册开关;默认关闭。 -func (s *TelegramBotService) registrationEnabled(ctx context.Context) bool { - v, err := s.repo.Setting.Get(ctx, TelegramRegistrationSettingKey) - if err != nil { - return false - } - return parseBoolSetting(v, false) -} - -// setRegistrationEnabled 持久化注册开关。 -func (s *TelegramBotService) setRegistrationEnabled(ctx context.Context, enabled bool) error { - return s.repo.Setting.Set(ctx, TelegramRegistrationSettingKey, strconv.FormatBool(enabled)) -} - -// HandleWebhook 处理 Telegram 推送的 Webhook/Polling 消息。 -func (s *TelegramBotService) HandleWebhook(ctx context.Context, body []byte) error { - var update TelegramUpdate - if err := json.Unmarshal(body, &update); err != nil { - return fmt.Errorf("invalid update: %w", err) - } - return s.handleTelegramUpdate(ctx, update, nil) -} - -func (s *TelegramBotService) handleTelegramUpdate(ctx context.Context, update TelegramUpdate, channelHint *model.NotifyChannel) error { - if update.CallbackQuery != nil { - return s.handleCallback(ctx, update.CallbackQuery, channelHint) - } - - if update.Message == nil || update.Message.Text == "" { - return nil - } - - msg := update.Message - text := strings.TrimSpace(msg.Text) - - // Button-initiated text prompts (register / redeem / change name·password / - // open-reg limit) arrive as ordinary messages. Consume them here before the - // command gate so the button-driven menu can collect free-form input. - if !telegramIsCommandText(text) { - if msg.Chat.Type == "" || msg.Chat.Type == "private" { - if channel := s.channelForMessage(ctx, msg, channelHint); channel != nil { - if reply, handled := s.handlePendingText(ctx, channel, msg, text); handled { - if reply.Text != "" { - if err := s.reply(ctx, channel, msg.Chat.ID, reply); err != nil { - s.log.Error("reply failed", zap.Error(err)) - } - } - s.deleteTelegramSourceMessage(channel, msg.Chat.ID, msg.MessageID) - return nil - } - if looksLikeRedemptionCode(text) { - reply := s.cmdRedeem(ctx, channel, msg, []string{text}) - if reply.Text != "" { - if err := s.reply(ctx, channel, msg.Chat.ID, reply); err != nil { - s.log.Error("reply failed", zap.Error(err)) - } - } - s.deleteTelegramSourceMessage(channel, msg.Chat.ID, msg.MessageID) - return nil - } - } - } - return nil - } - if msg.Chat.Type != "" && msg.Chat.Type != "private" && !telegramSupportedCommand(telegramCommandName(text)) { - return nil - } - - s.log.Info("telegram command received", - zap.Int("chat_id", msg.Chat.ID), - zap.String("user", msg.From.Username), - zap.String("text", text), - ) - - // 获取该消息可使用的 Telegram 通知渠道配置。群组/频道消息必须来自 - // 已配置的群组/频道;私聊消息会选择一个可验证该用户成员身份的 Bot。 - channel := s.channelForMessage(ctx, msg, channelHint) - if channel == nil { - s.log.Warn("telegram channel not allowed or not configured", - zap.Int("chat_id", msg.Chat.ID), - zap.String("chat_type", msg.Chat.Type), - zap.Int("telegram_user_id", msg.From.ID), - ) - return nil - } - - // 解析并执行命令 - reply, err := s.executeCommand(ctx, channel, msg, text) - if err != nil { - s.log.Error("command failed", zap.Error(err)) - _ = s.replyForMessage(ctx, channel, msg, telegramCommandReply{Text: "命令执行失败: " + err.Error()}) - s.deleteTelegramSourceMessage(channel, msg.Chat.ID, msg.MessageID) - return nil - } - - if reply.Text != "" { - if err := s.replyForMessage(ctx, channel, msg, reply); err != nil { - s.log.Error("reply failed", zap.Error(err)) - } - s.deleteTelegramSourceMessage(channel, msg.Chat.ID, msg.MessageID) - } - - return nil -} - -func telegramIsCommandText(text string) bool { - return strings.HasPrefix(strings.TrimSpace(text), "/") && telegramCommandName(text) != "" -} - -func telegramCommandName(text string) string { - parts := strings.Fields(strings.TrimSpace(text)) - if len(parts) == 0 { - return "" - } - cmd := strings.ToLower(strings.TrimSpace(parts[0])) - if !strings.HasPrefix(cmd, "/") { - return "" - } - if at := strings.Index(cmd, "@"); at > 0 { - cmd = cmd[:at] - } - return cmd -} - -func telegramIsGroupChat(chatType string) bool { - return chatType != "" && chatType != "private" -} - -func telegramPrivateMessageForUser(msg *TelegramMessage) *TelegramMessage { - if msg == nil || !telegramIsGroupChat(msg.Chat.Type) { - return msg - } - copied := *msg - copied.Chat = TelegramChat{ID: msg.From.ID, Type: "private"} - return &copied -} - -func telegramGroupPrivateAdminHint() string { - return "群组内不展示管理面板;管理员可在已绑定群组直接发送文本管理命令,涉及账号凭据的操作仍请私聊 Bot。" -} - -func telegramGroupPrivateUserHint(action string) string { - action = strings.TrimSpace(action) - if action == "" { - action = "此操作" - } - return action + "包含账号凭据或敏感信息,请私聊 Bot 操作;群组内仅开放账号状态、签到、设备与成人目录开关。" -} - -func telegramGroupPrivateDeliverySentHint() string { - return "已把你的 Bot 面板/执行结果私聊发送给你。若没收到,请先私聊 Bot 发送 /start。" -} - -func telegramGroupPrivateDeliveryFailedHint() string { - return "无法私聊发送给你。请先打开 Bot 私聊窗口发送 /start,再回群里使用命令。" -} diff --git a/internal/service/telegram_bot_binding_test.go b/internal/service/telegram_bot_binding_test.go deleted file mode 100644 index 9c2209d..0000000 --- a/internal/service/telegram_bot_binding_test.go +++ /dev/null @@ -1,293 +0,0 @@ -package service - -import ( - "encoding/json" - "errors" - "strings" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestTelegramStartClearsStaleUserBinding(t *testing.T) { - repos, auth, _, _ := newAuthTestServices(t) - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 20001, - TelegramName: "@viewer", - ChatID: 20001, - UserID: "deleted-user", - }).Error; err != nil { - t.Fatalf("create binding: %v", err) - } - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - - reply := bot.cmdStart(t.Context(), &TelegramMessage{ - From: TelegramUser{ID: 20001, Username: "viewer", FirstName: "Viewer"}, - Chat: TelegramChat{ID: 20001, Type: "private"}, - }, nil) - - if !strings.Contains(reply.Text, "已不存在") { - t.Fatalf("expected stale binding message, got %q", reply.Text) - } - var count int64 - if err := repos.DB.Model(&model.TelegramBinding{}).Where("telegram_user_id = ?", 20001).Count(&count).Error; err != nil { - t.Fatalf("count binding: %v", err) - } - if count != 0 { - t.Fatalf("stale binding should be removed, got %d", count) - } -} - -func TestTelegramStartReplacesAccountBindingFromAnotherTelegram(t *testing.T) { - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - user, _, err := auth.Register(ctx, "viewer", "secret-pass") - if err != nil { - t.Fatalf("register: %v", err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 20001, - TelegramName: "@viewer-one", - ChatID: 20001, - UserID: user.ID, - }).Error; err != nil { - t.Fatalf("create binding: %v", err) - } - - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - cfgJSON, _ := json.Marshal(map[string]string{"admin_user_ids": "20002"}) - if err := repos.DB.AutoMigrate(&model.NotifyChannel{}); err != nil { - t.Fatalf("migrate notify channel: %v", err) - } - if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfgJSON)}).Error; err != nil { - t.Fatalf("create notify channel: %v", err) - } - msg := &TelegramMessage{ - From: TelegramUser{ID: 20002, Username: "viewer-two", FirstName: "Viewer Two"}, - Chat: TelegramChat{ID: 20002, Type: "private"}, - } - reply := bot.cmdStart(ctx, msg, []string{"viewer", "secret-pass"}) - - if !strings.Contains(reply.Text, "绑定成功") { - t.Fatalf("expected new telegram account to replace old binding, got %q", reply.Text) - } - var accountBindings int64 - if err := repos.DB.Model(&model.TelegramBinding{}).Where("user_id = ?", user.ID).Count(&accountBindings).Error; err != nil { - t.Fatalf("count account bindings: %v", err) - } - if accountBindings != 1 { - t.Fatalf("account should keep exactly one telegram binding, got %d", accountBindings) - } - if binding := bot.telegramBinding(ctx, 20002); binding == nil || binding.UserID != user.ID { - t.Fatalf("new telegram account should be bound to user, got %#v", binding) - } - if binding := bot.telegramBinding(ctx, 20001); binding != nil { - t.Fatalf("old telegram binding should be removed, got %#v", binding) - } -} - -func TestTelegramStartUnbindsWhenBoundPasswordChanged(t *testing.T) { - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - user, _, err := auth.Register(ctx, "viewer", "old-password") - if err != nil { - t.Fatalf("register: %v", err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 20003, - TelegramName: "@viewer", - ChatID: 20003, - UserID: user.ID, - }).Error; err != nil { - t.Fatalf("create binding: %v", err) - } - if err := auth.ResetPassword(ctx, user.ID, "new-password"); err != nil { - t.Fatalf("reset password: %v", err) - } - if err := repos.DB.AutoMigrate(&model.NotifyChannel{}); err != nil { - t.Fatalf("migrate notify channel: %v", err) - } - cfgJSON, _ := json.Marshal(map[string]string{"admin_user_ids": "20003"}) - if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfgJSON)}).Error; err != nil { - t.Fatalf("create notify channel: %v", err) - } - - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - msg := &TelegramMessage{ - From: TelegramUser{ID: 20003, Username: "viewer", FirstName: "Viewer"}, - Chat: TelegramChat{ID: 20003, Type: "private"}, - } - reply := bot.cmdStart(ctx, msg, []string{"viewer", "old-password"}) - - if !strings.Contains(reply.Text, "已自动解绑") { - t.Fatalf("expected auto unbind reply, got %q", reply.Text) - } - if binding := bot.telegramBinding(ctx, 20003); binding != nil { - t.Fatal("stale binding should be removed after password mismatch") - } -} - -func TestTelegramSelfSetNameRequiresCurrentPassword(t *testing.T) { - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - user, _, err := auth.Register(ctx, "viewer", "old-password") - if err != nil { - t.Fatalf("register: %v", err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 20004, - TelegramName: "@viewer", - ChatID: 20004, - UserID: user.ID, - }).Error; err != nil { - t.Fatalf("create binding: %v", err) - } - - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - msg := &TelegramMessage{From: TelegramUser{ID: 20004, Username: "viewer"}, Chat: TelegramChat{ID: 20004, Type: "private"}} - if reply := bot.selfSetName(ctx, msg, "renamed"); !strings.Contains(reply.Text, "当前密码 新用户名") { - t.Fatalf("expected usage reply, got %q", reply.Text) - } - if reply := bot.selfSetName(ctx, msg, "old-password renamed"); !strings.Contains(reply.Text, "用户名已修改") { - t.Fatalf("expected rename success, got %q", reply.Text) - } - updated, _ := repos.User.FindByID(ctx, user.ID) - if updated == nil || updated.Username != "renamed" { - t.Fatalf("username not updated: %#v", updated) - } -} - -func TestTelegramSelfSetPassWrongCurrentPasswordUnbinds(t *testing.T) { - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - user, _, err := auth.Register(ctx, "viewer", "old-password") - if err != nil { - t.Fatalf("register: %v", err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 20005, - TelegramName: "@viewer", - ChatID: 20005, - UserID: user.ID, - }).Error; err != nil { - t.Fatalf("create binding: %v", err) - } - - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - msg := &TelegramMessage{From: TelegramUser{ID: 20005, Username: "viewer"}, Chat: TelegramChat{ID: 20005, Type: "private"}} - reply := bot.selfSetPass(ctx, msg, "wrong-password new-password") - - if !strings.Contains(reply.Text, "已自动解绑") { - t.Fatalf("expected auto unbind reply, got %q", reply.Text) - } - if binding := bot.telegramBinding(ctx, 20005); binding != nil { - t.Fatal("binding should be removed after wrong current password") - } - if _, err := auth.Login(ctx, "viewer", "old-password"); err != nil { - t.Fatalf("old password should remain valid after failed change: %v", err) - } -} - -func TestTelegramSelfSetPassChangesPasswordWithCurrentPassword(t *testing.T) { - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - user, _, err := auth.Register(ctx, "viewer", "old-password") - if err != nil { - t.Fatalf("register: %v", err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 20006, - TelegramName: "@viewer", - ChatID: 20006, - UserID: user.ID, - }).Error; err != nil { - t.Fatalf("create binding: %v", err) - } - - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - msg := &TelegramMessage{From: TelegramUser{ID: 20006, Username: "viewer"}, Chat: TelegramChat{ID: 20006, Type: "private"}} - reply := bot.selfSetPass(ctx, msg, "old-password new-password") - - if !strings.Contains(reply.Text, "密码已修改") { - t.Fatalf("expected password change success, got %q", reply.Text) - } - if _, err := auth.Login(ctx, "viewer", "old-password"); !errors.Is(err, ErrInvalidCredentials) { - t.Fatalf("old password should fail, got %v", err) - } - if _, err := auth.Login(ctx, "viewer", "new-password"); err != nil { - t.Fatalf("new password should login: %v", err) - } - if binding := bot.telegramBinding(ctx, 20006); binding == nil { - t.Fatal("successful password change should keep telegram binding") - } -} - -func TestTelegramBindingFromGroupStoresPrivateUserChatID(t *testing.T) { - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - user, _, err := auth.Register(ctx, "viewer", "secret-pass") - if err != nil { - t.Fatalf("register: %v", err) - } - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - msg := &TelegramMessage{ - From: TelegramUser{ID: 21001, Username: "viewer", FirstName: "Viewer"}, - Chat: TelegramChat{ID: -100123456, Type: "group"}, - } - - if err := bot.upsertTelegramBinding(ctx, msg, user.ID); err != nil { - t.Fatalf("upsert binding: %v", err) - } - binding := bot.telegramBinding(ctx, 21001) - if binding == nil { - t.Fatal("binding should be created") - } - if binding.ChatID != 21001 { - t.Fatalf("group binding must store private user chat id, got %d", binding.ChatID) - } -} - -func TestTelegramBindingFromGroupPreservesExistingPrivateChatID(t *testing.T) { - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - user, _, err := auth.Register(ctx, "viewer", "secret-pass") - if err != nil { - t.Fatalf("register: %v", err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 21002, - TelegramName: "@viewer", - ChatID: 987654, - UserID: user.ID, - }).Error; err != nil { - t.Fatalf("seed binding: %v", err) - } - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - msg := &TelegramMessage{ - From: TelegramUser{ID: 21002, Username: "viewer", FirstName: "Viewer"}, - Chat: TelegramChat{ID: -100123456, Type: "supergroup"}, - } - - if err := bot.upsertTelegramBinding(ctx, msg, user.ID); err != nil { - t.Fatalf("upsert binding: %v", err) - } - binding := bot.telegramBinding(ctx, 21002) - if binding == nil { - t.Fatal("binding should exist") - } - if binding.ChatID != 987654 { - t.Fatalf("group command must not overwrite existing private chat id, got %d", binding.ChatID) - } -} - -func TestTelegramPrivateNotifyChatIDFallsBackFromLegacyGroupBinding(t *testing.T) { - binding := model.TelegramBinding{ - TelegramUserID: 21003, - ChatID: -100123456, - } - if got := telegramPrivateChatIDFromBinding(binding); got != 21003 { - t.Fatalf("legacy group binding should notify private user chat, got %d", got) - } -} diff --git a/internal/service/telegram_bot_commands_core.go b/internal/service/telegram_bot_commands_core.go deleted file mode 100644 index 7d0d203..0000000 --- a/internal/service/telegram_bot_commands_core.go +++ /dev/null @@ -1,225 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strconv" - "strings" - - "golang.org/x/crypto/bcrypt" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// cmdStart 处理 /start 命令。 -func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { - name := msg.From.FirstName - if msg.From.Username != "" { - name = "@" + msg.From.Username - } - if telegramIsGroupChat(msg.Chat.Type) && len(args) > 0 { - return telegramCommandReply{Text: telegramGroupPrivateUserHint("绑定账号")} - } - if len(args) == 0 { - if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil { - user, _ := s.repo.User.FindByID(ctx, binding.UserID) - if user == nil { - _ = s.repo.DB.WithContext(ctx).Unscoped().Delete(&model.TelegramBinding{}, "id = ?", binding.ID).Error - return telegramCommandReply{Text: "之前绑定的媒体中心账号已不存在,请重新绑定:\n/start 用户名 密码"} - } - status := "未隐藏" - if user.HideAdult { - status = "已隐藏" - } - return telegramCommandReply{ - Text: fmt.Sprintf("MediaStationGo 已绑定\n\n你好 %s,当前账号:%s\n成人目录:%s", name, userNameOrFallback(user), status), - Buttons: [][]telegramInlineButton{{{ - Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult], - Data: "adult_toggle", - }}}, - } - } - hint := "如果没有账号,请联系管理员注册。" - if s.openRegEnabled(ctx) { - hint = "如果还没有账号,可直接注册:\n/register 用户名 密码\n或:/register 用户名-密码" - } - return telegramCommandReply{Text: "欢迎使用 MediaStationGo\n\n普通用户请先绑定账号:\n/start 用户名 密码\n或:/start 用户名-密码\n\n" + hint} - } - channel := s.findChannelForMessage(ctx, msg) - if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed { - return telegramCommandReply{Text: telegramBindRejectText(dec, "绑定媒体中心账号")} - } - username, password := parseStartCredentials(args) - if username == "" || password == "" { - return telegramCommandReply{Text: "绑定格式不正确,请使用:\n/start 用户名 密码\n或:/start 用户名-密码"} - } - existingBinding := s.telegramBinding(ctx, msg.From.ID) - user, err := s.repo.User.FindByUsername(ctx, username) - if err != nil || user == nil { - if existingBinding != nil { - _ = s.unbindTelegramUser(ctx, msg.From.ID) - return telegramCommandReply{Text: "当前绑定的媒体账号信息已失效,已自动解绑。请使用新的用户名和密码重新绑定。"} - } - return telegramCommandReply{Text: "未找到此用户,请联系管理员注册。"} - } - if !user.IsActive { - return telegramCommandReply{Text: "此账号已被禁用,请联系管理员。"} - } - if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { - if existingBinding != nil && existingBinding.UserID == user.ID { - _ = s.unbindTelegramUser(ctx, msg.From.ID) - return telegramCommandReply{Text: "当前绑定账号的密码已失效,已自动解绑。请使用新密码重新绑定。"} - } - return telegramCommandReply{Text: "账号或密码错误。"} - } - if err := s.upsertTelegramBinding(ctx, msg, user.ID); err != nil { - return telegramCommandReply{Text: "绑定失败:" + err.Error()} - } - return telegramCommandReply{ - Text: fmt.Sprintf("绑定成功:%s\n\n普通用户只能使用此 Bot 管理自己的成人目录隐藏状态;系统状态、搜索、下载和统计命令仅管理员可用。", user.Username), - Buttons: [][]telegramInlineButton{{{ - Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult], - Data: "adult_toggle", - }}}, - } -} - -// cmdRegister 处理 /register 命令:在管理员开启注册后,普通用户可通过 Bot -// 注册一个新的媒体中心账号,并自动绑定到当前 Telegram 账号。 -func (s *TelegramBotService) cmdRegister(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, args []string) telegramCommandReply { - if len(args) == 1 && looksLikeRedemptionCode(args[0]) { - return s.redeemRegisterFlow(ctx, channel, msg, args[0]) - } - if !s.openRegEnabled(ctx) { - return telegramCommandReply{Text: "注册功能未开放,请联系管理员开启后再试。"} - } - // 开注名额已用尽则拦截(容量随凭证授权实时变化,名额单独计数)。 - if c := s.loadCapacity(ctx); c.Remaining() <= 0 { - return telegramCommandReply{Text: "注册名额已满,请等待管理员重新开放或扩容授权。"} - } - if s.auth == nil { - return telegramCommandReply{Text: "注册功能暂不可用,请联系管理员。"} - } - if channel == nil { - channel = s.findChannelForMessage(ctx, msg) - } - if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed { - return telegramCommandReply{Text: telegramBindRejectText(dec, "注册账号")} - } - if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil { - if user, _ := s.repo.User.FindByID(ctx, binding.UserID); user != nil { - return telegramCommandReply{Text: fmt.Sprintf("当前 Telegram 已绑定账号:%s,无需重复注册。\n如需切换账号请使用 /start 用户名 密码。", userNameOrFallback(user))} - } - } - username, password := parseStartCredentials(args) - if username == "" || password == "" { - return telegramCommandReply{Text: "注册格式不正确,请使用:\n/register 用户名 密码\n或:/register 用户名-密码"} - } - user, _, err := s.auth.Register(ctx, username, password) - if err != nil { - switch { - case errors.Is(err, ErrUsernameTaken): - return telegramCommandReply{Text: "该用户名已被占用,请换一个;如果是你本人的账号,请改用 /start 用户名 密码 绑定。"} - case errors.Is(err, ErrUserLimitReached): - return telegramCommandReply{Text: "注册失败:已达到用户数量上限,请联系管理员。"} - default: - return telegramCommandReply{Text: "注册失败:" + err.Error()} - } - } - // 注册成功,扣减一个开注名额(名额用尽自动关闭注册)。 - s.consumeOpenRegSlot(ctx) - if err := s.upsertTelegramBinding(ctx, msg, user.ID); err != nil { - return telegramCommandReply{Text: fmt.Sprintf("账号 %s 注册成功,但自动绑定失败:%s\n请稍后使用 /start %s 密码 重新绑定。", user.Username, err.Error(), user.Username)} - } - return telegramCommandReply{ - Text: fmt.Sprintf("注册并绑定成功:%s\n\n你现在可以用此账号登录网页与第三方客户端。普通用户只能在此 Bot 管理成人目录显隐;其他功能仅管理员可用。", user.Username), - Buttons: [][]telegramInlineButton{{{ - Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult], - Data: "adult_toggle", - }}}, - } -} - -// cmdRegistrationToggle handles /registration and /openreg. It uses the same -// quota-aware open-registration state as the inline Bot menu. -func (s *TelegramBotService) cmdRegistrationToggle(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 || strings.EqualFold(strings.TrimSpace(args[0]), "status") { - c := s.loadCapacity(ctx) - state := "已关闭" - if c.OpenRegOn { - if c.OpenRegLimit > 0 { - state = fmt.Sprintf("已开启(%d/%d 名额)", c.OpenRegUsed, c.OpenRegLimit) - } else { - state = "已开启(不限名额,受授权上限约束)" - } - } - return telegramCommandReply{Text: fmt.Sprintf("普通用户 Bot 注册功能当前%s。\n剩余可注册:%d 人。\n\n开启:/registration on 10\n不限:/registration on 0\n关闭:/registration off", state, c.Remaining())} - } - switch strings.ToLower(strings.TrimSpace(args[0])) { - case "on", "true", "1", "open", "enable", "enabled", "开启", "打开", "开": - limit := 0 - if len(args) > 1 { - n, err := strconv.Atoi(strings.TrimSpace(args[1])) - if err != nil || n < 0 { - return telegramCommandReply{Text: "名额必须是非负整数,0 表示不限名额。"} - } - limit = n - } - if err := s.openRegistration(ctx, limit); err != nil { - return telegramCommandReply{Text: "开启失败:" + err.Error()} - } - label := "不限名额" - if limit > 0 { - label = fmt.Sprintf("%d 个名额", limit) - } - return telegramCommandReply{Text: "普通用户 Bot 注册功能已开启:" + label + "。"} - case "off", "false", "0", "close", "disable", "disabled", "关闭", "关": - if err := s.closeRegistration(ctx); err != nil { - return telegramCommandReply{Text: "关闭失败:" + err.Error()} - } - return telegramCommandReply{Text: "普通用户 Bot 注册功能已关闭。"} - default: - return telegramCommandReply{Text: "参数无效,请使用 /registration on [名额] 或 /registration off。"} - } -} - -// cmdStatus 处理 /status 命令。 -func (s *TelegramBotService) cmdHideAdult(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { - channel := s.findChannelForMessage(ctx, msg) - if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed { - return telegramCommandReply{Text: telegramBindRejectText(dec, "使用成人目录隐藏开关")} - } - binding := s.telegramBinding(ctx, msg.From.ID) - if binding == nil { - return telegramCommandReply{Text: "请先绑定账号:/start 用户名 密码"} - } - user, err := s.repo.User.FindByID(ctx, binding.UserID) - if err != nil || user == nil { - return telegramCommandReply{Text: "绑定用户不存在,请重新 /start 绑定。"} - } - next := true - if len(args) > 0 { - switch strings.ToLower(strings.TrimSpace(args[0])) { - case "off", "false", "0", "show", "显示", "关闭": - next = false - case "on", "true", "1", "hide", "隐藏", "开启": - next = true - default: - next = !user.HideAdult - } - } else { - next = !user.HideAdult - } - if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"hide_adult": next}); err != nil { - return telegramCommandReply{Text: "更新失败:" + err.Error()} - } - status := map[bool]string{true: "已隐藏", false: "已显示"}[next] - return telegramCommandReply{ - Text: "成人目录" + status + "。此设置会同步影响网页与第三方客户端。", - Buttons: [][]telegramInlineButton{{{ - Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[next], - Data: "adult_toggle", - }}}, - } -} diff --git a/internal/service/telegram_bot_commands_help.go b/internal/service/telegram_bot_commands_help.go deleted file mode 100644 index 6390753..0000000 --- a/internal/service/telegram_bot_commands_help.go +++ /dev/null @@ -1,81 +0,0 @@ -package service - -import "context" - -// cmdHelp 处理 /help 命令。 -func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage) string { - channel := s.findChannelForMessage(ctx, msg) - if telegramIsGroupChat(msg.Chat.Type) { - adminHint := "" - if s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { - adminHint = "\n\n管理员可在已绑定群组直接发送文本管理命令;管理面板和账号凭据操作请私聊 Bot。" - } - return "MediaStationGo 群组可用命令\n\n" + - "/menu — 打开群组自助菜单\n" + - "/account — 查看账号状态\n" + - "/signin — 签到\n" + - "/devices — 查看登录设备\n" + - "/kick all|编号 — 踢下线设备\n" + - "/hideadult on|off — 隐藏或显示成人目录\n\n" + - "绑定、注册、兑换、改名、改密等包含敏感信息的操作请私聊 Bot。" + - adminHint - } - if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { - register := "" - if s.openRegEnabled(ctx) { - register = "/register 用户名 密码 — 注册新账号\n" - } - return "MediaStationGo 用户命令\n\n" + - register + - "/start 用户名 密码 — 绑定账号\n" + - "/account — 查看账号状态\n" + - "/signin — 签到\n" + - "/devices — 查看登录设备\n" + - "/kick all|编号 — 踢下线设备\n" + - "/setname 当前密码 新用户名 — 修改用户名\n" + - "/setpass 当前密码 新密码 — 修改密码\n" + - "/redeem 兑换码 — 注册或续期兑换\n" + - "/hideadult on|off — 隐藏或显示成人目录\n\n" + - "系统状态、搜索、下载列表与统计命令仅管理员可用。" - } - return "MediaStationGo 命令列表\n\n" + - "/start — 开始使用\n" + - "/help — 帮助信息\n" + - "/account / /devices / /kick all|编号 — 用户自助设备管理\n" + - "/signin / /redeem 兑换码 — 签到与兑换\n" + - "/setname 当前密码 新用户名 / /setpass 当前密码 新密码 — 用户自助改名改密\n" + - "/register 用户名 密码 — 注册新账号(需管理员开启)\n" + - "/registration on [名额]|off — 开启/关闭普通用户注册(管理员)\n" + - "/capacity / /users — 容量与用户管理(管理员)\n" + - "/gencode register|renew 天数 [有效天数] — 生成兑换码(管理员)\n" + - "/renew_user 用户名 天数 / /delete_user 用户名 confirm — 续期/删除用户(管理员)\n" + - "/unbind 用户1 用户2 — 批量解绑 Telegram 绑定(管理员)\n" + - "/unbind_duplicates / /unbind_inactive 天数 — 清理重复/无效绑定或久未登录绑定(管理员)\n" + - "/antishare on play=3 login=3 warn=2 — 防共享策略(管理员)\n" + - "/cleanup run — 预览保号清理候选(管理员)\n" + - "/cleanup run confirm — 确认清理候选账号(管理员)\n" + - "/cleanup on|off — 保号规则开关(管理员)\n" + - "/cleanup_rule list|add|edit|修改|del|enable|disable — Mgo 保号规则(管理员)\n" + - "/ban 用户名 / /unban 用户名 — 禁用/解禁用户(管理员)\n" + - "/hideadult on|off — 隐藏/显示当前绑定账号的成人目录\n" + - "/status — 系统运行状态\n" + - "/search 关键词 — 搜索媒体库\n" + - "/downloads — 下载列表\n" + - "/stats — 媒体库统计\n\n" + - telegramMgoAdminCommandHelp() + "\n\n" + - "自动推送事件:\n" + - "• 订阅命中新资源\n" + - "• 下载任务完成\n" + - "• 刮削失败告警\n" + - "• 系统异常通知" -} - -func telegramMgoAdminCommandHelp() string { - return "Mgo 管理命令(管理员可用,已注册到命令栏):\n" + - "用户:/ucr 用户名 密码 [天数] 创建账号;/uinfo 用户名 查询账号;/rmemby 用户名 confirm 删除账号;/only_rm_record tg:ID|用户名 仅删 Bot 绑定;/renewall 天数 confirm 批量续期。\n" + - "审计:/userip 用户名 查用户 IP;/auditip IP 按 IP 审计;/auditdevice 关键词 按终端设备审计;/auditclient 关键词 按客户端审计;/udeviceid 设备ID 按设备指纹审计。\n" + - "清理:/syncunbound 检查未绑定账号;/syncgroupm 校验群成员;/check_ex 检查过期账号;/deleted 按保号规则预览清理候选。\n" + - "权限:/embyadmin 用户名 on|off 设置管理员;/banall confirm//unbanall confirm 批量禁用/解禁;/prouser 用户名//revuser 用户名 管理保护名单;/embylibs_blockall//embylibs_unblockall 批量禁用/开放媒体库权限。\n" + - "运维:/proadmin TelegramID//revadmin TelegramID 管理 Bot 管理员;/backup_db 备份数据库;/restore_from_db 文件名 confirm 恢复数据库。\n" + - "说明:重复别名如 /low_activity、/urm 仍可兼容识别,但不显示在命令栏。" -} diff --git a/internal/service/telegram_bot_user_test.go b/internal/service/telegram_bot_user_test.go deleted file mode 100644 index d506f41..0000000 --- a/internal/service/telegram_bot_user_test.go +++ /dev/null @@ -1,286 +0,0 @@ -package service - -import ( - "encoding/json" - "strings" - "testing" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestTelegramUpdateActionableDispatchesCallbackQuery(t *testing.T) { - if !telegramUpdateActionable(TelegramUpdate{CallbackQuery: &TelegramCallbackQuery{Data: "adult_toggle"}}) { - t.Fatal("callback_query update must be dispatched, otherwise inline buttons break") - } - if !telegramUpdateActionable(TelegramUpdate{Message: &TelegramMessage{Text: "/help"}}) { - t.Fatal("text command message must be dispatched") - } - if telegramUpdateActionable(TelegramUpdate{}) { - t.Fatal("empty update must be skipped") - } - if telegramUpdateActionable(TelegramUpdate{Message: &TelegramMessage{}}) { - t.Fatal("message without text must be skipped") - } -} - -func TestTelegramCallbackTogglesAdultVisibility(t *testing.T) { - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - user, _, err := auth.Register(ctx, "viewer", "secret-pass") - if err != nil { - t.Fatalf("register user: %v", err) - } - if err := repos.DB.Create(&model.TelegramBinding{ - TelegramUserID: 30001, - TelegramName: "@viewer", - ChatID: 30001, - UserID: user.ID, - }).Error; err != nil { - t.Fatalf("create binding: %v", err) - } - if err := repos.DB.AutoMigrate(&model.NotifyChannel{}); err != nil { - t.Fatalf("migrate notify_channels: %v", err) - } - // 配置一个绑定该 Telegram 用户的渠道(无 bot_token,避免测试触发网络请求)。 - cfg, _ := json.Marshal(map[string]string{"admin_user_ids": "30001"}) - if err := repos.DB.Create(&model.NotifyChannel{ - Name: "Telegram", - Type: "telegram", - Enabled: true, - Config: string(cfg), - }).Error; err != nil { - t.Fatalf("create channel: %v", err) - } - - before, err := repos.User.FindByID(ctx, user.ID) - if err != nil || before == nil { - t.Fatalf("load user before toggle: %v", err) - } - - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - update, _ := json.Marshal(TelegramUpdate{ - UpdateID: 1, - CallbackQuery: &TelegramCallbackQuery{ - ID: "cb1", - From: TelegramUser{ID: 30001, Username: "viewer", FirstName: "Viewer"}, - Message: &TelegramMessage{MessageID: 5, Chat: TelegramChat{ID: 30001, Type: "private"}}, - Data: "adult_toggle", - }, - }) - // reply 因 bot_token 为空会返回错误,但成人目录状态应已在数据库中被切换。 - _ = bot.HandleWebhook(ctx, update) - - updated, err := repos.User.FindByID(ctx, user.ID) - if err != nil || updated == nil { - t.Fatalf("reload user: %v", err) - } - if updated.HideAdult == before.HideAdult { - t.Fatalf("adult_toggle callback should have flipped HideAdult (was %v)", before.HideAdult) - } -} - -func TestTelegramRegisterRespectsAdminToggle(t *testing.T) { - ctx := t.Context() - repos, auth, _, _ := newAuthTestServices(t) - if err := repos.DB.AutoMigrate(&model.Setting{}, &model.NotifyChannel{}); err != nil { - t.Fatalf("migrate: %v", err) - } - // 预置一个管理员,确保通过 Bot 注册的用户是普通角色而非首个管理员。 - if _, _, err := auth.Register(ctx, "rootadmin", "admin-pass"); err != nil { - t.Fatalf("seed admin: %v", err) - } - - bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth) - // 把注册者放进 admin_user_ids,即可让 telegramUserCanBind 通过(私聊场景, - // 无需走 getChatMember 网络校验);注册流程本身不依赖角色。 - cfgJSON, _ := json.Marshal(map[string]string{"admin_user_ids": "999"}) - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfgJSON)} - - msg := &TelegramMessage{From: TelegramUser{ID: 999, Username: "newbie", FirstName: "Newbie"}, Chat: TelegramChat{ID: 999, Type: "private"}} - - // 默认关闭:拒绝且不创建用户。 - if reply := bot.cmdRegister(ctx, channel, msg, []string{"newbie", "secret-pass"}); !strings.Contains(reply.Text, "未开放") { - t.Fatalf("registration disabled by default, got %q", reply.Text) - } - if u, _ := repos.User.FindByUsername(ctx, "newbie"); u != nil { - t.Fatal("no user should be created while registration disabled") - } - - // 管理员开启后注册成功并自动绑定。 - if err := bot.setRegistrationEnabled(ctx, true); err != nil { - t.Fatalf("enable registration: %v", err) - } - reply := bot.cmdRegister(ctx, channel, msg, []string{"newbie", "secret-pass"}) - if !strings.Contains(reply.Text, "注册并绑定成功") { - t.Fatalf("expected success reply, got %q", reply.Text) - } - created, err := repos.User.FindByUsername(ctx, "newbie") - if err != nil || created == nil { - t.Fatalf("user should be created after enabling: %v", err) - } - if created.Role != "user" { - t.Fatalf("bot-registered account should be a regular user, got role %q", created.Role) - } - if binding := bot.telegramBinding(ctx, 999); binding == nil || binding.UserID != created.ID { - t.Fatalf("telegram should be bound to the newly registered user") - } - - // 重复注册:已绑定 → 提示无需重复注册。 - if reply := bot.cmdRegister(ctx, channel, msg, []string{"another", "pass-2"}); !strings.Contains(reply.Text, "无需重复注册") { - t.Fatalf("expected already-bound reply, got %q", reply.Text) - } -} - -func TestTelegramGroupHidesAdminPanelFromRegularUsers(t *testing.T) { - ctx := t.Context() - _, bot := newBotTestService(t) - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"group_chat_id":"-100123","admin_user_ids":"9001"}`} - msg := &TelegramMessage{ - From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"}, - Chat: TelegramChat{ID: -100123, Type: "supergroup"}, - } - - menu := bot.mainMenu(ctx, channel, msg) - if strings.Contains(menu.Text, "管理员") || telegramReplyHasButtonPrefix(menu, "adm_") { - t.Fatalf("regular group user must not see admin panel: text=%q buttons=%#v", menu.Text, menu.Buttons) - } - - reply, err := bot.executeCommand(ctx, channel, msg, "/users") - if err != nil { - t.Fatal(err) - } - if reply.Text != "" || len(reply.Buttons) != 0 { - t.Fatalf("regular group user admin command should be ignored, got %#v", reply) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/start viewer secret-pass") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "请私聊 Bot") { - t.Fatalf("group credential command should point to private chat, got %q", reply.Text) - } -} - -func TestTelegramGroupAdminMenuExposesButtonsOnlyToAdmins(t *testing.T) { - ctx := t.Context() - repos, bot := newBotTestService(t) - admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} - if err := repos.User.Create(ctx, admin); err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"group_chat_id":"-100123","admin_user_ids":"9001"}`} - msg := &TelegramMessage{ - From: TelegramUser{ID: 9001, Username: "admin", FirstName: "Admin"}, - Chat: TelegramChat{ID: -100123, Type: "group"}, - } - - menu := bot.mainMenu(ctx, channel, msg) - if !telegramReplyHasButtonPrefix(menu, "adm_") { - t.Fatalf("admin group menu should expose admin buttons, got %#v", menu.Buttons) - } - if !strings.Contains(menu.Text, "管理员入口") { - t.Fatalf("admin group menu should label admin section, got %q", menu.Text) - } - - reply, handled := bot.handleMenuCallback(ctx, channel, msg, "adm_users") - if !handled { - t.Fatal("admin callback should be handled") - } - if !strings.Contains(reply.Text, "用户管理") { - t.Fatalf("group admin callback should render admin panel, got %#v", reply) - } - - normal := &TelegramMessage{ - From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"}, - Chat: TelegramChat{ID: -100123, Type: "group"}, - } - normalMenu := bot.mainMenu(ctx, channel, normal) - if telegramReplyHasButtonPrefix(normalMenu, "adm_") || strings.Contains(normalMenu.Text, "管理员入口") { - t.Fatalf("normal group user must not see admin controls: %#v", normalMenu) - } - normalReply, handled := bot.handleMenuCallback(ctx, channel, normal, "adm_users") - if !handled || normalReply.Text != "" || len(normalReply.Buttons) != 0 { - t.Fatalf("normal group user must not use admin callbacks: %#v handled=%v", normalReply, handled) - } - - reply, err := bot.executeCommand(ctx, channel, msg, "/users") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "用户管理") { - t.Fatalf("bound group admin text command should run, got %q", reply.Text) - } - if len(reply.Buttons) == 0 { - t.Fatalf("group admin text command should expose admin action buttons: %#v", reply.Buttons) - } -} - -func TestTelegramPollingChannelHintWinsForPrivateMessages(t *testing.T) { - ctx := t.Context() - repos, bot := newBotTestService(t) - msg := &TelegramMessage{ - From: TelegramUser{ID: 9101, Username: "viewer", FirstName: "Viewer"}, - Chat: TelegramChat{ID: 9101, Type: "private"}, - } - bad := model.NotifyChannel{Name: "BadToken", Type: "telegram", Enabled: true, Config: `{"bot_token":"bad","admin_user_ids":"9101"}`} - good := model.NotifyChannel{Name: "GoodToken", Type: "telegram", Enabled: true, Config: `{"bot_token":"good","admin_user_ids":"9101"}`} - if err := repos.DB.Create(&bad).Error; err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&good).Error; err != nil { - t.Fatal(err) - } - - if first := bot.findChannelForMessage(ctx, msg); first == nil || first.ID != bad.ID { - t.Fatalf("setup expected normal private lookup to pick first channel, got %#v", first) - } - if hinted := bot.channelForMessage(ctx, msg, &good); hinted == nil || hinted.ID != good.ID { - t.Fatalf("polling channel hint should route replies through the token that received the update, got %#v", hinted) - } -} - -func TestTelegramMgoCompatibleUserCommands(t *testing.T) { - ctx := t.Context() - repos, bot := newBotTestService(t) - user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user", IsActive: true} - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.TelegramBinding{TelegramUserID: 9102, ChatID: 9102, UserID: user.ID}).Error; err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9102"}`} - msg := &TelegramMessage{ - From: TelegramUser{ID: 9102, Username: "viewer", FirstName: "Viewer"}, - Chat: TelegramChat{ID: 9102, Type: "private"}, - } - - info, err := bot.executeCommand(ctx, channel, msg, "/myinfo") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(info.Text, "我的账号") { - t.Fatalf("/myinfo should show account info, got %q", info.Text) - } - count, err := bot.executeCommand(ctx, channel, msg, "/count") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(count.Text, "媒体库统计") { - t.Fatalf("/count should show library counts, got %q", count.Text) - } -} - -func telegramReplyHasButtonPrefix(reply telegramCommandReply, prefix string) bool { - for _, row := range reply.Buttons { - for _, button := range row { - if strings.HasPrefix(button.Data, prefix) { - return true - } - } - } - return false -} diff --git a/internal/service/telegram_callback.go b/internal/service/telegram_callback.go deleted file mode 100644 index 08816ae..0000000 --- a/internal/service/telegram_callback.go +++ /dev/null @@ -1,77 +0,0 @@ -package service - -import ( - "context" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) handleCallback(ctx context.Context, cb *TelegramCallbackQuery, channelHint *model.NotifyChannel) error { - if cb == nil || cb.Message == nil { - return nil - } - msg := *cb.Message - msg.From = cb.From - channel := s.channelForMessage(ctx, &msg, channelHint) - if channel == nil { - channel = s.findChannelByChatID(ctx, cb.Message.Chat.ID) - } - // 立即应答回调,关闭按钮上的加载状态,避免客户端长时间转圈。 - if telegramIsGroupChat(cb.Message.Chat.Type) { - s.answerCallbackWithText(ctx, channel, cb.ID, "为了隐私,群组内按钮面板已禁用。请私聊 Bot 或在群里发送 /menu,我会把面板私聊给你。", true) - s.deleteTelegramSourceMessage(channel, cb.Message.Chat.ID, cb.Message.MessageID) - return nil - } - if cb.Message.Chat.Type == "private" && cb.Message.Chat.ID != cb.From.ID { - s.answerCallbackWithText(ctx, channel, cb.ID, "这个面板不属于你,请发送 /menu 打开自己的面板。", true) - return nil - } - s.answerCallback(ctx, channel, cb.ID) - data := strings.TrimSpace(cb.Data) - if data == "adult_toggle" { - reply := s.cmdHideAdult(ctx, &msg, nil) - if reply.Text != "" { - err := s.reply(ctx, channel, cb.Message.Chat.ID, reply) - s.deleteTelegramSourceMessage(channel, cb.Message.Chat.ID, cb.Message.MessageID) - return err - } - return nil - } - if reply, handled := s.handleMenuCallback(ctx, channel, &msg, data); handled { - if reply.Text != "" { - err := s.reply(ctx, channel, cb.Message.Chat.ID, reply) - s.deleteTelegramSourceMessage(channel, cb.Message.Chat.ID, cb.Message.MessageID) - return err - } - } - return nil -} - -// answerCallback 应答 Telegram 回调查询,关闭按钮上的加载提示。 -func (s *TelegramBotService) answerCallback(ctx context.Context, channel *model.NotifyChannel, callbackID string) { - s.answerCallbackWithText(ctx, channel, callbackID, "", false) -} - -func (s *TelegramBotService) answerCallbackWithText(ctx context.Context, channel *model.NotifyChannel, callbackID, text string, showAlert bool) { - if channel == nil || strings.TrimSpace(callbackID) == "" { - return - } - cfg := s.telegramChannelConfig(channel) - if strings.TrimSpace(cfg["bot_token"]) == "" { - return - } - payload := map[string]interface{}{ - "callback_query_id": callbackID, - } - if strings.TrimSpace(text) != "" { - payload["text"] = text - payload["show_alert"] = showAlert - } - if err := telegramPostJSON(ctx, cfg, "answerCallbackQuery", payload, 8*time.Second); err != nil { - s.log.Debug("telegram answerCallbackQuery failed", zap.Error(sanitizeTelegramError(err))) - } -} diff --git a/internal/service/telegram_channel_resolve.go b/internal/service/telegram_channel_resolve.go deleted file mode 100644 index af30068..0000000 --- a/internal/service/telegram_channel_resolve.go +++ /dev/null @@ -1,160 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "strconv" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// findChannelByChatID 根据 chat_id 查找已配置的通知渠道。 -func (s *TelegramBotService) findChannelByChatID(ctx context.Context, chatID int) *model.NotifyChannel { - channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram") - if err != nil { - return nil - } - target := strconv.Itoa(chatID) - for _, ch := range channels { - if !ch.Enabled { - continue - } - configStr := ch.Config - if s.crypto != nil && configStr != "" { - configStr = s.crypto.Decrypt(configStr) - } - var cfg map[string]string - if err := json.Unmarshal([]byte(configStr), &cfg); err != nil { - continue - } - if cfg["chat_id"] == target || cfg["command_chat_id"] == target || - cfg["group_chat_id"] == target || cfg["channel_chat_id"] == target { - return &ch - } - } - if len(channels) == 1 && channels[0].Enabled { - return &channels[0] - } - return nil -} - -func (s *TelegramBotService) findChannelForMessage(ctx context.Context, msg *TelegramMessage) *model.NotifyChannel { - if msg == nil { - return nil - } - if msg.Chat.Type != "" && msg.Chat.Type != "private" { - return s.findChannelByChatID(ctx, msg.Chat.ID) - } - channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram") - if err != nil { - return nil - } - var first *model.NotifyChannel - for i := range channels { - ch := channels[i] - if !ch.Enabled { - continue - } - if first == nil { - first = &ch - } - if s.telegramUserIsAdmin(ctx, &ch, msg.From.ID) || s.telegramUserCanBind(ctx, &ch, msg.From.ID) { - return &ch - } - } - return first -} - -func (s *TelegramBotService) channelForMessage(ctx context.Context, msg *TelegramMessage, hint *model.NotifyChannel) *model.NotifyChannel { - if hint == nil { - return s.findChannelForMessage(ctx, msg) - } - if msg == nil { - return hint - } - if msg.Chat.Type != "" && msg.Chat.Type != "private" && !s.telegramChatAllowed(hint, msg.Chat.ID) { - return nil - } - return hint -} - -func (s *TelegramBotService) telegramChatAllowed(channel *model.NotifyChannel, chatID int) bool { - if channel == nil { - return false - } - configStr := channel.Config - if s.crypto != nil && configStr != "" { - configStr = s.crypto.Decrypt(configStr) - } - var cfg map[string]string - if err := json.Unmarshal([]byte(configStr), &cfg); err != nil { - return false - } - target := strconv.Itoa(chatID) - for _, key := range []string{"group_chat_id", "channel_chat_id", "command_chat_id"} { - if configured := strings.TrimSpace(cfg[key]); configured != "" && configured == target { - return true - } - } - if strings.TrimSpace(cfg["group_chat_id"]) != "" || strings.TrimSpace(cfg["channel_chat_id"]) != "" || strings.TrimSpace(cfg["command_chat_id"]) != "" { - return false - } - return strings.TrimSpace(cfg["chat_id"]) == target -} - -func (s *TelegramBotService) telegramUserIDConfigured(channel *model.NotifyChannel, telegramUserID int) bool { - if channel == nil || telegramUserID == 0 { - return false - } - cfg := s.telegramChannelConfig(channel) - target := strconv.Itoa(telegramUserID) - for _, value := range telegramConfiguredUserIDs(cfg["admin_user_ids"]) { - if value == target { - return true - } - } - if strings.TrimSpace(cfg["admin_user_ids"]) == "" && strings.TrimSpace(cfg["chat_id"]) == target { - return true - } - return false -} - -func (s *TelegramBotService) telegramChannelConfig(channel *model.NotifyChannel) map[string]string { - return telegramConfigFromChannel(s.crypto, channel) -} - -func telegramConfigFromChannel(crypto *CryptoService, channel *model.NotifyChannel) map[string]string { - if channel == nil { - return map[string]string{} - } - configStr := channel.Config - if crypto != nil && configStr != "" { - configStr = crypto.Decrypt(configStr) - } - var cfg map[string]string - if err := json.Unmarshal([]byte(configStr), &cfg); err != nil || cfg == nil { - return map[string]string{} - } - normalizeTelegramConfig(cfg) - return cfg -} - -func normalizeTelegramConfig(cfg map[string]string) { - if cfg == nil { - return - } - chatID := strings.TrimSpace(cfg["chat_id"]) - if chatID == "" { - return - } - if strings.HasPrefix(chatID, "-") { - if strings.TrimSpace(cfg["group_chat_id"]) == "" && strings.TrimSpace(cfg["channel_chat_id"]) == "" && strings.TrimSpace(cfg["command_chat_id"]) == "" { - cfg["group_chat_id"] = chatID - } - return - } - if strings.TrimSpace(cfg["admin_user_ids"]) == "" { - cfg["admin_user_ids"] = chatID - } -} diff --git a/internal/service/telegram_cleanup_rules.go b/internal/service/telegram_cleanup_rules.go deleted file mode 100644 index 6103b83..0000000 --- a/internal/service/telegram_cleanup_rules.go +++ /dev/null @@ -1,220 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "strings" -) - -func (s *TelegramBotService) currentCleanupRules(ctx context.Context) []accountCleanupRule { - cfg := loadBotConfig(ctx, s.repo) - return cfg.AccountCleanupRules -} - -func (s *TelegramBotService) saveCleanupRules(ctx context.Context, rules []accountCleanupRule) error { - raw, err := json.Marshal(normalizeCleanupRules(rules)) - if err != nil { - return err - } - return s.repo.Setting.Set(ctx, SettingAccountCleanupRules, string(raw)) -} - -func parseCommandBool(value string) (bool, bool) { - switch strings.ToLower(strings.TrimSpace(value)) { - case "on", "true", "1", "yes", "enable", "enabled", "开启", "开": - return true, true - case "off", "false", "0", "no", "disable", "disabled", "关闭", "关": - return false, true - default: - return false, false - } -} - -func parseCleanupRuleCommand(args []string) (accountCleanupRule, error) { - if len(args) < 2 { - return accountCleanupRule{}, fmt.Errorf("新增规则参数不足") - } - rule := accountCleanupRule{ - Type: strings.ToLower(strings.TrimSpace(args[0])), - ID: strings.TrimSpace(args[1]), - Enabled: true, - WindowDaysMin: 3, - WindowDaysMax: 5, - MinHours: 6, - MinCount: 1, - } - switch rule.Type { - case "watch_hours": - name, values := cleanupRuleNameAndValues(args[2:], 3) - rule.Name = name - if len(values) >= 3 { - rule.WindowDaysMin, _ = strconv.Atoi(values[0]) - rule.WindowDaysMax, _ = strconv.Atoi(values[1]) - rule.MinHours, _ = strconv.ParseFloat(values[2], 64) - if rule.Name == "" { - rule.Name = fmt.Sprintf("%d~%d 天观看满 %s 小时", rule.WindowDaysMin, rule.WindowDaysMax, formatRuleHours(rule.MinHours)) - } - } - case "recent_login": - name, values := cleanupRuleNameAndValues(args[2:], 1) - rule.Name = name - if len(values) >= 1 { - rule.WindowDaysMax, _ = strconv.Atoi(values[0]) - if rule.Name == "" { - rule.Name = fmt.Sprintf("%d 天内登录", rule.WindowDaysMax) - } - } - case "signin_streak", "account_age_grace": - name, values := cleanupRuleNameAndValues(args[2:], 1) - rule.Name = name - if len(values) >= 1 { - rule.MinCount, _ = strconv.Atoi(values[0]) - if rule.Name == "" { - if rule.Type == "signin_streak" { - rule.Name = fmt.Sprintf("连续签到 %d 天", rule.MinCount) - } else { - rule.Name = fmt.Sprintf("新号宽限 %d 天", rule.MinCount) - } - } - } - default: - return accountCleanupRule{}, fmt.Errorf("不支持的规则类型:%s", rule.Type) - } - normalized := normalizeCleanupRules([]accountCleanupRule{rule}) - if len(normalized) == 0 { - return accountCleanupRule{}, fmt.Errorf("规则无效") - } - return normalized[0], nil -} - -func cleanupRuleNameAndValues(args []string, numericCount int) (string, []string) { - if len(args) == 0 { - return "", nil - } - if len(args) >= numericCount && cleanupRuleValuesAreNumeric(args[:numericCount]) { - return "", args - } - return strings.TrimSpace(args[0]), args[1:] -} - -func cleanupRuleValuesAreNumeric(values []string) bool { - for _, value := range values { - if _, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err != nil { - return false - } - } - return true -} - -func formatCleanupRules(rules []accountCleanupRule) string { - if len(rules) == 0 { - return "保号规则\n\n暂无规则。" - } - var sb strings.Builder - sb.WriteString("保号规则\n") - for i, r := range rules { - state := map[bool]string{true: "启用", false: "停用"}[r.Enabled] - detail := cleanupRuleDetail(r) - parts := []string{ - fmt.Sprintf("\n%d. %s", i+1, r.ID), - } - if shouldShowCleanupRuleName(r, detail) { - parts = append(parts, r.Name) - } - parts = append(parts, cleanupRuleTypeLabel(r.Type), state) - if detail != "" { - parts = append(parts, detail) - } - sb.WriteString(strings.Join(parts, " · ")) - } - return sb.String() -} - -func shouldShowCleanupRuleName(r accountCleanupRule, detail string) bool { - name := strings.TrimSpace(r.Name) - if name == "" || strings.EqualFold(name, r.ID) { - return false - } - if detail != "" && strings.EqualFold(name, detail) { - return false - } - return true -} - -func cleanupRuleDetail(r accountCleanupRule) string { - switch r.Type { - case "watch_hours": - return fmt.Sprintf("%d~%d 天 %s 小时", r.WindowDaysMin, r.WindowDaysMax, formatRuleHours(r.MinHours)) - case "recent_login": - return fmt.Sprintf("%d 天内登录", r.WindowDaysMax) - case "signin_streak": - return fmt.Sprintf("连续签到 %d 天", r.MinCount) - case "account_age_grace": - return fmt.Sprintf("新号宽限 %d 天", r.MinCount) - default: - return "" - } -} - -func formatRuleHours(hours float64) string { - if hours == float64(int(hours)) { - return strconv.Itoa(int(hours)) - } - return fmt.Sprintf("%.1f", hours) -} - -func cleanupRuleTypeLabel(t string) string { - switch t { - case "watch_hours": - return "观看时长" - case "recent_login": - return "最近登录" - case "signin_streak": - return "连续签到" - case "account_age_grace": - return "新号宽限" - default: - return t - } -} - -func cleanupRuleHelp() string { - return "Mgo 保号规则命令\n\n" + - "/cleanup_rule list — 查看规则\n" + - "/cleanup_rule add watch_hours watch_3_5d_6h 观看3到5天满6小时 3 5 6\n" + - "/cleanup_rule add recent_login login_7d 七天内登录 7\n" + - "/cleanup_rule add signin_streak sign_3 连续签到3天 3\n" + - "/cleanup_rule add account_age_grace new_7d 新号宽限7天 7\n" + - "/cleanup_rule edit 规则类型 规则ID 名称 参数... — 修改同 ID 规则\n" + - "/cleanup_rule 修改 规则类型 规则ID 名称 参数... — 中文修改入口\n" + - "/cleanup_rule enable 规则ID / disable 规则ID\n" + - "/cleanup_rule del 规则ID\n\n" + - "保号模式固定为:满足任意一条启用规则即保留;全部不满足才会清理。" -} - -func onOff(b bool) string { - return map[bool]string{true: "已开启", false: "已关闭"}[b] -} - -func toggleLabel(name string, enabled bool) string { - if enabled { - return "关闭" + name - } - return "开启" + name -} - -func cleanupModeLabel(mode string) string { - return "满足任意一条" -} - -func countEnabledCleanupRules(rules []accountCleanupRule) int { - n := 0 - for _, r := range rules { - if r.Enabled { - n++ - } - } - return n -} diff --git a/internal/service/telegram_cleanup_rules_test.go b/internal/service/telegram_cleanup_rules_test.go deleted file mode 100644 index 47f5fdf..0000000 --- a/internal/service/telegram_cleanup_rules_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package service - -import ( - "strings" - "testing" -) - -func TestParseCleanupRuleCommandWithNamedWatchHours(t *testing.T) { - rule, err := parseCleanupRuleCommand([]string{"watch_hours", "watch_3_5d_6h", "观看3到5天满6小时", "3", "5", "6"}) - if err != nil { - t.Fatal(err) - } - if rule.Type != "watch_hours" || rule.ID != "watch_3_5d_6h" || rule.Name != "观看3到5天满6小时" { - t.Fatalf("unexpected rule identity: %+v", rule) - } - if !rule.Enabled || rule.WindowDaysMin != 3 || rule.WindowDaysMax != 5 || rule.MinHours != 6 { - t.Fatalf("unexpected watch-hours rule values: %+v", rule) - } -} - -func TestFormatCleanupRulesShowsUsefulDetails(t *testing.T) { - text := formatCleanupRules([]accountCleanupRule{{ - ID: "login_7d", - Type: "recent_login", - Name: "七天内登录", - Enabled: true, - WindowDaysMax: 7, - }}) - for _, want := range []string{"保号规则", "login_7d", "七天内登录", "最近登录", "启用"} { - if !strings.Contains(text, want) { - t.Fatalf("formatCleanupRules() missing %q in %q", want, text) - } - } -} diff --git a/internal/service/telegram_command_menus.go b/internal/service/telegram_command_menus.go deleted file mode 100644 index e7aefbc..0000000 --- a/internal/service/telegram_command_menus.go +++ /dev/null @@ -1,158 +0,0 @@ -package service - -import ( - "context" - "strings" - "time" -) - -func telegramSupportedCommand(cmd string) bool { - cmd = telegramCommandName(cmd) - if cmd == "" { - return false - } - _, ok := telegramSupportedCommandSet[cmd] - return ok -} - -var telegramSupportedCommandSet = map[string]struct{}{ - "/start": {}, "/menu": {}, "/cancel": {}, "/help": {}, "/hideadult": {}, "/hide_adult": {}, "/adult": {}, - "/account": {}, "/me": {}, "/myinfo": {}, "/count": {}, "/signin": {}, "/checkin": {}, "/devices": {}, "/kick": {}, "/setname": {}, "/rename": {}, "/setpass": {}, "/passwd": {}, "/password": {}, - "/redeem": {}, "/redeem_register": {}, "/redeem_renew": {}, - "/register": {}, "/reg": {}, "/signup": {}, "/registration": {}, "/reg_switch": {}, "/openreg": {}, - "/capacity": {}, "/users": {}, "/kk": {}, "/gencode": {}, "/renew_user": {}, "/delete_user": {}, "/unbind": {}, "/unbind_duplicates": {}, "/unbind_inactive": {}, - "/devicepolicy": {}, "/policy": {}, "/antishare": {}, "/cleanup": {}, "/cleanup_mode": {}, "/cleanup_rule": {}, - "/ban": {}, "/unban": {}, "/status": {}, "/search": {}, "/downloads": {}, "/stats": {}, - "/renew": {}, "/ucr": {}, "/uinfo": {}, "/rmemby": {}, "/urm": {}, "/only_rm_emby": {}, "/only_rm_record": {}, - "/userip": {}, "/udeviceid": {}, "/auditip": {}, "/auditdevice": {}, "/auditclient": {}, - "/renewall": {}, "/callall": {}, "/syncunbound": {}, "/syncgroupm": {}, "/kick_not_emby": {}, "/scan_embyname": {}, - "/check_ex": {}, "/deleted": {}, "/low_activity": {}, "/uranks": {}, "/days_ranks": {}, "/week_ranks": {}, - "/embyadmin": {}, "/unbanall": {}, "/banall": {}, "/embylibs_unblockall": {}, "/embylibs_blockall": {}, - "/extraembylibs_unblockall": {}, "/extraembylibs_blockall": {}, "/proadmin": {}, "/revadmin": {}, - "/backup_db": {}, "/restore_from_db": {}, "/prouser": {}, "/revuser": {}, -} - -type telegramBotCommand struct { - Command string `json:"command"` - Description string `json:"description"` -} - -func telegramBotCommandMenu() []telegramBotCommand { - return telegramPrivateBotCommandMenu() -} - -func telegramPrivateBotCommandMenu() []telegramBotCommand { - return []telegramBotCommand{ - {Command: "start", Description: "绑定账号或打开主菜单"}, - {Command: "menu", Description: "打开功能菜单"}, - {Command: "help", Description: "查看命令帮助"}, - {Command: "account", Description: "查看账号状态"}, - {Command: "signin", Description: "签到"}, - {Command: "devices", Description: "查看登录设备"}, - {Command: "kick", Description: "踢下线设备"}, - {Command: "setname", Description: "修改用户名(需当前密码)"}, - {Command: "setpass", Description: "修改密码(需当前密码)"}, - {Command: "hideadult", Description: "隐藏/显示成人媒体库"}, - {Command: "redeem", Description: "兑换注册码或续期码"}, - {Command: "register", Description: "注册新账号"}, - } -} - -func telegramGroupBotCommandMenu() []telegramBotCommand { - return []telegramBotCommand{ - {Command: "start", Description: "打开群组自助菜单"}, - {Command: "menu", Description: "打开群组自助菜单"}, - {Command: "help", Description: "查看群组可用命令"}, - {Command: "account", Description: "查看账号状态"}, - {Command: "signin", Description: "签到"}, - {Command: "devices", Description: "查看登录设备"}, - {Command: "kick", Description: "踢下线设备"}, - {Command: "hideadult", Description: "隐藏/显示成人媒体库"}, - } -} - -func telegramAdminBotCommandMenu() []telegramBotCommand { - commands := append([]telegramBotCommand{}, telegramPrivateBotCommandMenu()...) - commands = append(commands, - telegramBotCommand{Command: "status", Description: "系统运行状态(管理员)"}, - telegramBotCommand{Command: "search", Description: "搜索媒体库(管理员)"}, - telegramBotCommand{Command: "downloads", Description: "下载列表(管理员)"}, - telegramBotCommand{Command: "stats", Description: "媒体库统计(管理员)"}, - telegramBotCommand{Command: "users", Description: "用户管理(管理员)"}, - telegramBotCommand{Command: "cleanup", Description: "保号清理预览/确认(管理员)"}, - telegramBotCommand{Command: "cleanup_mode", Description: "查看固定保号模式(管理员)"}, - telegramBotCommand{Command: "cleanup_rule", Description: "Mgo保号规则管理(管理员)"}, - ) - commands = append(commands, telegramMgoAdminBotCommandMenu()...) - return commands -} - -func telegramMgoAdminBotCommandMenu() []telegramBotCommand { - return []telegramBotCommand{ - // 用户管理:保留 Sakura/Mgo 常用命令名,剔除 /urm、/only_rm_emby 等重复别名。 - {Command: "ucr", Description: "Mgo用户: 创建账号"}, - {Command: "uinfo", Description: "Mgo用户: 查询账号"}, - {Command: "rmemby", Description: "Mgo用户: 删除账号"}, - {Command: "only_rm_record", Description: "Mgo用户: 仅删Bot绑定"}, - {Command: "renewall", Description: "Mgo用户: 批量续期"}, - - // 审计:按 IP、设备指纹、客户端和 Telegram 绑定信息排查共享。 - {Command: "userip", Description: "Mgo审计: 查询用户IP"}, - {Command: "auditip", Description: "Mgo审计: 按IP审计"}, - {Command: "auditdevice", Description: "Mgo审计: 按设备审计"}, - {Command: "auditclient", Description: "Mgo审计: 按客户端审计"}, - {Command: "udeviceid", Description: "Mgo审计: 按设备ID审计"}, - - // 清理:/low_activity 是 /deleted 的兼容别名,不显示在命令栏。 - {Command: "syncunbound", Description: "Mgo清理: 未绑定账号"}, - {Command: "syncgroupm", Description: "Mgo清理: 校验群成员"}, - {Command: "check_ex", Description: "Mgo清理: 检查过期账号"}, - {Command: "deleted", Description: "Mgo清理: 保号清理预览"}, - - // 权限:批量禁用、保护用户、媒体库播放权限。 - {Command: "embyadmin", Description: "Mgo权限: 设置管理员"}, - {Command: "banall", Description: "Mgo权限: 批量禁用用户"}, - {Command: "unbanall", Description: "Mgo权限: 批量解禁用户"}, - {Command: "prouser", Description: "Mgo权限: 加入保护名单"}, - {Command: "revuser", Description: "Mgo权限: 移出保护名单"}, - {Command: "embylibs_blockall", Description: "Mgo权限: 批量禁用媒体库"}, - {Command: "embylibs_unblockall", Description: "Mgo权限: 批量开放媒体库"}, - - // 运维:Bot 管理员与数据库备份恢复。 - {Command: "proadmin", Description: "Mgo运维: 添加Bot管理员"}, - {Command: "revadmin", Description: "Mgo运维: 移除Bot管理员"}, - {Command: "backup_db", Description: "Mgo运维: 备份数据库"}, - {Command: "restore_from_db", Description: "Mgo运维: 恢复数据库"}, - } -} - -func registerTelegramBotCommands(ctx context.Context, cfg map[string]string) error { - if strings.TrimSpace(cfg["bot_token"]) == "" { - return nil - } - normalizeTelegramConfig(cfg) - if err := telegramSetBotCommands(ctx, cfg, telegramPrivateBotCommandMenu(), nil); err != nil { - return err - } - if err := telegramSetBotCommands(ctx, cfg, telegramPrivateBotCommandMenu(), map[string]interface{}{"type": "all_private_chats"}); err != nil { - return err - } - if err := telegramSetBotCommands(ctx, cfg, telegramGroupBotCommandMenu(), map[string]interface{}{"type": "all_group_chats"}); err != nil { - return err - } - _ = telegramSetBotCommands(ctx, cfg, telegramAdminBotCommandMenu(), map[string]interface{}{"type": "all_chat_administrators"}) - - adminCommands := telegramAdminBotCommandMenu() - for _, adminID := range telegramConfiguredUserIDs(cfg["admin_user_ids"]) { - _ = telegramSetBotCommands(ctx, cfg, adminCommands, map[string]interface{}{"type": "chat", "chat_id": adminID}) - } - return nil -} - -func telegramSetBotCommands(ctx context.Context, cfg map[string]string, commands []telegramBotCommand, scope map[string]interface{}) error { - payload := map[string]interface{}{"commands": commands} - if scope != nil { - payload["scope"] = scope - } - return telegramPostJSON(ctx, cfg, "setMyCommands", payload, 15*time.Second) -} diff --git a/internal/service/telegram_commands.go b/internal/service/telegram_commands.go deleted file mode 100644 index 6bdb682..0000000 --- a/internal/service/telegram_commands.go +++ /dev/null @@ -1,224 +0,0 @@ -// Package service — Telegram command registry and dispatch. -package service - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -type telegramCommandHandler func(args []string) (telegramCommandReply, error) - -type telegramCommandDefinition struct { - Aliases []string - AdminOnly bool - AdminOnlyText string - GroupAllowed bool - Handle telegramCommandHandler -} - -func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) []telegramCommandDefinition { - adminOnly := "此命令仅管理员可用。" - defs := s.telegramCoreCommandDefinitions(ctx, channel, msg) - defs = append(defs, s.telegramSelfServiceCommandDefinitions(ctx, channel, msg)...) - defs = append(defs, s.telegramAdminCoreCommandDefinitions(ctx, msg, adminOnly)...) - defs = append(defs, s.telegramMgoUserCommandDefinitions(ctx, adminOnly)...) - defs = append(defs, s.telegramMgoAuditCommandDefinitions(ctx, adminOnly)...) - defs = append(defs, s.telegramMgoMaintenanceCommandDefinitions(ctx, channel, adminOnly)...) - defs = append(defs, s.telegramMgoPolicyCommandDefinitions(ctx, channel, adminOnly)...) - return defs -} - -func (s *TelegramBotService) telegramCoreCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) []telegramCommandDefinition { - return []telegramCommandDefinition{ - {Aliases: []string{"/start"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { - if len(args) == 0 { - return s.mainMenu(ctx, channel, msg), nil - } - return s.cmdStart(ctx, msg, args), nil - }}, - {Aliases: []string{"/menu"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { - return s.mainMenu(ctx, channel, msg), nil - }}, - {Aliases: []string{"/cancel"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { - s.takePending(int64(msg.From.ID)) - return telegramCommandReply{Text: "已取消当前操作。"}, nil - }}, - {Aliases: []string{"/help"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { - return telegramCommandReply{Text: s.cmdHelp(ctx, msg)}, nil - }}, - } -} - -func (s *TelegramBotService) telegramSelfServiceCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) []telegramCommandDefinition { - return []telegramCommandDefinition{ - {Aliases: []string{"/hideadult", "/hide_adult", "/adult"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdHideAdult(ctx, msg, args), nil }}, - {Aliases: []string{"/account", "/me", "/myinfo"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.replyAccount(ctx, msg), nil }}, - {Aliases: []string{"/count"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdStats(ctx) }}, - {Aliases: []string{"/signin", "/checkin"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.replySignIn(ctx, msg), nil }}, - {Aliases: []string{"/devices"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.replyDevices(ctx, msg), nil }}, - {Aliases: []string{"/kick"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdKick(ctx, msg, args), nil }}, - {Aliases: []string{"/setname", "/rename"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSetName(ctx, msg, args), nil }}, - {Aliases: []string{"/setpass", "/passwd", "/password"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSetPass(ctx, msg, args), nil }}, - {Aliases: []string{"/redeem"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRedeem(ctx, channel, msg, args), nil }}, - {Aliases: []string{"/redeem_register"}, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdRedeemRegister(ctx, channel, msg, args), nil - }}, - {Aliases: []string{"/redeem_renew"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRedeemRenew(ctx, msg, args), nil }}, - {Aliases: []string{"/register", "/reg", "/signup"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRegister(ctx, channel, msg, args), nil }}, - } -} - -func (s *TelegramBotService) telegramAdminCoreCommandDefinitions(ctx context.Context, msg *TelegramMessage, adminOnly string) []telegramCommandDefinition { - return []telegramCommandDefinition{ - {Aliases: []string{"/registration", "/reg_switch", "/openreg"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRegistrationToggle(ctx, args), nil }}, - {Aliases: []string{"/capacity"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.replyCapacity(ctx), nil }}, - {Aliases: []string{"/users", "/kk"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.replyUserList(ctx), nil }}, - {Aliases: []string{"/gencode"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdGenCode(ctx, msg, args), nil }}, - {Aliases: []string{"/renew_user"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserRenew(ctx, args), nil }}, - {Aliases: []string{"/delete_user"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserDelete(ctx, args), nil }}, - {Aliases: []string{"/unbind"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUnbind(ctx, args), nil }}, - {Aliases: []string{"/unbind_duplicates"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUnbindDuplicates(ctx), nil }}, - {Aliases: []string{"/unbind_inactive"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUnbindInactive(ctx, args), nil }}, - {Aliases: []string{"/devicepolicy", "/policy"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdDevicePolicy(ctx, args), nil }}, - {Aliases: []string{"/antishare"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdAntiShare(ctx, args), nil }}, - {Aliases: []string{"/cleanup"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanup(ctx, args), nil }}, - {Aliases: []string{"/cleanup_mode"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanupMode(ctx, args), nil }}, - {Aliases: []string{"/cleanup_rule"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanupRule(ctx, args), nil }}, - {Aliases: []string{"/ban"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserBan(ctx, args, false), nil }}, - {Aliases: []string{"/unban"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserBan(ctx, args, true), nil }}, - {Aliases: []string{"/status"}, AdminOnly: true, AdminOnlyText: "此命令仅管理员可用。普通用户只能使用 /start 绑定账号,并通过按钮隐藏成人目录。", Handle: func(args []string) (telegramCommandReply, error) { return s.cmdStatus(ctx) }}, - {Aliases: []string{"/search"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSearch(ctx, args) }}, - {Aliases: []string{"/downloads"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdDownloads(ctx) }}, - {Aliases: []string{"/stats"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdStats(ctx) }}, - {Aliases: []string{"/renew"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserRenew(ctx, args), nil }}, - } -} - -func (s *TelegramBotService) telegramMgoUserCommandDefinitions(ctx context.Context, adminOnly string) []telegramCommandDefinition { - return []telegramCommandDefinition{ - {Aliases: []string{"/ucr"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoCreateUser(ctx, args), nil }}, - {Aliases: []string{"/uinfo"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoUserInfo(ctx, args), nil }}, - {Aliases: []string{"/rmemby", "/urm", "/only_rm_emby"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoDeleteUser(ctx, args), nil }}, - {Aliases: []string{"/only_rm_record"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoOnlyRemoveRecord(ctx, args), nil }}, - {Aliases: []string{"/userip"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoUserIP(ctx, args), nil }}, - } -} - -func (s *TelegramBotService) telegramMgoAuditCommandDefinitions(ctx context.Context, adminOnly string) []telegramCommandDefinition { - return []telegramCommandDefinition{ - {Aliases: []string{"/udeviceid"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoAuditDevices(ctx, "udeviceid", args), nil - }}, - {Aliases: []string{"/auditip"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoAuditDevices(ctx, "auditip", args), nil - }}, - {Aliases: []string{"/auditdevice"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoAuditDevices(ctx, "auditdevice", args), nil - }}, - {Aliases: []string{"/auditclient"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoAuditDevices(ctx, "auditclient", args), nil - }}, - } -} - -func (s *TelegramBotService) telegramMgoMaintenanceCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, adminOnly string) []telegramCommandDefinition { - return []telegramCommandDefinition{ - {Aliases: []string{"/renewall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoRenewAll(ctx, args), nil }}, - {Aliases: []string{"/callall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoCallAll(ctx, channel, args), nil }}, - {Aliases: []string{"/syncunbound"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoSyncUnbound(ctx, args), nil }}, - {Aliases: []string{"/syncgroupm"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoSyncGroup(ctx, channel, args), nil - }}, - {Aliases: []string{"/kick_not_emby"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoUnsupported("群内无号用户清理", "/syncgroupm 可检查已绑定账号是否仍在群内;Telegram Bot API 无法枚举全部群成员,因此不能可靠找出“在群但无号”的用户。"), nil - }}, - {Aliases: []string{"/scan_embyname"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoScanNames(ctx), nil }}, - {Aliases: []string{"/check_ex"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoCheckExpired(ctx, args), nil }}, - {Aliases: []string{"/deleted", "/low_activity"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanup(ctx, []string{"run"}), nil }}, - {Aliases: []string{"/uranks"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoRanks(ctx, 0, true), nil }}, - {Aliases: []string{"/days_ranks"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoRanks(ctx, 24*time.Hour, false), nil - }}, - {Aliases: []string{"/week_ranks"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoRanks(ctx, 7*24*time.Hour, false), nil - }}, - } -} - -func (s *TelegramBotService) telegramMgoPolicyCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, adminOnly string) []telegramCommandDefinition { - return []telegramCommandDefinition{ - {Aliases: []string{"/embyadmin"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoAdminRole(ctx, args), nil }}, - {Aliases: []string{"/unbanall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoBanAll(ctx, true, args), nil }}, - {Aliases: []string{"/banall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoBanAll(ctx, false, args), nil }}, - {Aliases: []string{"/embylibs_unblockall", "/extraembylibs_unblockall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoMediaAccessAll(ctx, true), nil - }}, - {Aliases: []string{"/embylibs_blockall", "/extraembylibs_blockall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoMediaAccessAll(ctx, false), nil - }}, - {Aliases: []string{"/proadmin"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoBotAdmin(ctx, channel, args, true), nil - }}, - {Aliases: []string{"/revadmin"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoBotAdmin(ctx, channel, args, false), nil - }}, - {Aliases: []string{"/backup_db"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoBackupDB(ctx), nil }}, - {Aliases: []string{"/restore_from_db"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdMgoRestoreDB(ctx, args), nil }}, - {Aliases: []string{"/prouser"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoProtectedUser(ctx, args, true), nil - }}, - {Aliases: []string{"/revuser"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { - return s.cmdMgoProtectedUser(ctx, args, false), nil - }}, - } -} - -func (s *TelegramBotService) telegramCommandRegistry(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) map[string]telegramCommandDefinition { - defs := s.telegramCommandDefinitions(ctx, channel, msg) - registry := make(map[string]telegramCommandDefinition, len(defs)*2) - for _, def := range defs { - for _, alias := range def.Aliases { - registry[alias] = def - } - } - return registry -} - -// executeCommand parses and dispatches Telegram commands through a registry so -// adding a command does not grow a monolithic switch. -func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, text string) (telegramCommandReply, error) { - parts := strings.Fields(text) - if len(parts) == 0 { - return telegramCommandReply{}, nil - } - - cmd := telegramCommandName(parts[0]) - args := parts[1:] - if msg.Chat.Type != "" && msg.Chat.Type != "private" && !s.telegramChatAllowed(channel, msg.Chat.ID) { - return telegramCommandReply{Text: "此群组/频道未绑定到 Bot 管理入口,请在通知渠道里填写「绑定群组 ID」或「绑定频道 ID」。"}, nil - } - - def, ok := s.telegramCommandRegistry(ctx, channel, msg)[cmd] - if !ok { - return telegramCommandReply{Text: fmt.Sprintf("未知命令: %s\n\n输入 /help 查看可用命令列表。", cmd)}, nil - } - if telegramIsGroupChat(msg.Chat.Type) && !def.GroupAllowed { - if !def.AdminOnly || !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { - return telegramCommandReply{}, nil - } - } - if def.AdminOnly && !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { - return telegramCommandReply{Text: def.AdminOnlyText}, nil - } - reply, err := def.Handle(args) - if telegramIsGroupChat(msg.Chat.Type) && def.AdminOnly && !def.GroupAllowed { - if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { - reply.Buttons = nil - } - } - return reply, err -} diff --git a/internal/service/telegram_device_policy.go b/internal/service/telegram_device_policy.go deleted file mode 100644 index 79eeb57..0000000 --- a/internal/service/telegram_device_policy.go +++ /dev/null @@ -1,252 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" -) - -func (s *TelegramBotService) replyDevicePolicy(ctx context.Context) telegramCommandReply { - cfg := loadBotConfig(ctx, s.repo) - text := fmt.Sprintf( - "设备策略\n\n① 防共享:%s\n 并发播放终端上限 %d / 登录终端上限 %d;同一终端多个 App 只算 1 台,App 作为登录渠道记录。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② Mgo 保号规则:%s\n 保号模式:%s;启用规则 %d 条。\n\n命令:\n/antishare on play=3 login=3 warn=2\n/cleanup run 预览候选\n/cleanup run confirm 确认清理\n/cleanup on|off\n/cleanup_rule list|add|edit|修改|del|enable|disable\n\n策略默认关闭;清理前会先预览候选;满足任意一条保号规则即保留;管理员/受保护账号永不自动处理。", - onOff(cfg.AntiShareEnabled), cfg.MaxConcurrentPlay, cfg.MaxLoggedClients, cfg.WarnThreshold, - onOff(cfg.AccountCleanupEnabled), cleanupModeLabel(cfg.AccountCleanupKeepMode), countEnabledCleanupRules(cfg.AccountCleanupRules)) - return telegramCommandReply{ - Text: text, - Buttons: [][]telegramInlineButton{ - {{Text: toggleLabel("防共享", cfg.AntiShareEnabled), Data: "dp_toggle:antishare"}}, - {{Text: toggleLabel("保号规则", cfg.AccountCleanupEnabled), Data: "dp_toggle:cleanup"}}, - {{Text: "⬅️ 返回菜单", Data: "menu_main"}}, - }, - } -} - -func (s *TelegramBotService) cmdDevicePolicy(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 || strings.EqualFold(args[0], "status") { - return s.replyDevicePolicy(ctx) - } - switch strings.ToLower(strings.TrimSpace(args[0])) { - case "run", "sweep": - return s.cmdCleanup(ctx, []string{"run"}) - default: - return telegramCommandReply{Text: "用法:/devicepolicy 查看策略,或使用 /antishare、/cleanup、/cleanup_rule 管理。"} - } -} - -func (s *TelegramBotService) cmdAntiShare(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 || strings.EqualFold(args[0], "status") { - return s.replyDevicePolicy(ctx) - } - enabled, ok := parseCommandBool(args[0]) - if !ok { - return telegramCommandReply{Text: "用法:/antishare on|off [play=3] [login=3] [warn=2],login 表示登录终端设备上限,同一终端多个 App 不重复计数。"} - } - if err := s.repo.Setting.Set(ctx, SettingAntiShareEnabled, strconv.FormatBool(enabled)); err != nil { - return telegramCommandReply{Text: "更新失败:" + err.Error()} - } - for _, arg := range args[1:] { - key, value, ok := strings.Cut(arg, "=") - if !ok { - continue - } - n, err := strconv.Atoi(strings.TrimSpace(value)) - if err != nil || n < 1 { - continue - } - switch strings.ToLower(strings.TrimSpace(key)) { - case "play", "maxplay", "播放": - _ = s.repo.Setting.Set(ctx, SettingMaxConcurrentPlay, strconv.Itoa(n)) - case "login", "client", "clients", "登录": - _ = s.repo.Setting.Set(ctx, SettingMaxLoggedClients, strconv.Itoa(n)) - case "warn", "warnings", "警告": - _ = s.repo.Setting.Set(ctx, SettingWarnThreshold, strconv.Itoa(n)) - } - } - return s.replyDevicePolicy(ctx) -} - -func (s *TelegramBotService) cmdCleanup(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 || strings.EqualFold(args[0], "status") { - return s.replyDevicePolicy(ctx) - } - switch strings.ToLower(strings.TrimSpace(args[0])) { - case "on", "true", "1", "开启", "enable": - if err := s.repo.Setting.Set(ctx, SettingAccountCleanupEnabled, "true"); err != nil { - return telegramCommandReply{Text: "开启失败:" + err.Error()} - } - return s.replyDevicePolicy(ctx) - case "off", "false", "0", "关闭", "disable": - if err := s.repo.Setting.Set(ctx, SettingAccountCleanupEnabled, "false"); err != nil { - return telegramCommandReply{Text: "关闭失败:" + err.Error()} - } - return s.replyDevicePolicy(ctx) - case "run", "sweep", "巡检", "preview", "预览": - device := s.device - if device == nil { - device = NewDeviceService(s.log, s.repo) - } - if len(args) > 1 && isCleanupConfirmArg(args[1]) { - cfg := loadBotConfig(ctx, s.repo) - if !cfg.AccountCleanupEnabled { - return telegramCommandReply{Text: "保号规则未开启,不会清理账号。"} - } - if countEnabledCleanupRules(cfg.AccountCleanupRules) == 0 { - return telegramCommandReply{Text: "没有启用的保号规则,不会清理账号。"} - } - removed, err := device.SweepAccountCleanup(ctx) - if err != nil { - return telegramCommandReply{Text: "确认清理失败:" + err.Error()} - } - return telegramCommandReply{Text: fmt.Sprintf("保号规则确认清理完成,已清理 %d 个账号。", removed)} - } - candidates, err := device.PreviewAccountCleanup(ctx) - if err != nil { - return telegramCommandReply{Text: "巡检预览失败:" + err.Error()} - } - return telegramCommandReply{Text: s.formatCleanupPreview(ctx, candidates)} - default: - return telegramCommandReply{Text: "用法:/cleanup on|off、/cleanup run 预览、/cleanup run confirm 确认清理"} - } -} - -func isCleanupConfirmArg(arg string) bool { - switch strings.ToLower(strings.TrimSpace(arg)) { - case "confirm", "yes", "delete", "确认", "清理", "删除": - return true - default: - return false - } -} - -func (s *TelegramBotService) formatCleanupPreview(ctx context.Context, candidates []accountCleanupCandidate) string { - cfg := loadBotConfig(ctx, s.repo) - if !cfg.AccountCleanupEnabled { - return "保号规则未开启,不会清理账号。" - } - if countEnabledCleanupRules(cfg.AccountCleanupRules) == 0 { - return "没有启用的保号规则,不会清理账号。" - } - if len(candidates) == 0 { - return "保号规则预览完成:没有需要清理的账号。" - } - var sb strings.Builder - sb.WriteString(fmt.Sprintf("保号规则预览\n\n将清理候选:%d 个账号。\n当前只是预览,未删除任何账号。\n\n", len(candidates))) - limit := len(candidates) - if limit > 10 { - limit = 10 - } - for i := 0; i < limit; i++ { - candidate := candidates[i] - sb.WriteString(fmt.Sprintf("%d. %s\n%s\n", i+1, escapeHTML(candidate.Username), escapeHTML(candidate.Details))) - } - if len(candidates) > limit { - sb.WriteString(fmt.Sprintf("……另有 %d 个候选未展示。\n", len(candidates)-limit)) - } - sb.WriteString("\n确认无误后再执行:/cleanup run confirm") - return sb.String() -} - -func (s *TelegramBotService) cmdCleanupMode(ctx context.Context, args []string) telegramCommandReply { - if err := s.repo.Setting.Set(ctx, SettingAccountCleanupKeepMode, "any"); err != nil { - return telegramCommandReply{Text: "更新失败:" + err.Error()} - } - if err := s.repo.Setting.Set(ctx, SettingAccountCleanupRequiredCount, "1"); err != nil { - return telegramCommandReply{Text: "更新失败:" + err.Error()} - } - reply := s.replyDevicePolicy(ctx) - reply.Text = "Mgo 保号模式固定为:满足任意一条启用规则即保留;只有全部规则都不满足才进入清理候选。\n\n" + reply.Text - return reply -} - -func (s *TelegramBotService) cmdCleanupRule(ctx context.Context, args []string) telegramCommandReply { - rules := s.currentCleanupRules(ctx) - if len(args) == 0 { - return telegramCommandReply{Text: formatCleanupRules(rules)} - } - action := strings.ToLower(strings.TrimSpace(args[0])) - switch action { - case "list", "ls", "status": - return telegramCommandReply{Text: formatCleanupRules(rules)} - case "help", "?", "帮助": - return telegramCommandReply{Text: cleanupRuleHelp()} - case "del", "delete", "rm": - if len(args) < 2 { - return telegramCommandReply{Text: "用法:/cleanup_rule del 规则ID"} - } - next := make([]accountCleanupRule, 0, len(rules)) - removed := false - for _, r := range rules { - if r.ID == args[1] { - removed = true - continue - } - next = append(next, r) - } - if !removed { - return telegramCommandReply{Text: "未找到该规则。"} - } - if err := s.saveCleanupRules(ctx, next); err != nil { - return telegramCommandReply{Text: "保存失败:" + err.Error()} - } - return telegramCommandReply{Text: "已删除规则。\n\n" + formatCleanupRules(next)} - case "enable", "on", "disable", "off": - if len(args) < 2 { - return telegramCommandReply{Text: "用法:/cleanup_rule enable|disable 规则ID"} - } - enable := action == "enable" || action == "on" - changed := false - for i := range rules { - if rules[i].ID == args[1] { - rules[i].Enabled = enable - changed = true - } - } - if !changed { - return telegramCommandReply{Text: "未找到该规则。"} - } - if err := s.saveCleanupRules(ctx, rules); err != nil { - return telegramCommandReply{Text: "保存失败:" + err.Error()} - } - return telegramCommandReply{Text: "已更新规则状态。\n\n" + formatCleanupRules(rules)} - case "add", "set", "edit", "update", "修改", "更新", "改": - rule, err := parseCleanupRuleCommand(args[1:]) - if err != nil { - return telegramCommandReply{Text: err.Error() + "\n\n" + cleanupRuleHelp()} - } - updated := false - for i := range rules { - if rules[i].ID == rule.ID { - rules[i] = rule - updated = true - break - } - } - if !updated { - rules = append(rules, rule) - } - rules = normalizeCleanupRules(rules) - if err := s.saveCleanupRules(ctx, rules); err != nil { - return telegramCommandReply{Text: "保存失败:" + err.Error()} - } - actionText := "已新增规则。" - if updated { - actionText = "已更新规则。" - } - return telegramCommandReply{Text: actionText + "\n\n" + formatCleanupRules(rules)} - default: - return telegramCommandReply{Text: cleanupRuleHelp()} - } -} - -func (s *TelegramBotService) replyDevicePolicyToggle(ctx context.Context, which string) telegramCommandReply { - cfg := loadBotConfig(ctx, s.repo) - switch which { - case "antishare": - _ = s.repo.Setting.Set(ctx, SettingAntiShareEnabled, strconv.FormatBool(!cfg.AntiShareEnabled)) - case "cleanup": - _ = s.repo.Setting.Set(ctx, SettingAccountCleanupEnabled, strconv.FormatBool(!cfg.AccountCleanupEnabled)) - } - return s.replyDevicePolicy(ctx) -} diff --git a/internal/service/telegram_membership.go b/internal/service/telegram_membership.go deleted file mode 100644 index 37f3c30..0000000 --- a/internal/service/telegram_membership.go +++ /dev/null @@ -1,122 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) telegramUserIsAdmin(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) bool { - if s.telegramUserIDConfigured(channel, telegramUserID) { - return true - } - binding := s.telegramBinding(ctx, telegramUserID) - if binding == nil { - return false - } - user, err := s.repo.User.FindByID(ctx, binding.UserID) - return err == nil && user != nil && user.Role == "admin" && user.IsActive -} - -// telegramBindDecision 表示成员资格校验的三态结果:通过 / 明确不通过 / -// 无法验证(getChatMember 出错,如 Bot 不在群、群 ID 失效、网络或代理不可达)。 -// 区分「明确不是成员」和「查不了」,是为了避免把验证失败误报成「你不在群」。 -type telegramBindDecision int - -const ( - bindDenied telegramBindDecision = iota // 已查实:不在任何绑定群组/频道 - bindAllowed // 管理员,或查实是某绑定群组/频道成员 - bindUnverifiable // 配了群组/频道但 getChatMember 全部失败 -) - -// telegramMembership 表示单个 chat 的成员资格三态。 -type telegramMembership int - -const ( - membershipNo telegramMembership = iota // 查实不是成员(left/kicked 等) - membershipYes // 查实是成员 - membershipUnknown // getChatMember 出错,无法判定 -) - -func (s *TelegramBotService) telegramUserBindDecision(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) telegramBindDecision { - if telegramUserID == 0 || channel == nil { - return bindDenied - } - if s.telegramUserIDConfigured(channel, telegramUserID) { - return bindAllowed - } - chatIDs := s.telegramMembershipChatIDs(channel) - if len(chatIDs) == 0 { - return bindDenied - } - sawUnknown := false - for _, chatID := range chatIDs { - switch s.telegramChatMembership(ctx, channel, chatID, telegramUserID) { - case membershipYes: - return bindAllowed - case membershipUnknown: - sawUnknown = true - } - } - if sawUnknown { - return bindUnverifiable - } - return bindDenied -} - -// telegramUserCanBind 是 telegramUserBindDecision 的布尔包装,供尽力而为的场景 -// 使用(如私聊时挑选可用渠道):只有查实通过才返回 true。 -func (s *TelegramBotService) telegramUserCanBind(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) bool { - return s.telegramUserBindDecision(ctx, channel, telegramUserID) == bindAllowed -} - -// telegramBindRejectText 根据三态结果生成面向用户的提示。action 形如「兑换注册账号」 -// 「绑定媒体中心账号」。bindUnverifiable 时不再误导用户「你不在群」,而是提示 -// 管理员检查 Bot 权限与群组 ID。 -func telegramBindRejectText(decision telegramBindDecision, action string) string { - if decision == bindUnverifiable { - return fmt.Sprintf("暂时无法验证你的群组/频道成员身份,%s未成功。这通常是因为 Bot 未加入绑定群组、在频道中不是管理员,或群组 ID 配置有误(如超级群需带 -100 前缀)。请联系管理员检查 Bot 权限与「绑定群组/频道 ID」。", action) - } - return fmt.Sprintf("当前 Telegram 账号不在管理员配置的绑定群组/频道中,无法%s。请先加入管理员配置的群组或频道;如果尚未配置,请联系管理员。", action) -} - -func (s *TelegramBotService) telegramChatMembership(ctx context.Context, channel *model.NotifyChannel, chatID string, telegramUserID int) telegramMembership { - cfg := s.telegramChannelConfig(channel) - if strings.TrimSpace(cfg["bot_token"]) == "" || chatID == "" || telegramUserID == 0 { - return membershipUnknown - } - payload := map[string]interface{}{ - "chat_id": chatID, - "user_id": telegramUserID, - } - var result struct { - OK bool `json:"ok"` - Result struct { - Status string `json:"status"` - } `json:"result"` - } - if err := telegramPostJSONDecode(ctx, cfg, "getChatMember", payload, 15*time.Second, &result); err != nil { - s.log.Warn("telegram getChatMember failed", zap.String("chat_id", chatID), zap.Int("telegram_user_id", telegramUserID), zap.Error(sanitizeTelegramError(err))) - return membershipUnknown - } - if !result.OK { - return membershipUnknown - } - switch strings.ToLower(result.Result.Status) { - case "creator", "administrator", "member", "restricted": - return membershipYes - default: - return membershipNo - } -} - -// telegramUserIsChatMember 是 telegramChatMembership 的布尔包装,仅在查实是成员时 -// 返回 true(查不了也视为非成员,供尽力而为的场景使用)。 -func (s *TelegramBotService) telegramUserIsChatMember(ctx context.Context, channel *model.NotifyChannel, chatID string, telegramUserID int) bool { - return s.telegramChatMembership(ctx, channel, chatID, telegramUserID) == membershipYes -} diff --git a/internal/service/telegram_menu.go b/internal/service/telegram_menu.go deleted file mode 100644 index 064d543..0000000 --- a/internal/service/telegram_menu.go +++ /dev/null @@ -1,219 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// pendingTTL bounds how long a button-initiated text prompt stays valid. -const pendingTTL = 5 * time.Minute - -func (s *TelegramBotService) setPending(userID int64, kind string) { - s.pendingMu.Lock() - s.pending[userID] = pendingInput{Kind: kind, CreatedAt: time.Now()} - s.pendingMu.Unlock() -} - -func (s *TelegramBotService) takePending(userID int64) (pendingInput, bool) { - s.pendingMu.Lock() - defer s.pendingMu.Unlock() - p, ok := s.pending[userID] - if ok { - delete(s.pending, userID) - } - if ok && time.Since(p.CreatedAt) > pendingTTL { - return pendingInput{}, false - } - return p, ok -} - -// boundUser resolves the local user bound to a Telegram account, or nil. -func (s *TelegramBotService) boundUser(ctx context.Context, telegramUserID int) *model.User { - binding := s.telegramBinding(ctx, telegramUserID) - if binding == nil { - return nil - } - u, _ := s.repo.User.FindByID(ctx, binding.UserID) - return u -} - -// handleMenuCallback routes inline-button taps. Returns (reply, handled). -func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, data string) (telegramCommandReply, bool) { - isAdmin := s.telegramUserIsAdmin(ctx, channel, msg.From.ID) - isGroup := telegramIsGroupChat(msg.Chat.Type) - if reply, handled := s.handleUserMenuCallback(ctx, channel, msg, data, isGroup); handled { - return reply, true - } - if !isAdmin { - if isGroup { - return telegramCommandReply{}, true - } - return telegramCommandReply{Text: "此功能仅管理员可用。"}, true - } - return s.handleAdminMenuCallback(ctx, msg, data) -} - -func (s *TelegramBotService) handleUserMenuCallback(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, data string, isGroup bool) (telegramCommandReply, bool) { - switch { - case data == "noop": - return telegramCommandReply{}, true - case data == "menu_main": - return s.mainMenu(ctx, channel, msg), true - case data == "act_account": - return s.replyAccount(ctx, msg), true - case data == "act_signin": - return s.replySignIn(ctx, msg), true - case data == "act_devices": - return s.replyDevices(ctx, msg), true - case strings.HasPrefix(data, "kick:"): - return s.replyKick(ctx, msg, strings.TrimPrefix(data, "kick:")), true - } - return s.handlePrivatePromptMenuCallback(ctx, msg, data, isGroup) -} - -func (s *TelegramBotService) handlePrivatePromptMenuCallback(ctx context.Context, msg *TelegramMessage, data string, isGroup bool) (telegramCommandReply, bool) { - switch data { - case "act_bind": - return telegramPrivateOnlyMenuReply(isGroup, "绑定账号", "请发送:/start 用户名 密码 绑定已有账号。"), true - case "act_register": - if isGroup { - return telegramCommandReply{Text: telegramGroupPrivateUserHint("注册账号")}, true - } - if !s.openRegEnabled(ctx) { - return telegramCommandReply{Text: "注册功能未开放,请联系管理员。"}, true - } - s.setPending(int64(msg.From.ID), "register") - return telegramCommandReply{Text: "请发送新账号的 用户名 密码(空格分隔),例如:alice mypass123"}, true - case "act_redeem_register": - if isGroup { - return telegramCommandReply{Text: telegramGroupPrivateUserHint("兑换码注册")}, true - } - s.setPending(int64(msg.From.ID), "redeem_register") - return telegramCommandReply{Text: "请发送你的注册兑换码,例如:ABCD2345EFGH\n(兑换后会要求设置用户名密码)"}, true - case "act_redeem_renew": - if isGroup { - return telegramCommandReply{Text: telegramGroupPrivateUserHint("兑换码续期")}, true - } - s.setPending(int64(msg.From.ID), "redeem_renew") - return telegramCommandReply{Text: "请发送你的续期兑换码,将为当前绑定账号续期。"}, true - case "act_setname": - return s.setPendingPrivatePrompt(msg, isGroup, "修改用户名", "setname", "请发送:当前密码 新用户名。"), true - case "act_setpass": - return s.setPendingPrivatePrompt(msg, isGroup, "修改密码", "setpass", "请发送:当前密码 新密码(新密码至少 6 位)。"), true - } - return telegramCommandReply{}, false -} - -func telegramPrivateOnlyMenuReply(isGroup bool, action, privateText string) telegramCommandReply { - if isGroup { - return telegramCommandReply{Text: telegramGroupPrivateUserHint(action)} - } - return telegramCommandReply{Text: privateText} -} - -func (s *TelegramBotService) setPendingPrivatePrompt(msg *TelegramMessage, isGroup bool, action, kind, text string) telegramCommandReply { - if isGroup { - return telegramCommandReply{Text: telegramGroupPrivateUserHint(action)} - } - s.setPending(int64(msg.From.ID), kind) - return telegramCommandReply{Text: text} -} - -func (s *TelegramBotService) handleAdminMenuCallback(ctx context.Context, msg *TelegramMessage, data string) (telegramCommandReply, bool) { - if reply, handled := s.handleAdminRegistrationCallback(ctx, msg, data); handled { - return reply, true - } - if reply, handled := s.handleAdminUserCallback(ctx, data); handled { - return reply, true - } - switch { - case data == "adm_capacity": - return s.replyCapacity(ctx), true - case data == "adm_devicepolicy": - return s.replyDevicePolicy(ctx), true - case data == "adm_mgo_commands": - return telegramCommandReply{Text: telegramMgoAdminCommandHelp(), Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}}, true - case strings.HasPrefix(data, "dp_toggle:"): - return s.replyDevicePolicyToggle(ctx, strings.TrimPrefix(data, "dp_toggle:")), true - } - return telegramCommandReply{}, false -} - -func (s *TelegramBotService) handleAdminRegistrationCallback(ctx context.Context, msg *TelegramMessage, data string) (telegramCommandReply, bool) { - switch { - case data == "adm_openreg": - return s.replyOpenRegMenu(ctx), true - case data == "adm_openreg_close": - _ = s.closeRegistration(ctx) - return telegramCommandReply{Text: "已关闭注册。"}, true - case strings.HasPrefix(data, "adm_openreg_set:"): - n, _ := strconv.Atoi(strings.TrimPrefix(data, "adm_openreg_set:")) - if err := s.openRegistration(ctx, n); err != nil { - return telegramCommandReply{Text: "开注失败:" + err.Error()}, true - } - label := "不限" - if n > 0 { - label = fmt.Sprintf("%d 个名额", n) - } - return telegramCommandReply{Text: "已开放注册:" + label + "。"}, true - case data == "adm_gencode": - return s.replyGenCodeMenu(), true - case strings.HasPrefix(data, "gc:"): - return s.replyGenCode(ctx, msg, data), true - } - return telegramCommandReply{}, false -} - -func (s *TelegramBotService) handleAdminUserCallback(ctx context.Context, data string) (telegramCommandReply, bool) { - switch { - case data == "adm_users": - return s.replyUserList(ctx), true - case strings.HasPrefix(data, "usr:"): - return s.replyUserActions(ctx, strings.TrimPrefix(data, "usr:")), true - case strings.HasPrefix(data, "uban:"): - return s.replyUserBan(ctx, strings.TrimPrefix(data, "uban:"), false), true - case strings.HasPrefix(data, "uunban:"): - return s.replyUserBan(ctx, strings.TrimPrefix(data, "uunban:"), true), true - case strings.HasPrefix(data, "udel:"): - return s.replyUserDelete(ctx, strings.TrimPrefix(data, "udel:")), true - case strings.HasPrefix(data, "urenew:"): - return s.replyUserRenew(ctx, strings.TrimPrefix(data, "urenew:")), true - } - return telegramCommandReply{}, false -} - -// handlePendingText consumes a button-initiated text prompt. Returns (reply, -// handled). handled=false means there was no pending prompt for this user. -func (s *TelegramBotService) handlePendingText(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, text string) (telegramCommandReply, bool) { - p, ok := s.takePending(int64(msg.From.ID)) - if !ok { - return telegramCommandReply{}, false - } - switch p.Kind { - case "register": - return s.cmdRegister(ctx, channel, msg, strings.Fields(text)), true - case "redeem_register": - return s.redeemRegisterFlow(ctx, channel, msg, text), true - case "redeem_renew": - return s.redeemRenewFlow(ctx, msg, text), true - case "setname": - return s.selfSetName(ctx, msg, text), true - case "setpass": - return s.selfSetPass(ctx, msg, text), true - case "openreg_limit": - n, err := strconv.Atoi(strings.TrimSpace(text)) - if err != nil || n < 0 { - return telegramCommandReply{Text: "请输入有效的非负整数。"}, true - } - if err := s.openRegistration(ctx, n); err != nil { - return telegramCommandReply{Text: "开注失败:" + err.Error()}, true - } - return telegramCommandReply{Text: fmt.Sprintf("已开放注册:%d 个名额。", n)}, true - } - return telegramCommandReply{}, false -} diff --git a/internal/service/telegram_menu_layout.go b/internal/service/telegram_menu_layout.go deleted file mode 100644 index 851ca67..0000000 --- a/internal/service/telegram_menu_layout.go +++ /dev/null @@ -1,115 +0,0 @@ -package service - -import ( - "context" - "fmt" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// mainMenu builds the button-based menu, tailored to the user's binding and -// admin status. Ordinary users only see self-service actions; admins get an -// extra management section. -func (s *TelegramBotService) mainMenu(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) telegramCommandReply { - isAdmin := s.telegramUserIsAdmin(ctx, channel, msg.From.ID) - user := s.boundUser(ctx, msg.From.ID) - if telegramIsGroupChat(msg.Chat.Type) { - return s.groupMainMenu(isAdmin, user) - } - return s.privateMainMenu(ctx, isAdmin, user) -} - -func (s *TelegramBotService) groupMainMenu(isAdmin bool, user *model.User) telegramCommandReply { - header := "MediaStationGo 群组自助菜单\n\n你还没有绑定媒体中心账号。绑定、注册、兑换等包含敏感信息的操作请私聊 Bot。" - var rows [][]telegramInlineButton - if user != nil { - header = telegramUserMenuHeader("MediaStationGo 群组自助菜单", user) - rows = telegramBoundUserMenuRows(user, false) - } - if isAdmin { - header += "\n\n管理员入口" - rows = append(rows, telegramAdminMenuRows()...) - } - return telegramCommandReply{Text: header, Buttons: rows} -} - -func (s *TelegramBotService) privateMainMenu(ctx context.Context, isAdmin bool, user *model.User) telegramCommandReply { - header := "MediaStationGo\n\n你还没有绑定媒体中心账号。" - rows := s.privateUnboundMenuRows(ctx) - if user != nil { - header = telegramUserMenuHeader("MediaStationGo", user) - rows = telegramBoundUserMenuRows(user, true) - } - if isAdmin { - rows = append(rows, telegramAdminMenuRows()...) - } - return telegramCommandReply{Text: header, Buttons: rows} -} - -func (s *TelegramBotService) privateUnboundMenuRows(ctx context.Context) [][]telegramInlineButton { - rows := [][]telegramInlineButton{{{Text: "🔗 绑定账号", Data: "act_bind"}}} - if s.openRegEnabled(ctx) { - rows = append(rows, []telegramInlineButton{{Text: "📝 注册新账号", Data: "act_register"}}) - } - return append(rows, []telegramInlineButton{{Text: "🎟 兑换码注册", Data: "act_redeem_register"}}) -} - -func telegramUserMenuHeader(title string, user *model.User) string { - return fmt.Sprintf("%s\n\n账号:%s\n到期:%s\n成人目录:%s", - title, user.Username, formatExpiry(user.ExpiredAt), telegramAdultVisibilityLabel(user.HideAdult)) -} - -func telegramAdultVisibilityLabel(hidden bool) string { - if hidden { - return "已隐藏" - } - return "已显示" -} - -func telegramAdultToggleText(hidden bool) string { - if hidden { - return "🔞 显示成人目录" - } - return "🔞 隐藏成人目录" -} - -func telegramBoundUserMenuRows(user *model.User, includePrivateActions bool) [][]telegramInlineButton { - rows := [][]telegramInlineButton{ - { - {Text: "👤 我的账号", Data: "act_account"}, - {Text: "📅 签到", Data: "act_signin"}, - }, - { - {Text: "📱 我的设备", Data: "act_devices"}, - {Text: telegramAdultToggleText(user.HideAdult), Data: "adult_toggle"}, - }, - } - if includePrivateActions { - rows = append(rows, - []telegramInlineButton{ - {Text: "✏️ 改用户名", Data: "act_setname"}, - {Text: "🔑 改密码", Data: "act_setpass"}, - }, - []telegramInlineButton{{Text: "🎟 兑换码续期", Data: "act_redeem_renew"}}, - ) - } - return rows -} - -func telegramAdminMenuRows() [][]telegramInlineButton { - return [][]telegramInlineButton{ - {{Text: "—— 管理员 ——", Data: "noop"}}, - { - {Text: "📊 容量/状态", Data: "adm_capacity"}, - {Text: "👥 用户管理", Data: "adm_users"}, - }, - { - {Text: "🔓 开注设置", Data: "adm_openreg"}, - {Text: "🎟 生成兑换码", Data: "adm_gencode"}, - }, - { - {Text: "⚙️ 设备策略", Data: "adm_devicepolicy"}, - {Text: "🛠 管理命令", Data: "adm_mgo_commands"}, - }, - } -} diff --git a/internal/service/telegram_mgo_admin.go b/internal/service/telegram_mgo_admin.go deleted file mode 100644 index 728d50f..0000000 --- a/internal/service/telegram_mgo_admin.go +++ /dev/null @@ -1,159 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "fmt" - "sort" - "strconv" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) cmdMgoAdminRole(ctx context.Context, args []string) telegramCommandReply { - if len(args) < 2 { - return telegramCommandReply{Text: "用法:/embyadmin 用户名 on|off"} - } - user := s.findMgoBotUser(ctx, args[0]) - if user == nil { - return telegramCommandReply{Text: "未找到用户。"} - } - enable := parseOnOff(args[1]) - if enable == nil { - return telegramCommandReply{Text: "第二个参数请使用 on/off。"} - } - if !*enable { - if first, _ := s.repo.User.FirstAdmin(ctx); first != nil && first.ID == user.ID { - return telegramCommandReply{Text: "默认管理员不可降级。"} - } - } - role := "user" - if *enable { - role = "admin" - } - if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"role": role}); err != nil { - return telegramCommandReply{Text: "更新失败:" + err.Error()} - } - return telegramCommandReply{Text: fmt.Sprintf("已将 %s 角色设置为 %s。", user.Username, role)} -} - -func (s *TelegramBotService) cmdMgoMediaAccessAll(ctx context.Context, allow bool) telegramCommandReply { - users, err := s.repo.User.List(ctx) - if err != nil { - return telegramCommandReply{Text: "读取用户失败:" + err.Error()} - } - updated := 0 - for _, user := range users { - if user.Role == "admin" { - continue - } - perm, err := s.repo.Permission.FindByUserID(ctx, user.ID) - if err != nil { - continue - } - if perm == nil { - perm = DefaultPermissions(user.ID) - perm.CanPlayMedia = allow - if err := s.repo.Permission.Create(ctx, perm); err != nil { - continue - } - } - if err := s.repo.DB.WithContext(ctx).Model(&model.UserPermission{}). - Where("user_id = ?", user.ID). - Update("can_play_media", allow).Error; err == nil { - updated++ - } - } - state := "关闭" - if allow { - state = "开启" - } - return telegramCommandReply{Text: fmt.Sprintf("已为普通用户%s媒体播放权限:%d 个。", state, updated)} -} - -func (s *TelegramBotService) cmdMgoBotAdmin(ctx context.Context, channel *model.NotifyChannel, args []string, add bool) telegramCommandReply { - if channel == nil { - return telegramCommandReply{Text: "Telegram 渠道不存在。"} - } - if len(args) == 0 { - return telegramCommandReply{Text: "用法:/proadmin TelegramID 或 /revadmin TelegramID"} - } - tgID := strings.TrimPrefix(strings.TrimSpace(args[0]), "tg:") - if _, err := strconv.ParseInt(tgID, 10, 64); err != nil { - return telegramCommandReply{Text: "TelegramID 必须是数字。"} - } - cfg := s.telegramChannelConfig(channel) - ids := telegramConfiguredUserIDs(cfg["admin_user_ids"]) - seen := make(map[string]bool, len(ids)+1) - var next []string - for _, id := range ids { - if id == tgID { - seen[id] = true - if add { - next = append(next, id) - } - continue - } - if id != "" { - next = append(next, id) - } - } - if add && !seen[tgID] { - next = append(next, tgID) - } - cfg["admin_user_ids"] = strings.Join(next, ",") - raw, _ := json.Marshal(cfg) - updated := *channel - updated.Config = string(raw) - if s.crypto != nil { - updated.Config = s.crypto.Encrypt(updated.Config) - } - if err := s.repo.NotifyChannel.Update(ctx, &updated); err != nil { - return telegramCommandReply{Text: "更新管理员列表失败:" + err.Error()} - } - if add { - return telegramCommandReply{Text: "已添加 Bot 管理员:" + tgID + ""} - } - return telegramCommandReply{Text: "已移除 Bot 管理员:" + tgID + ""} -} - -func (s *TelegramBotService) cmdMgoProtectedUser(ctx context.Context, args []string, protect bool) telegramCommandReply { - if len(args) == 0 || strings.EqualFold(args[0], "list") { - return s.cmdMgoProtectedUserList(ctx) - } - user := s.findMgoBotUser(ctx, args[0]) - if user == nil { - return telegramCommandReply{Text: "未找到用户。"} - } - ids := ProtectedUserIDSet(ctx, s.repo) - if protect { - ids[user.ID] = struct{}{} - if err := SaveProtectedUserIDSet(ctx, s.repo, ids); err != nil { - return telegramCommandReply{Text: "保存保护名单失败:" + err.Error()} - } - return telegramCommandReply{Text: fmt.Sprintf("已加入保护名单:%s。\n该用户不会被 Bot 自动清理、批量禁用或删除。", user.Username)} - } - delete(ids, user.ID) - if err := SaveProtectedUserIDSet(ctx, s.repo, ids); err != nil { - return telegramCommandReply{Text: "保存保护名单失败:" + err.Error()} - } - return telegramCommandReply{Text: fmt.Sprintf("已移出保护名单:%s。", user.Username)} -} - -func (s *TelegramBotService) cmdMgoProtectedUserList(ctx context.Context) telegramCommandReply { - ids := ProtectedUserIDSet(ctx, s.repo) - if len(ids) == 0 { - return telegramCommandReply{Text: "保护名单为空。管理员和默认管理员始终自动保护。"} - } - names := make([]string, 0, len(ids)) - for id := range ids { - if user, _ := s.repo.User.FindByID(ctx, id); user != nil { - names = append(names, user.Username) - } else { - names = append(names, id+"(用户不存在)") - } - } - sort.Strings(names) - return telegramCommandReply{Text: fmt.Sprintf("保护名单:%d 个。\n%s", len(names), telegramInlineCodeList(names))} -} diff --git a/internal/service/telegram_mgo_audit.go b/internal/service/telegram_mgo_audit.go deleted file mode 100644 index b696852..0000000 --- a/internal/service/telegram_mgo_audit.go +++ /dev/null @@ -1,133 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) cmdMgoSyncUnbound(ctx context.Context, args []string) telegramCommandReply { - var users []model.User - if err := s.repo.DB.WithContext(ctx). - Where("role <> ?", "admin"). - Where("NOT EXISTS (SELECT 1 FROM telegram_bindings WHERE telegram_bindings.user_id = users.id AND telegram_bindings.deleted_at IS NULL)"). - Order("created_at asc").Find(&users).Error; err != nil { - return telegramCommandReply{Text: "查询失败:" + err.Error()} - } - if len(args) >= 2 && strings.EqualFold(args[0], "delete") && strings.EqualFold(args[1], "confirm") { - deleted := 0 - for _, user := range users { - if UserIsProtectedAccount(ctx, s.repo, &user) { - continue - } - _ = s.repo.UserDevice.DeleteByUser(ctx, user.ID) - if err := s.repo.User.Delete(ctx, user.ID); err == nil { - deleted++ - } - } - return telegramCommandReply{Text: fmt.Sprintf("已删除未绑定 Bot 的普通用户:%d 个。", deleted)} - } - if len(users) == 0 { - return telegramCommandReply{Text: "没有未绑定 Bot 的普通用户。"} - } - names := make([]string, 0, minInt(len(users), 20)) - for i, user := range users { - if i >= 20 { - break - } - names = append(names, user.Username) - } - return telegramCommandReply{Text: fmt.Sprintf("未绑定 Bot 的普通用户:%d 个。\n%s\n\n如需删除:/syncunbound delete confirm", len(users), telegramInlineCodeList(names))} -} - -func (s *TelegramBotService) cmdMgoCheckExpired(ctx context.Context, args []string) telegramCommandReply { - now := time.Now() - var users []model.User - if err := s.repo.DB.WithContext(ctx).Where("expired_at IS NOT NULL AND expired_at < ?", now).Order("expired_at asc").Find(&users).Error; err != nil { - return telegramCommandReply{Text: "查询失败:" + err.Error()} - } - if len(args) >= 2 && strings.EqualFold(args[0], "disable") && strings.EqualFold(args[1], "confirm") { - disabled := 0 - for _, user := range users { - if UserIsProtectedAccount(ctx, s.repo, &user) { - continue - } - if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"is_active": false}); err == nil { - disabled++ - } - } - return telegramCommandReply{Text: fmt.Sprintf("已禁用过期普通用户:%d 个。", disabled)} - } - if len(users) == 0 { - return telegramCommandReply{Text: "没有过期用户。"} - } - lines := make([]string, 0, minInt(len(users), 20)) - for i, user := range users { - if i >= 20 { - break - } - lines = append(lines, fmt.Sprintf("%s(%s)", user.Username, formatExpiry(user.ExpiredAt))) - } - return telegramCommandReply{Text: fmt.Sprintf("过期用户:%d 个。\n%s\n\n如需禁用:/check_ex disable confirm", len(users), telegramInlineCodeList(lines))} -} - -func (s *TelegramBotService) cmdMgoScanNames(ctx context.Context) telegramCommandReply { - var rows []struct { - Username string - Count int64 - } - if err := s.repo.DB.WithContext(ctx).Table("users"). - Select("LOWER(username) AS username, COUNT(*) AS count"). - Group("LOWER(username)").Having("COUNT(*) > 1").Scan(&rows).Error; err != nil { - return telegramCommandReply{Text: "扫描失败:" + err.Error()} - } - if len(rows) == 0 { - return telegramCommandReply{Text: "未发现同名用户记录。"} - } - var out []string - for _, row := range rows { - out = append(out, fmt.Sprintf("%s x%d", row.Username, row.Count)) - } - return telegramCommandReply{Text: "同名用户记录\n" + telegramInlineCodeList(out)} -} - -func (s *TelegramBotService) cmdMgoRanks(ctx context.Context, window time.Duration, byDuration bool) telegramCommandReply { - since := time.Now().Add(-window) - title := "播放次数排行" - selectExpr := "COUNT(*) AS score" - if byDuration { - title = "观影时长排行" - selectExpr = "COALESCE(SUM(position_ms), 0) AS score" - } - q := s.repo.DB.WithContext(ctx).Table("playback_histories"). - Select("users.username, " + selectExpr). - Joins("JOIN users ON users.id = playback_histories.user_id"). - Group("users.username"). - Order("score DESC"). - Limit(10) - if window > 0 { - q = q.Where("playback_histories.watched_at >= ?", since) - } - var rows []struct { - Username string - Score int64 - } - if err := q.Scan(&rows).Error; err != nil { - return telegramCommandReply{Text: "排行查询失败:" + err.Error()} - } - if len(rows) == 0 { - return telegramCommandReply{Text: "暂无排行数据。"} - } - var out []string - for i, row := range rows { - score := fmt.Sprintf("%d 次", row.Score) - if byDuration { - score = humanDurationFromMillis(row.Score) - } - out = append(out, fmt.Sprintf("%d. %s — %s", i+1, row.Username, score)) - } - return telegramCommandReply{Text: "" + title + "\n\n" + strings.Join(out, "\n") + ""} -} diff --git a/internal/service/telegram_mgo_backup.go b/internal/service/telegram_mgo_backup.go deleted file mode 100644 index 7ba58c9..0000000 --- a/internal/service/telegram_mgo_backup.go +++ /dev/null @@ -1,49 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" -) - -func (s *TelegramBotService) cmdMgoBackupDB(ctx context.Context) telegramCommandReply { - if s.backup == nil { - return telegramCommandReply{Text: "备份服务暂不可用。"} - } - info, err := s.backup.Create(ctx) - if err != nil { - return telegramCommandReply{Text: "数据库备份失败:" + err.Error()} - } - return telegramCommandReply{Text: fmt.Sprintf("数据库备份完成:%s\n大小:%d bytes", info.Filename, info.Size)} -} - -func (s *TelegramBotService) cmdMgoRestoreDB(ctx context.Context, args []string) telegramCommandReply { - if s.backup == nil { - return telegramCommandReply{Text: "备份服务暂不可用。"} - } - if len(args) == 0 || strings.EqualFold(args[0], "list") { - items, err := s.backup.List() - if err != nil { - return telegramCommandReply{Text: "读取备份列表失败:" + err.Error()} - } - if len(items) == 0 { - return telegramCommandReply{Text: "暂无数据库备份。可先使用 /backup_db 创建。"} - } - lines := make([]string, 0, minInt(len(items), 10)) - for i, item := range items { - if i >= 10 { - break - } - lines = append(lines, fmt.Sprintf("%s(%d bytes)", item.Filename, item.Size)) - } - return telegramCommandReply{Text: "可恢复备份:\n" + telegramInlineCodeList(lines) + "\n\n恢复需要确认:/restore_from_db 文件名 confirm"} - } - if len(args) < 2 || !strings.EqualFold(args[len(args)-1], "confirm") { - return telegramCommandReply{Text: "恢复数据库会覆盖当前数据,需要确认:/restore_from_db 文件名 confirm"} - } - filename := strings.TrimSpace(args[0]) - if err := s.backup.Restore(ctx, filename); err != nil { - return telegramCommandReply{Text: "恢复失败:" + err.Error()} - } - return telegramCommandReply{Text: "数据库已从备份恢复,请重启 MediaStationGo 后生效。"} -} diff --git a/internal/service/telegram_mgo_bulk.go b/internal/service/telegram_mgo_bulk.go deleted file mode 100644 index e06f554..0000000 --- a/internal/service/telegram_mgo_bulk.go +++ /dev/null @@ -1,94 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) cmdMgoRenewAll(ctx context.Context, args []string) telegramCommandReply { - if len(args) < 2 || !strings.EqualFold(args[len(args)-1], "confirm") { - return telegramCommandReply{Text: "批量续期需要确认:/renewall 天数 confirm"} - } - days, err := strconv.Atoi(args[0]) - if err != nil || days < 0 { - return telegramCommandReply{Text: "天数必须是非负整数,0 表示永久。"} - } - users, err := s.repo.User.List(ctx) - if err != nil { - return telegramCommandReply{Text: "读取用户失败:" + err.Error()} - } - var count int - for _, user := range users { - if user.Role == "admin" { - continue - } - if err := s.applyRenewal(ctx, user.ID, days); err == nil { - count++ - } - } - return telegramCommandReply{Text: fmt.Sprintf("批量续期完成:%d 个普通用户。", count)} -} - -func (s *TelegramBotService) cmdMgoBanAll(ctx context.Context, active bool, args []string) telegramCommandReply { - if len(args) == 0 || !strings.EqualFold(args[len(args)-1], "confirm") { - action := "banall" - if active { - action = "unbanall" - } - return telegramCommandReply{Text: fmt.Sprintf("批量操作需要确认:/%s confirm", action)} - } - users, err := s.repo.User.List(ctx) - if err != nil { - return telegramCommandReply{Text: "读取用户失败:" + err.Error()} - } - var count int - for _, user := range users { - if !active && UserIsProtectedAccount(ctx, s.repo, &user) { - continue - } - if active && user.Role == "admin" { - continue - } - updates := map[string]any{"is_active": active} - if active { - updates["share_warnings"] = 0 - updates["last_share_warn_at"] = nil - } - if err := s.repo.User.UpdateFields(ctx, user.ID, updates); err == nil { - _ = s.repo.UserDevice.SetKickedByUser(ctx, user.ID, !active) - count++ - } - } - if active { - return telegramCommandReply{Text: fmt.Sprintf("已解禁普通用户:%d 个。", count)} - } - return telegramCommandReply{Text: fmt.Sprintf("已禁用普通用户:%d 个。", count)} -} - -func (s *TelegramBotService) cmdMgoCallAll(ctx context.Context, channel *model.NotifyChannel, args []string) telegramCommandReply { - message := strings.TrimSpace(strings.Join(args, " ")) - if message == "" { - return telegramCommandReply{Text: "用法:/callall 消息内容"} - } - if strings.TrimSpace(s.telegramChannelConfig(channel)["bot_token"]) == "" { - return telegramCommandReply{Text: "当前 Telegram 渠道未配置 bot_token,无法群发。"} - } - var bindings []model.TelegramBinding - if err := s.repo.DB.WithContext(ctx).Find(&bindings).Error; err != nil { - return telegramCommandReply{Text: "读取绑定失败:" + err.Error()} - } - sent := 0 - for _, binding := range bindings { - if binding.ChatID == 0 { - continue - } - if err := s.reply(ctx, channel, int(binding.ChatID), telegramCommandReply{Text: message}); err == nil { - sent++ - } - } - return telegramCommandReply{Text: fmt.Sprintf("群发完成:成功发送 %d 个绑定用户。", sent)} -} diff --git a/internal/service/telegram_mgo_compat.go b/internal/service/telegram_mgo_compat.go deleted file mode 100644 index 46f2188..0000000 --- a/internal/service/telegram_mgo_compat.go +++ /dev/null @@ -1,104 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) cmdMgoUnsupported(name, replacement string) telegramCommandReply { - text := fmt.Sprintf("%s 已识别,但当前 Telegram Bot API 无法完整复刻该行为。", name) - if replacement != "" { - text += "\n请使用:" + replacement - } - return telegramCommandReply{Text: text} -} - -func (s *TelegramBotService) findMgoBotUser(ctx context.Context, target string) *model.User { - target = strings.TrimSpace(strings.TrimPrefix(target, "@")) - if target == "" { - return nil - } - if user, _ := s.repo.User.FindByUsername(ctx, target); user != nil { - return user - } - if user, _ := s.repo.User.FindByID(ctx, target); user != nil { - return user - } - if tgRaw, ok := strings.CutPrefix(strings.ToLower(target), "tg:"); ok { - if tgID, err := strconv.ParseInt(tgRaw, 10, 64); err == nil { - var binding model.TelegramBinding - if err := s.repo.DB.WithContext(ctx).Where("telegram_user_id = ?", tgID).First(&binding).Error; err == nil { - user, _ := s.repo.User.FindByID(ctx, binding.UserID) - return user - } - } - } - return nil -} - -func activeLabel(user *model.User) string { - if user == nil { - return "未知" - } - if !user.IsActive { - return "已禁用" - } - if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) { - return "已过期" - } - return "正常" -} - -func formatOptionalTime(t *time.Time) string { - if t == nil { - return "-" - } - return t.Format("2006-01-02 15:04") -} - -func blankDash(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "-" - } - return value -} - -func telegramInlineCodeList(items []string) string { - if len(items) == 0 { - return "" - } - return "" + strings.Join(items, "、") + "" -} - -func parseOnOff(raw string) *bool { - value := strings.ToLower(strings.TrimSpace(raw)) - switch value { - case "on", "true", "1", "yes", "enable", "enabled", "开启", "开": - v := true - return &v - case "off", "false", "0", "no", "disable", "disabled", "关闭", "关": - v := false - return &v - default: - return nil - } -} - -func humanDurationFromMillis(ms int64) string { - if ms <= 0 { - return "0 分钟" - } - totalMinutes := ms / 1000 / 60 - hours := totalMinutes / 60 - minutes := totalMinutes % 60 - if hours == 0 { - return fmt.Sprintf("%d 分钟", minutes) - } - return fmt.Sprintf("%d 小时 %d 分钟", hours, minutes) -} diff --git a/internal/service/telegram_mgo_compat_test.go b/internal/service/telegram_mgo_compat_test.go deleted file mode 100644 index bf3abee..0000000 --- a/internal/service/telegram_mgo_compat_test.go +++ /dev/null @@ -1,278 +0,0 @@ -package service - -import ( - "strings" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func TestMgoBotUserManagementAndAuditCommands(t *testing.T) { - ctx := t.Context() - repos, bot := newBotTestService(t) - if err := repos.User.Create(ctx, &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}); err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9401"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9401, Username: "admin"}, Chat: TelegramChat{ID: 9401, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/ucr viewer secret-pass 30") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已创建用户") { - t.Fatalf("expected user creation, got %q", reply.Text) - } - viewer, err := repos.User.FindByUsername(ctx, "viewer") - if err != nil || viewer == nil { - t.Fatalf("viewer should exist: %v", err) - } - if err := repos.UserDevice.Create(ctx, &model.UserDevice{ - UserID: viewer.ID, - DeviceID: "dev-abc", - DeviceName: "Windows PC", - Client: "Infuse", - LastIP: "1.2.3.4", - FirstSeenAt: time.Now(), - LastSeenAt: time.Now(), - }); err != nil { - t.Fatal(err) - } - - for _, tc := range []struct { - command string - want string - }{ - {"/uinfo viewer", "用户信息"}, - {"/userip viewer", "1.2.3.4"}, - {"/auditip 1.2.3", "viewer"}, - {"/auditdevice Windows", "viewer"}, - {"/auditclient Infuse", "viewer"}, - {"/udeviceid dev-abc", "viewer"}, - } { - t.Run(tc.command, func(t *testing.T) { - reply, err := bot.executeCommand(ctx, channel, msg, tc.command) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, tc.want) { - t.Fatalf("%s expected %q in %q", tc.command, tc.want, reply.Text) - } - }) - } -} - -func TestMgoBotBatchAndPermissionCommands(t *testing.T) { - ctx := t.Context() - repos, bot := newBotTestService(t) - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9501"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9501, Username: "admin"}, Chat: TelegramChat{ID: 9501, Type: "private"}} - users := []*model.User{ - {Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}, - {Username: "viewer1", PasswordHash: "x", Role: "user", IsActive: true}, - {Username: "viewer2", PasswordHash: "x", Role: "user", IsActive: true}, - {Username: "viewer3", PasswordHash: "x", Role: "user", IsActive: true}, - } - for _, user := range users { - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - if user.Role != "admin" && user.Username != "viewer3" { - if err := repos.Permission.Create(ctx, DefaultPermissions(user.ID)); err != nil { - t.Fatal(err) - } - } - } - - reply, err := bot.executeCommand(ctx, channel, msg, "/renewall 7 confirm") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "批量续期完成") { - t.Fatalf("expected renewall success, got %q", reply.Text) - } - renewed, _ := repos.User.FindByUsername(ctx, "viewer1") - if renewed.ExpiredAt == nil { - t.Fatal("renewall should set expiry for normal users") - } - - if reply, err = bot.executeCommand(ctx, channel, msg, "/embylibs_blockall"); err != nil || !strings.Contains(reply.Text, "关闭媒体播放权限") { - t.Fatalf("expected blockall success, reply=%q err=%v", reply.Text, err) - } - perm, _ := repos.Permission.FindByUserID(ctx, users[1].ID) - if perm == nil || perm.CanPlayMedia { - t.Fatal("embylibs_blockall should disable media playback for normal users") - } - perm, _ = repos.Permission.FindByUserID(ctx, users[3].ID) - if perm == nil || perm.CanPlayMedia { - t.Fatal("embylibs_blockall should create disabled media playback permissions when missing") - } - if reply, err = bot.executeCommand(ctx, channel, msg, "/embylibs_unblockall"); err != nil || !strings.Contains(reply.Text, "开启媒体播放权限") { - t.Fatalf("expected unblockall success, reply=%q err=%v", reply.Text, err) - } - perm, _ = repos.Permission.FindByUserID(ctx, users[1].ID) - if perm == nil || !perm.CanPlayMedia { - t.Fatal("embylibs_unblockall should enable media playback for normal users") - } - - if reply, err = bot.executeCommand(ctx, channel, msg, "/banall confirm"); err != nil || !strings.Contains(reply.Text, "已禁用普通用户") { - t.Fatalf("expected banall success, reply=%q err=%v", reply.Text, err) - } - banned, _ := repos.User.FindByUsername(ctx, "viewer2") - if banned.IsActive { - t.Fatal("banall should disable normal users") - } - if reply, err = bot.executeCommand(ctx, channel, msg, "/unbanall confirm"); err != nil || !strings.Contains(reply.Text, "已解禁普通用户") { - t.Fatalf("expected unbanall success, reply=%q err=%v", reply.Text, err) - } - unbanned, _ := repos.User.FindByUsername(ctx, "viewer2") - if !unbanned.IsActive { - t.Fatal("unbanall should re-enable normal users") - } -} - -func TestMgoBotSyncExpiryAndBotAdminCommands(t *testing.T) { - ctx := t.Context() - repos, bot := newBotTestService(t) - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9601"}`} - if err := repos.NotifyChannel.Create(ctx, channel); err != nil { - t.Fatal(err) - } - msg := &TelegramMessage{From: TelegramUser{ID: 9601, Username: "admin"}, Chat: TelegramChat{ID: 9601, Type: "private"}} - past := time.Now().Add(-24 * time.Hour) - if err := repos.User.Create(ctx, &model.User{Username: "expired", PasswordHash: "x", Role: "user", IsActive: true, ExpiredAt: &past}); err != nil { - t.Fatal(err) - } - - reply, err := bot.executeCommand(ctx, channel, msg, "/syncunbound") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "expired") { - t.Fatalf("syncunbound should list unbound users, got %q", reply.Text) - } - reply, err = bot.executeCommand(ctx, channel, msg, "/check_ex") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "expired") { - t.Fatalf("check_ex should list expired users, got %q", reply.Text) - } - reply, err = bot.executeCommand(ctx, channel, msg, "/proadmin 9602") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已添加") { - t.Fatalf("proadmin should update channel config, got %q", reply.Text) - } - updated, _ := repos.NotifyChannel.FindByID(ctx, channel.ID) - cfg := bot.telegramChannelConfig(updated) - if !strings.Contains(cfg["admin_user_ids"], "9602") { - t.Fatalf("expected admin ids to include 9602, got %#v", cfg) - } -} - -func TestMgoBotProtectedUsersAndBackupCommands(t *testing.T) { - ctx := t.Context() - repos, bot := newBotTestService(t) - cfg := &config.Config{} - cfg.App.DataDir = t.TempDir() - bot.SetBackupService(NewBackupService(cfg, zap.NewNop(), repos.DB)) - - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9701"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9701, Username: "admin"}, Chat: TelegramChat{ID: 9701, Type: "private"}} - users := []*model.User{ - {Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}, - {Username: "safe", PasswordHash: "x", Role: "user", IsActive: true}, - {Username: "normal", PasswordHash: "x", Role: "user", IsActive: true}, - } - for _, user := range users { - if err := repos.User.Create(ctx, user); err != nil { - t.Fatal(err) - } - } - - reply, err := bot.executeCommand(ctx, channel, msg, "/prouser safe") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已加入保护名单") { - t.Fatalf("expected protect success, got %q", reply.Text) - } - if reason := bot.protectReason(ctx, users[1].ID); !strings.Contains(reason, "保护名单") { - t.Fatalf("protected user should have protect reason, got %q", reason) - } - reply, err = bot.executeCommand(ctx, channel, msg, "/banall confirm") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已禁用普通用户") { - t.Fatalf("expected banall success, got %q", reply.Text) - } - protected, _ := repos.User.FindByUsername(ctx, "safe") - normal, _ := repos.User.FindByUsername(ctx, "normal") - if !protected.IsActive { - t.Fatal("protected user should not be disabled by banall") - } - if normal.IsActive { - t.Fatal("normal user should be disabled by banall") - } - reply, err = bot.executeCommand(ctx, channel, msg, "/revuser safe") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "已移出保护名单") { - t.Fatalf("expected unprotect success, got %q", reply.Text) - } - - reply, err = bot.executeCommand(ctx, channel, msg, "/backup_db") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "数据库备份完成") { - t.Fatalf("backup_db should create backup, got %q", reply.Text) - } - reply, err = bot.executeCommand(ctx, channel, msg, "/restore_from_db list") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "mediastation_") { - t.Fatalf("restore list should show backup, got %q", reply.Text) - } -} - -func TestMgoBotAliasesAndSyncGroupGuards(t *testing.T) { - ctx := t.Context() - repos, bot := newBotTestService(t) - if err := repos.User.Create(ctx, &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}); err != nil { - t.Fatal(err) - } - channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9801"}`} - msg := &TelegramMessage{From: TelegramUser{ID: 9801, Username: "admin"}, Chat: TelegramChat{ID: 9801, Type: "private"}} - - reply, err := bot.executeCommand(ctx, channel, msg, "/kk") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "用户管理") { - t.Fatalf("/kk should map to user management, got %q", reply.Text) - } - reply, err = bot.executeCommand(ctx, channel, msg, "/syncgroupm") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "未配置可校验成员") { - t.Fatalf("syncgroupm should explain missing group config, got %q", reply.Text) - } - reply, err = bot.executeCommand(ctx, channel, msg, "/kick_not_emby") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(reply.Text, "无法枚举全部群成员") { - t.Fatalf("kick_not_emby should explain Telegram limitation, got %q", reply.Text) - } -} diff --git a/internal/service/telegram_mgo_membership.go b/internal/service/telegram_mgo_membership.go deleted file mode 100644 index 0804483..0000000 --- a/internal/service/telegram_mgo_membership.go +++ /dev/null @@ -1,92 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) cmdMgoSyncGroup(ctx context.Context, channel *model.NotifyChannel, args []string) telegramCommandReply { - chatIDs := s.telegramMembershipChatIDs(channel) - if len(chatIDs) == 0 { - return telegramCommandReply{Text: "未配置可校验成员的群组/频道 ID。请在 Telegram 通知渠道设置 group_chat_id 或 channel_chat_id。"} - } - if strings.TrimSpace(s.telegramChannelConfig(channel)["bot_token"]) == "" { - return telegramCommandReply{Text: "当前 Telegram 渠道未配置 bot_token,无法校验群成员。"} - } - var bindings []model.TelegramBinding - if err := s.repo.DB.WithContext(ctx).Find(&bindings).Error; err != nil { - return telegramCommandReply{Text: "读取绑定失败:" + err.Error()} - } - type staleBinding struct { - User model.User - Binding model.TelegramBinding - } - var stale []staleBinding - for _, binding := range bindings { - if binding.TelegramUserID == 0 || binding.UserID == "" { - continue - } - user, _ := s.repo.User.FindByID(ctx, binding.UserID) - if user == nil || UserIsProtectedAccount(ctx, s.repo, user) { - continue - } - // 仅当所有绑定群组/频道都「查实不是成员」时才判定为可清理; - // getChatMember 出错(membershipUnknown)时保守跳过,避免误删。 - confirmedNo := true - for _, chatID := range chatIDs { - if s.telegramChatMembership(ctx, channel, chatID, int(binding.TelegramUserID)) != membershipNo { - confirmedNo = false - break - } - } - if confirmedNo { - stale = append(stale, staleBinding{User: *user, Binding: binding}) - } - } - if len(stale) == 0 { - return telegramCommandReply{Text: "所有已绑定账号都仍在配置的群组/频道中。"} - } - if len(args) >= 2 && strings.EqualFold(args[0], "delete") && strings.EqualFold(args[1], "confirm") { - deleted := 0 - for _, item := range stale { - _ = s.repo.UserDevice.DeleteByUser(ctx, item.User.ID) - if err := s.repo.User.Delete(ctx, item.User.ID); err == nil { - deleted++ - } - } - return telegramCommandReply{Text: fmt.Sprintf("已删除不在群组/频道中的普通账号:%d 个。", deleted)} - } - names := make([]string, 0, minInt(len(stale), 20)) - for i, item := range stale { - if i >= 20 { - break - } - names = append(names, fmt.Sprintf("%s(tg:%d)", item.User.Username, item.Binding.TelegramUserID)) - } - return telegramCommandReply{Text: fmt.Sprintf("不在配置群组/频道中的绑定账号:%d 个。\n%s\n\n删除需确认:/syncgroupm delete confirm", len(stale), telegramInlineCodeList(names))} -} - -func (s *TelegramBotService) telegramMembershipChatIDs(channel *model.NotifyChannel) []string { - cfg := s.telegramChannelConfig(channel) - seen := map[string]struct{}{} - var out []string - for _, key := range []string{"group_chat_id", "channel_chat_id", "command_chat_id"} { - value := strings.TrimSpace(cfg[key]) - if value == "" { - continue - } - if _, ok := seen[value]; !ok { - seen[value] = struct{}{} - out = append(out, value) - } - } - if len(out) == 0 { - if value := strings.TrimSpace(cfg["chat_id"]); strings.HasPrefix(value, "-") { - out = append(out, value) - } - } - return out -} diff --git a/internal/service/telegram_mgo_users.go b/internal/service/telegram_mgo_users.go deleted file mode 100644 index 552ed3b..0000000 --- a/internal/service/telegram_mgo_users.go +++ /dev/null @@ -1,184 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) cmdMgoCreateUser(ctx context.Context, args []string) telegramCommandReply { - if len(args) < 2 { - return telegramCommandReply{Text: "用法:/ucr 用户名 密码 [天数],天数 0 表示永久。"} - } - if s.auth == nil { - return telegramCommandReply{Text: "注册服务暂不可用。"} - } - user, _, err := s.auth.Register(ctx, args[0], args[1]) - if err != nil { - return telegramCommandReply{Text: "创建失败:" + err.Error()} - } - days := 0 - if len(args) >= 3 { - parsed, err := strconv.Atoi(args[2]) - if err != nil || parsed < 0 { - return telegramCommandReply{Text: "账号已创建,但天数无效。请用 /renew 用户名 天数 调整。"} - } - days = parsed - if err := s.applyRenewal(ctx, user.ID, days); err != nil { - return telegramCommandReply{Text: "账号已创建,但续期失败:" + err.Error()} - } - } - return telegramCommandReply{Text: fmt.Sprintf("已创建用户:%s\n到期:%s", user.Username, formatExpiry(s.userExpiry(ctx, user.ID)))} -} - -func (s *TelegramBotService) cmdMgoUserInfo(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 { - return telegramCommandReply{Text: "用法:/uinfo 用户名"} - } - user := s.findMgoBotUser(ctx, args[0]) - if user == nil { - return telegramCommandReply{Text: "未找到用户。"} - } - s.applyRealtimeUserActivity(ctx, user) - devices, _ := s.listUserDevices(ctx, user.ID) - var historyCount int64 - _ = s.repo.DB.WithContext(ctx).Model(&model.PlaybackHistory{}).Where("user_id = ?", user.ID).Count(&historyCount).Error - var binding model.TelegramBinding - tg := "未绑定" - if err := s.repo.DB.WithContext(ctx).Where("user_id = ?", user.ID).First(&binding).Error; err == nil { - tg = fmt.Sprintf("tg:%d", binding.TelegramUserID) - if binding.TelegramName != "" { - tg += " " + binding.TelegramName - } - } - return telegramCommandReply{Text: fmt.Sprintf( - "用户信息\n\n用户名:%s\n角色:%s\n状态:%s\n到期:%s\nTelegram:%s\n设备:%d\n播放记录:%d\n最后登录:%s", - user.Username, user.Role, activeLabel(user), formatExpiry(user.ExpiredAt), tg, len(devices), historyCount, formatOptionalTime(user.LastLoginAt), - )} -} - -func (s *TelegramBotService) applyRealtimeUserActivity(ctx context.Context, user *model.User) { - if s == nil || user == nil || s.device == nil || s.device.sessions == nil { - return - } - users := []model.User{*user} - s.device.sessions.ApplyToUsers(ctx, users) - *user = users[0] -} - -func (s *TelegramBotService) cmdMgoDeleteUser(ctx context.Context, args []string) telegramCommandReply { - if len(args) < 2 || !strings.EqualFold(args[len(args)-1], "confirm") { - return telegramCommandReply{Text: "删除用户需要确认:/rmemby 用户名 confirm 或 /urm 用户名 confirm"} - } - user := s.findMgoBotUser(ctx, args[0]) - if user == nil { - return telegramCommandReply{Text: "未找到用户。"} - } - if reason := s.protectReason(ctx, user.ID); reason != "" { - return telegramCommandReply{Text: reason} - } - _ = s.repo.UserDevice.DeleteByUser(ctx, user.ID) - if err := s.repo.User.Delete(ctx, user.ID); err != nil { - return telegramCommandReply{Text: "删除失败:" + err.Error()} - } - return telegramCommandReply{Text: fmt.Sprintf("已删除用户 %s。", user.Username)} -} - -func (s *TelegramBotService) cmdMgoOnlyRemoveRecord(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 { - return telegramCommandReply{Text: "用法:/only_rm_record tg:123456 或 /only_rm_record 用户名,只删除 Telegram 绑定记录。"} - } - target := strings.TrimSpace(args[0]) - var removed int64 - if raw, ok := strings.CutPrefix(strings.ToLower(target), "tg:"); ok { - tgID, err := strconv.ParseInt(raw, 10, 64) - if err != nil || tgID == 0 { - return telegramCommandReply{Text: "Telegram ID 无效。"} - } - removed, err = s.deleteTelegramBindings(ctx, "telegram_user_id = ?", tgID) - if err != nil { - return telegramCommandReply{Text: "删除绑定失败:" + err.Error()} - } - } else { - user := s.findMgoBotUser(ctx, target) - if user == nil { - return telegramCommandReply{Text: "未找到用户。"} - } - n, err := s.deleteTelegramBindings(ctx, "user_id = ?", user.ID) - if err != nil { - return telegramCommandReply{Text: "删除绑定失败:" + err.Error()} - } - removed = n - } - return telegramCommandReply{Text: fmt.Sprintf("已删除 Telegram 绑定记录:%d 条。", removed)} -} - -func (s *TelegramBotService) cmdMgoUserIP(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 { - return telegramCommandReply{Text: "用法:/userip 用户名"} - } - user := s.findMgoBotUser(ctx, args[0]) - if user == nil { - return telegramCommandReply{Text: "未找到用户。"} - } - devices, err := s.listUserDevices(ctx, user.ID) - if err != nil { - return telegramCommandReply{Text: "查询失败:" + err.Error()} - } - if len(devices) == 0 { - return telegramCommandReply{Text: "该用户暂无设备/IP记录。"} - } - var out []string - for i, d := range devices { - if i >= 20 { - break - } - out = append(out, fmt.Sprintf("%d. %s / %s / %s / %s", i+1, blankDash(d.LastIP), blankDash(d.DeviceName), blankDash(d.Client), d.LastSeenAt.Format("2006-01-02 15:04"))) - } - return telegramCommandReply{Text: "" + user.Username + " 的设备/IP\n\n" + strings.Join(out, "\n") + ""} -} - -func (s *TelegramBotService) cmdMgoAuditDevices(ctx context.Context, mode string, args []string) telegramCommandReply { - if len(args) == 0 { - return telegramCommandReply{Text: fmt.Sprintf("用法:/%s 关键词", mode)} - } - keyword := strings.TrimSpace(strings.Join(args, " ")) - var rows []struct { - Username string - DeviceID string - DeviceName string - Client string - LastIP string - LastSeenAt time.Time - } - q := s.repo.DB.WithContext(ctx).Table("user_devices"). - Select("users.username, user_devices.device_id, user_devices.device_name, user_devices.client, user_devices.last_ip, user_devices.last_seen_at"). - Joins("JOIN users ON users.id = user_devices.user_id"). - Order("user_devices.last_seen_at desc"). - Limit(20) - switch mode { - case "auditip": - q = q.Where("user_devices.last_ip LIKE ?", "%"+keyword+"%") - case "auditdevice": - q = q.Where("user_devices.device_name LIKE ? OR user_devices.device_id LIKE ?", "%"+keyword+"%", "%"+keyword+"%") - case "auditclient": - q = q.Where("user_devices.client LIKE ?", "%"+keyword+"%") - case "udeviceid": - q = q.Where("user_devices.device_id LIKE ?", "%"+keyword+"%") - } - if err := q.Scan(&rows).Error; err != nil { - return telegramCommandReply{Text: "查询失败:" + err.Error()} - } - if len(rows) == 0 { - return telegramCommandReply{Text: "没有匹配记录。"} - } - var out []string - for i, r := range rows { - out = append(out, fmt.Sprintf("%d. %s / %s / %s / %s / %s", i+1, r.Username, blankDash(r.LastIP), blankDash(r.DeviceName), blankDash(r.Client), r.LastSeenAt.Format("2006-01-02 15:04"))) - } - return telegramCommandReply{Text: "审计结果\n\n" + strings.Join(out, "\n") + ""} -} diff --git a/internal/service/telegram_polling.go b/internal/service/telegram_polling.go deleted file mode 100644 index 399f1b3..0000000 --- a/internal/service/telegram_polling.go +++ /dev/null @@ -1,208 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// TelegramPollingStartResult describes what happened when local long polling -// was requested. The admin UI uses it to avoid a silent "started" toast when -// no Telegram channel can actually poll. -type TelegramPollingStartResult struct { - Message string `json:"message"` - Started int `json:"started"` - AlreadyRunning int `json:"already_running"` - Skipped int `json:"skipped"` - Errors []string `json:"errors,omitempty"` -} - -// StartPolling 为所有已启用的 Telegram 通知渠道启动长轮询。 -func (s *TelegramBotService) StartPolling(ctx context.Context) TelegramPollingStartResult { - result := TelegramPollingStartResult{Message: "telegram polling started"} - channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram") - if err != nil { - s.log.Error("failed to list telegram channels for polling", zap.Error(err)) - result.Message = "failed to list telegram channels" - result.Errors = append(result.Errors, err.Error()) - return result - } - if len(channels) == 0 { - result.Message = "no telegram channels configured" - result.Errors = append(result.Errors, "没有配置 Telegram 通知渠道") - return result - } - - for _, ch := range channels { - if !ch.Enabled { - result.Skipped++ - result.Errors = append(result.Errors, ch.Name+": 通知渠道未启用") - continue - } - configStr := ch.Config - if s.crypto != nil && configStr != "" { - configStr = s.crypto.Decrypt(configStr) - } - var rawCfg map[string]any - if err := json.Unmarshal([]byte(configStr), &rawCfg); err != nil { - result.Skipped++ - result.Errors = append(result.Errors, ch.Name+": Telegram 配置解析失败: "+err.Error()) - continue - } - cfg := telegramStringConfigFromAny(rawCfg) - botToken := cfg["bot_token"] - if botToken == "" { - result.Skipped++ - result.Errors = append(result.Errors, ch.Name+": Telegram Bot Token 为空") - continue - } - s.pollingMu.Lock() - if _, running := s.pollingCancel[botToken]; running { - s.pollingMu.Unlock() - result.AlreadyRunning++ - continue - } - s.pollingMu.Unlock() - - if err := registerTelegramBotCommands(ctx, cfg); err != nil && s.log != nil { - s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err))) - } - if err := deleteTelegramWebhook(ctx, cfg); err != nil { - result.Skipped++ - result.Errors = append(result.Errors, ch.Name+": "+sanitizeTelegramError(err).Error()) - continue - } - - s.pollingMu.Lock() - if _, running := s.pollingCancel[botToken]; running { - s.pollingMu.Unlock() - result.AlreadyRunning++ - continue - } - pollCtx, cancel := context.WithCancel(context.Background()) - s.pollingCancel[botToken] = cancel - s.pollingMu.Unlock() - - channel := ch - go s.pollLoop(pollCtx, cfg, &channel) - result.Started++ - s.log.Info("started telegram polling", zap.String("channel", ch.Name)) - } - if result.Started == 0 && result.AlreadyRunning == 0 { - result.Message = "no enabled telegram channels started" - } - return result -} - -// StopPolling 停止所有 Telegram 长轮询。 -func (s *TelegramBotService) StopPolling() int { - s.pollingMu.Lock() - defer s.pollingMu.Unlock() - stopped := 0 - for token, cancel := range s.pollingCancel { - cancel() - delete(s.pollingCancel, token) - stopped++ - } - s.log.Info("telegram polling stopped") - return stopped -} - -// pollLoop 对单个 Bot Token 执行长轮询。 -func (s *TelegramBotService) pollLoop(ctx context.Context, cfg map[string]string, channel *model.NotifyChannel) { - var offset int64 = 0 - pollURL, err := telegramMethodURL(cfg, cfg["bot_token"], "getUpdates") - if err != nil { - s.log.Warn("telegram polling config invalid", zap.Error(err)) - return - } - clients := telegramHTTPClients(45*time.Second, cfg) - - for { - select { - case <-ctx.Done(): - return - default: - } - - reqBody, _ := json.Marshal(map[string]interface{}{ - "offset": offset, - "timeout": 30, - }) - respBody, err := telegramPollingRequest(ctx, clients, pollURL, string(reqBody)) - if err != nil { - s.log.Debug("telegram polling failed", zap.Error(err)) - time.Sleep(5 * time.Second) - continue - } - - var result struct { - OK bool `json:"ok"` - Result []TelegramUpdate `json:"result"` - } - if err := json.Unmarshal(respBody, &result); err != nil || !result.OK { - time.Sleep(3 * time.Second) - continue - } - - for _, upd := range result.Result { - if upd.UpdateID >= int(offset) { - offset = int64(upd.UpdateID) + 1 - } - if !telegramUpdateActionable(upd) { - continue - } - go func(u TelegramUpdate) { - handlerCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - _ = s.handleTelegramUpdate(handlerCtx, u, channel) - }(upd) - } - } -} - -// telegramUpdateActionable 判断一条 update 是否需要分发处理。 -// 长轮询默认会返回 message 与 callback_query 两类更新;命令消息需有文本, -// 而内联按钮回调(callback_query)必须被分发,否则成人目录显隐开关会失效。 -func telegramUpdateActionable(upd TelegramUpdate) bool { - if upd.CallbackQuery != nil { - return true - } - return upd.Message != nil && upd.Message.Text != "" -} - -func telegramPollingRequest(ctx context.Context, clients []*http.Client, pollURL, body string) ([]byte, error) { - var lastErr error - for _, client := range clients { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, pollURL, strings.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - resp, err := client.Do(req) - if err != nil { - lastErr = sanitizeTelegramError(err) - continue - } - respBody, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - if resp.StatusCode >= 400 { - lastErr = fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(respBody))) - continue - } - return respBody, nil - } - if lastErr != nil { - return nil, lastErr - } - return nil, errors.New("telegram polling failed") -} diff --git a/internal/service/telegram_redeem.go b/internal/service/telegram_redeem.go deleted file mode 100644 index dc9862f..0000000 --- a/internal/service/telegram_redeem.go +++ /dev/null @@ -1,185 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" - "gorm.io/gorm" -) - -var ( - errRegistrationCodeAlreadyUsed = errors.New("registration code already used") - errRegistrationCodeExpired = errors.New("registration code expired") -) - -func (s *TelegramBotService) cmdRedeem(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, args []string) telegramCommandReply { - if len(args) == 0 { - return telegramCommandReply{Text: "请发送:/redeem 兑换码\n未绑定账号时自动尝试注册码;已绑定账号时自动尝试续期码。"} - } - code := strings.Join(args, " ") - if s.boundUser(ctx, msg.From.ID) == nil { - return s.redeemRegisterFlow(ctx, channel, msg, code) - } - return s.redeemRenewFlow(ctx, msg, code) -} - -func (s *TelegramBotService) cmdRedeemRegister(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, args []string) telegramCommandReply { - if len(args) == 0 { - return telegramCommandReply{Text: "请发送:/redeem_register 注册兑换码"} - } - return s.redeemRegisterFlow(ctx, channel, msg, strings.Join(args, " ")) -} - -func (s *TelegramBotService) cmdRedeemRenew(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { - if len(args) == 0 { - return telegramCommandReply{Text: "请发送:/redeem_renew 续期兑换码"} - } - return s.redeemRenewFlow(ctx, msg, strings.Join(args, " ")) -} - -func (s *TelegramBotService) redeemRegisterFlow(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, raw string) telegramCommandReply { - if channel == nil { - channel = s.findChannelForMessage(ctx, msg) - } - if dec := s.telegramUserBindDecision(ctx, channel, msg.From.ID); dec != bindAllowed { - return telegramCommandReply{Text: telegramBindRejectText(dec, "兑换注册账号")} - } - rc, errMsg := s.lookupRedeemableCode(ctx, raw, model.RegistrationCodeRegister) - if rc == nil { - return telegramCommandReply{Text: errMsg} - } - if s.auth == nil { - return telegramCommandReply{Text: "注册服务暂不可用。"} - } - if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil { - if u, _ := s.repo.User.FindByID(ctx, binding.UserID); u != nil { - return telegramCommandReply{Text: fmt.Sprintf("当前 Telegram 已绑定账号 %s,无需再用注册码。", u.Username)} - } - } - user, password, claimedCode, err := s.createUserFromRegistrationCode(ctx, rc.Code) - if err != nil { - if errors.Is(err, errRegistrationCodeAlreadyUsed) { - return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"} - } - if errors.Is(err, errRegistrationCodeExpired) { - return telegramCommandReply{Text: "兑换码已过期。"} - } - if errors.Is(err, ErrUserLimitReached) { - return telegramCommandReply{Text: "注册失败:用户数量已达授权上限。"} - } - return telegramCommandReply{Text: "注册失败:" + err.Error()} - } - if claimedCode == nil { - return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"} - } - _ = s.upsertTelegramBinding(ctx, msg, user.ID) - return telegramCommandReply{ - Text: fmt.Sprintf("兑换成功并已创建账号:\n用户名:%s\n密码:%s\n到期:%s\n\n请尽快用「改用户名/改密码」修改为你自己的凭据。", - user.Username, password, formatExpiry(s.userExpiry(ctx, user.ID))), - Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}, - } -} - -func (s *TelegramBotService) createUserFromRegistrationCode(ctx context.Context, rawCode string) (*model.User, string, *model.RegistrationCode, error) { - code := normalizeRedemptionCode(rawCode) - if code == "" { - return nil, "", nil, errRegistrationCodeAlreadyUsed - } - password := randomCode(10) - var created model.User - var claimed model.RegistrationCode - err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("code = ? AND kind = ? AND used_at IS NULL AND used_count < CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END", code, model.RegistrationCodeRegister). - First(&claimed).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errRegistrationCodeAlreadyUsed - } - return err - } - if claimed.IsExpired() { - return errRegistrationCodeExpired - } - var count int64 - if err := tx.Model(&model.User{}).Count(&count).Error; err != nil { - return err - } - if count >= LicensedMaxUsers(ctx, s.repo) { - return ErrUserLimitReached - } - hash, err := hashPassword(password) - if err != nil { - return err - } - codePrefix := strings.ToLower(claimed.Code) - if len(codePrefix) > 8 { - codePrefix = codePrefix[:8] - } - created = model.User{ - Username: "u" + codePrefix, - PasswordHash: hash, - Role: "user", - Tier: "free", - HideAdult: true, - ExpiredAt: renewExpiry(nil, claimed.DurationDays), - } - if err := tx.Create(&created).Error; err != nil { - return err - } - if err := tx.Create(DefaultPermissions(created.ID)).Error; err != nil { - return err - } - now := time.Now() - res := tx.Model(&model.RegistrationCode{}). - Where("id = ? AND used_at IS NULL AND used_count < CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END", claimed.ID). - Updates(map[string]any{ - "used_by_user_id": created.ID, - "used_count": gorm.Expr("used_count + 1"), - "used_at": gorm.Expr("CASE WHEN used_count + 1 >= CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END THEN ? ELSE used_at END", now), - }) - if res.Error != nil { - return res.Error - } - if res.RowsAffected == 0 { - return errRegistrationCodeAlreadyUsed - } - claimed.UsedByUserID = created.ID - claimed.UsedCount++ - if claimed.UsedCount >= claimed.EffectiveMaxUses() { - claimed.UsedAt = &now - } - return nil - }) - if err != nil { - return nil, "", nil, err - } - return &created, password, &claimed, nil -} - -func (s *TelegramBotService) redeemRenewFlow(ctx context.Context, msg *TelegramMessage, raw string) telegramCommandReply { - user := s.boundUser(ctx, msg.From.ID) - if user == nil { - return telegramCommandReply{Text: "请先绑定账号再续期。"} - } - rc, errMsg := s.lookupRedeemableCode(ctx, raw, model.RegistrationCodeRenew) - if rc == nil { - return telegramCommandReply{Text: errMsg} - } - if err := s.repo.RegCode.MarkUsed(ctx, rc.ID, user.ID); err != nil { - return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"} - } - if err := s.applyRenewal(ctx, user.ID, rc.DurationDays); err != nil { - return telegramCommandReply{Text: "续期失败:" + err.Error()} - } - return telegramCommandReply{Text: fmt.Sprintf("续期成功 ✅ 当前到期:%s", formatExpiry(s.userExpiry(ctx, user.ID)))} -} - -func (s *TelegramBotService) userExpiry(ctx context.Context, userID string) *time.Time { - if u, _ := s.repo.User.FindByID(ctx, userID); u != nil { - return u.ExpiredAt - } - return nil -} diff --git a/internal/service/telegram_reply.go b/internal/service/telegram_reply.go deleted file mode 100644 index f710cd2..0000000 --- a/internal/service/telegram_reply.go +++ /dev/null @@ -1,127 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -const defaultTelegramMessageDeleteDelay = 120 * time.Second - -type telegramSendMessageResponse struct { - OK bool `json:"ok"` - Result struct { - MessageID int `json:"message_id"` - } `json:"result"` -} - -// reply 通过 Telegram Bot API 发送回复消息。 -func (s *TelegramBotService) reply(ctx context.Context, channel *model.NotifyChannel, chatID int, reply telegramCommandReply) error { - cfg := s.telegramChannelConfig(channel) - if strings.TrimSpace(cfg["bot_token"]) == "" { - return fmt.Errorf("bot_token not configured") - } - - payload := map[string]interface{}{ - "chat_id": strconv.Itoa(chatID), - "text": reply.Text, - "parse_mode": "HTML", - } - if len(reply.Buttons) > 0 { - keyboard := make([][]map[string]string, 0, len(reply.Buttons)) - for _, row := range reply.Buttons { - buttons := make([]map[string]string, 0, len(row)) - for _, button := range row { - buttons = append(buttons, map[string]string{ - "text": button.Text, - "callback_data": button.Data, - }) - } - keyboard = append(keyboard, buttons) - } - payload["reply_markup"] = map[string]interface{}{"inline_keyboard": keyboard} - } - var sent telegramSendMessageResponse - if err := telegramPostJSONDecode(ctx, cfg, "sendMessage", payload, 15*time.Second, &sent); err != nil { - return err - } - if sent.Result.MessageID > 0 { - s.scheduleTelegramMessageDelete(cfg, chatID, sent.Result.MessageID) - } - return nil -} - -func (s *TelegramBotService) replyForMessage(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, reply telegramCommandReply) error { - if msg == nil { - return nil - } - if strings.TrimSpace(reply.Text) == "" { - return nil - } - return s.reply(ctx, channel, msg.Chat.ID, reply) -} - -func (s *TelegramBotService) deleteTelegramSourceMessage(channel *model.NotifyChannel, chatID, messageID int) { - if messageID <= 0 { - return - } - s.scheduleTelegramMessageDelete(s.telegramChannelConfig(channel), chatID, messageID) -} - -func (s *TelegramBotService) scheduleTelegramMessageDelete(cfg map[string]string, chatID, messageID int) { - if chatID == 0 || messageID <= 0 || strings.TrimSpace(cfg["bot_token"]) == "" { - return - } - delay := telegramMessageDeleteDelay(cfg) - if delay < 0 { - return - } - cfgCopy := make(map[string]string, len(cfg)) - for k, v := range cfg { - cfgCopy[k] = v - } - go func() { - if delay > 0 { - timer := time.NewTimer(delay) - defer timer.Stop() - <-timer.C - } - deleteCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - err := telegramPostJSON(deleteCtx, cfgCopy, "deleteMessage", map[string]interface{}{ - "chat_id": strconv.Itoa(chatID), - "message_id": messageID, - }, 10*time.Second) - if err != nil && s.log != nil { - s.log.Debug("telegram deleteMessage failed", - zap.Int("chat_id", chatID), - zap.Int("message_id", messageID), - zap.Error(sanitizeTelegramError(err)), - ) - } - }() -} - -func telegramMessageDeleteDelay(cfg map[string]string) time.Duration { - for _, key := range []string{"auto_delete_seconds", "message_delete_seconds", "delete_after_seconds"} { - raw := strings.TrimSpace(cfg[key]) - if raw == "" { - continue - } - seconds, err := strconv.Atoi(raw) - if err != nil { - continue - } - if seconds < 0 { - return -1 - } - return time.Duration(seconds) * time.Second - } - return defaultTelegramMessageDeleteDelay -} diff --git a/internal/service/telegram_stats.go b/internal/service/telegram_stats.go deleted file mode 100644 index faf62b9..0000000 --- a/internal/service/telegram_stats.go +++ /dev/null @@ -1,224 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" - - "gorm.io/gorm" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -// cmdStatus 处理 /status 命令。 -func (s *TelegramBotService) cmdStatus(ctx context.Context) (telegramCommandReply, error) { - libraryIDs, err := s.activeTelegramStatsLibraryIDs(ctx) - if err != nil { - return telegramCommandReply{}, err - } - var mediaCount int64 - s.mediaStatsQuery(libraryIDs).Count(&mediaCount) - - var totalSize int64 - if err := s.mediaStatsQuery(libraryIDs).Select("COALESCE(SUM(size_bytes), 0)").Row().Scan(&totalSize); err != nil { - return telegramCommandReply{}, err - } - totalSizeGB := float64(totalSize) / 1024 / 1024 / 1024 - - return telegramCommandReply{Text: fmt.Sprintf( - "系统运行状态\n\n"+ - "🎬 媒体总数: %d\n"+ - "💾 存储占用: %.1f GB", - mediaCount, totalSizeGB, - )}, nil -} - -// cmdSearch 处理 /search 命令。 -func (s *TelegramBotService) cmdSearch(ctx context.Context, args []string) (telegramCommandReply, error) { - if len(args) == 0 { - return telegramCommandReply{Text: "请提供搜索关键词\n例: /search 哥斯拉"}, nil - } - - keyword := strings.Join(args, " ") - var results []model.Media - err := s.repo.DB.Where("title LIKE ?", "%"+keyword+"%"). - Order("year DESC").Limit(8). - Find(&results).Error - if err != nil { - return telegramCommandReply{}, err - } - - if len(results) == 0 { - return telegramCommandReply{Text: fmt.Sprintf("未找到与 %s 相关的媒体", keyword)}, nil - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("搜索: %s\n\n", keyword)) - for i, m := range results { - year := "" - if m.Year > 0 { - year = fmt.Sprintf(" (%d)", m.Year) - } - ep := "" - if m.SeasonNum > 0 && m.EpisodeNum > 0 { - ep = fmt.Sprintf(" S%02dE%02d", m.SeasonNum, m.EpisodeNum) - } - sb.WriteString(fmt.Sprintf("%d. %s%s%s — %s\n", i+1, m.Title, year, ep, formatSize(m.SizeBytes))) - } - - return telegramCommandReply{Text: sb.String()}, nil -} - -// cmdDownloads 处理 /downloads 命令。 -func (s *TelegramBotService) cmdDownloads(ctx context.Context) (telegramCommandReply, error) { - type Row struct { - Title string - Status string - } - var rows []Row - if err := s.repo.DB.Raw( - "SELECT COALESCE(NULLIF(title,''),'下载任务') as title, COALESCE(status,'unknown') as status FROM download_tasks ORDER BY created_at DESC LIMIT 8", - ).Scan(&rows).Error; err != nil { - return telegramCommandReply{}, err - } - - if len(rows) == 0 { - return telegramCommandReply{Text: "当前没有下载任务。"}, nil - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("下载任务 (%d)\n\n", len(rows))) - for _, r := range rows { - icon := "⏳" - switch r.Status { - case "completed": - icon = "✅" - case "downloading": - icon = "📥" - case "error": - icon = "❌" - } - name := strings.TrimSpace(r.Title) - if name == "" { - name = "下载任务" - } - if len(name) > 60 { - name = name[:57] + "..." - } - sb.WriteString(fmt.Sprintf("%s %s\n", icon, name)) - } - - return telegramCommandReply{Text: sb.String()}, nil -} - -// cmdStats 处理 /stats 命令。 -func (s *TelegramBotService) cmdStats(ctx context.Context) (telegramCommandReply, error) { - libs, err := s.activeTelegramStatsLibraries(ctx) - if err != nil { - return telegramCommandReply{}, err - } - libraryIDs := make([]string, 0, len(libs)) - for _, lib := range libs { - libraryIDs = append(libraryIDs, lib.ID) - } - var totalMedia int64 - s.mediaStatsQuery(libraryIDs).Count(&totalMedia) - - var totalSize int64 - if err := s.mediaStatsQuery(libraryIDs).Select("COALESCE(SUM(size_bytes), 0)").Row().Scan(&totalSize); err != nil { - return telegramCommandReply{}, err - } - - type LibStat struct { - Name string - Type string - Count int64 - } - stats := make([]LibStat, 0, len(libs)) - for _, lib := range libs { - var count int64 - if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("library_id = ?", lib.ID).Count(&count).Error; err != nil { - return telegramCommandReply{}, err - } - stats = append(stats, LibStat{Name: lib.Name, Type: lib.Type, Count: count}) - } - - var sb strings.Builder - sb.WriteString("媒体库统计\n\n") - sb.WriteString(fmt.Sprintf("📚 总数: %d\n", totalMedia)) - sb.WriteString(fmt.Sprintf("💾 大小: %s\n", formatSize(totalSize))) - - if len(stats) > 0 { - sb.WriteString("\n各库分布:\n") - for _, l := range stats { - icon := "🎬" - switch l.Type { - case "tv": - icon = "📺" - case "anime": - icon = "🍥" - case "music": - icon = "🎵" - } - sb.WriteString(fmt.Sprintf("%s %s: %d\n", icon, l.Name, l.Count)) - } - } - - return telegramCommandReply{Text: sb.String()}, nil -} - -func (s *TelegramBotService) activeTelegramStatsLibraries(ctx context.Context) ([]model.Library, error) { - if s == nil || s.repo == nil || s.repo.Library == nil { - return nil, nil - } - libs, err := s.repo.Library.List(ctx) - if err != nil { - return nil, err - } - libs = FilterDisplayCloudLibraries(ctx, s.repo, libs) - out := libs[:0] - for _, lib := range libs { - if lib.Enabled { - out = append(out, lib) - } - } - return out, nil -} - -func (s *TelegramBotService) activeTelegramStatsLibraryIDs(ctx context.Context) ([]string, error) { - libs, err := s.activeTelegramStatsLibraries(ctx) - if err != nil { - return nil, err - } - ids := make([]string, 0, len(libs)) - for _, lib := range libs { - ids = append(ids, lib.ID) - } - return ids, nil -} - -func (s *TelegramBotService) mediaStatsQuery(libraryIDs []string) *gorm.DB { - q := s.repo.DB.Model(&model.Media{}) - if len(libraryIDs) == 0 { - return q.Where("1 = 0") - } - return q.Where("library_id IN ?", libraryIDs) -} - -// formatSize 格式化字节数为可读字符串。 -func formatSize(bytes int64) string { - if bytes <= 0 { - return "0 B" - } - units := []string{"B", "KB", "MB", "GB", "TB"} - v := float64(bytes) - i := 0 - for v >= 1024 && i < len(units)-1 { - v /= 1024 - i++ - } - if i == 0 { - return fmt.Sprintf("%.0f %s", v, units[i]) - } - return fmt.Sprintf("%.1f %s", v, units[i]) -} diff --git a/internal/service/telegram_unbind.go b/internal/service/telegram_unbind.go deleted file mode 100644 index d496eee..0000000 --- a/internal/service/telegram_unbind.go +++ /dev/null @@ -1,224 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strconv" - "strings" - "time" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) cmdUnbind(ctx context.Context, args []string) telegramCommandReply { - targets := parseTelegramUnbindTargets(args) - if len(targets) == 0 { - return telegramCommandReply{Text: "用法:/unbind 用户名1 用户名2\n也支持逗号分隔,或使用 tg:TelegramID 按 Telegram ID 解绑。此命令只解绑 Bot,不删除媒体账号。"} - } - var removed int64 - var done []string - var skipped []string - var missing []string - for _, target := range targets { - if tgIDRaw, ok := strings.CutPrefix(strings.ToLower(target), "tg:"); ok { - tgID, err := strconv.ParseInt(tgIDRaw, 10, 64) - if err != nil || tgID == 0 { - missing = append(missing, target) - continue - } - n, err := s.deleteTelegramBindings(ctx, "telegram_user_id = ?", tgID) - if err != nil { - return telegramCommandReply{Text: "解绑失败:" + err.Error()} - } - if n == 0 { - missing = append(missing, target) - continue - } - removed += n - done = append(done, target) - continue - } - - user, _ := s.repo.User.FindByUsername(ctx, target) - if user == nil { - user, _ = s.repo.User.FindByID(ctx, target) - } - if user == nil { - missing = append(missing, target) - continue - } - if user.Role == "admin" { - skipped = append(skipped, user.Username+"(管理员)") - continue - } - n, err := s.deleteTelegramBindings(ctx, "user_id = ?", user.ID) - if err != nil { - return telegramCommandReply{Text: "解绑失败:" + err.Error()} - } - if n == 0 { - missing = append(missing, user.Username+"(未绑定)") - continue - } - removed += n - done = append(done, user.Username) - } - return formatUnbindResult("批量解绑完成", removed, done, skipped, missing) -} - -func (s *TelegramBotService) cmdUnbindDuplicates(ctx context.Context) telegramCommandReply { - if s == nil || s.repo == nil || s.repo.DB == nil { - return telegramCommandReply{Text: "仓库不可用。"} - } - var bindings []model.TelegramBinding - if err := s.repo.DB.WithContext(ctx).Order("updated_at desc, created_at desc").Find(&bindings).Error; err != nil { - return telegramCommandReply{Text: "读取绑定失败:" + err.Error()} - } - seenTelegram := make(map[int64]string) - seenUser := make(map[string]string) - var removeIDs []string - var removedLabels []string - for _, binding := range bindings { - remove := false - if binding.UserID == "" || binding.TelegramUserID == 0 { - remove = true - } else if user, _ := s.repo.User.FindByID(ctx, binding.UserID); user == nil { - remove = true - } else if _, ok := seenTelegram[binding.TelegramUserID]; ok { - remove = true - } else if _, ok := seenUser[binding.UserID]; ok { - remove = true - } - if remove { - removeIDs = append(removeIDs, binding.ID) - removedLabels = append(removedLabels, fmt.Sprintf("tg:%d", binding.TelegramUserID)) - continue - } - seenTelegram[binding.TelegramUserID] = binding.ID - seenUser[binding.UserID] = binding.ID - } - if len(removeIDs) == 0 { - return telegramCommandReply{Text: "未发现重复或无效绑定。"} - } - n, err := s.deleteTelegramBindings(ctx, "id IN ?", removeIDs) - if err != nil { - return telegramCommandReply{Text: "清理失败:" + err.Error()} - } - return formatUnbindResult("重复/无效绑定清理完成", n, removedLabels, nil, nil) -} - -func (s *TelegramBotService) cmdUnbindInactive(ctx context.Context, args []string) telegramCommandReply { - if len(args) == 0 { - return telegramCommandReply{Text: "用法:/unbind_inactive 天数\n例如 /unbind_inactive 30 会解绑 30 天未登录的普通用户 Bot 绑定,不删除账号。"} - } - days, err := strconv.Atoi(strings.TrimSpace(args[0])) - if err != nil || days < 1 { - return telegramCommandReply{Text: "天数必须是大于 0 的整数。"} - } - users, err := s.repo.User.List(ctx) - if err != nil { - return telegramCommandReply{Text: "读取用户失败:" + err.Error()} - } - cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour) - recentWindow := time.Duration(days) * 24 * time.Hour - var userIDs []string - var done []string - for _, user := range users { - if user.Role == "admin" { - continue - } - lastActive := user.CreatedAt - if user.LastLoginAt != nil { - lastActive = *user.LastLoginAt - } - if lastActive.IsZero() || lastActive.After(cutoff) { - continue - } - if s.device != nil && s.device.UserRecentlyActive(ctx, user.ID, recentWindow) { - continue - } - var count int64 - _ = s.repo.DB.WithContext(ctx).Model(&model.TelegramBinding{}).Where("user_id = ?", user.ID).Count(&count).Error - if count == 0 { - continue - } - userIDs = append(userIDs, user.ID) - done = append(done, user.Username) - } - if len(userIDs) == 0 { - return telegramCommandReply{Text: fmt.Sprintf("未发现 %d 天未登录且已绑定 Bot 的普通用户。", days)} - } - n, err := s.deleteTelegramBindings(ctx, "user_id IN ?", userIDs) - if err != nil { - return telegramCommandReply{Text: "解绑失败:" + err.Error()} - } - return formatUnbindResult(fmt.Sprintf("已解绑 %d 天未登录用户", days), n, done, nil, nil) -} - -func parseTelegramUnbindTargets(args []string) []string { - seen := make(map[string]struct{}) - var targets []string - for _, arg := range args { - for _, part := range strings.FieldsFunc(arg, func(r rune) bool { - return r == ',' || r == ',' || r == ';' || r == ';' || r == '\n' || r == '\t' - }) { - part = strings.TrimSpace(part) - if part == "" { - continue - } - key := strings.ToLower(part) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - targets = append(targets, part) - } - } - return targets -} - -func (s *TelegramBotService) deleteTelegramBindings(ctx context.Context, query string, args ...interface{}) (int64, error) { - if s == nil || s.repo == nil || s.repo.DB == nil { - return 0, nil - } - tx := s.repo.DB.WithContext(ctx).Unscoped().Where(query, args...).Delete(&model.TelegramBinding{}) - return tx.RowsAffected, tx.Error -} - -func formatUnbindResult(title string, removed int64, done, skipped, missing []string) telegramCommandReply { - var sb strings.Builder - sb.WriteString("") - sb.WriteString(title) - sb.WriteString("\n\n") - sb.WriteString(fmt.Sprintf("已解绑:%d 条绑定", removed)) - if len(done) > 0 { - sb.WriteString("\n目标:") - sb.WriteString(formatShortList(done, 12)) - } - if len(skipped) > 0 { - sb.WriteString("\n跳过:") - sb.WriteString(formatShortList(skipped, 8)) - } - if len(missing) > 0 { - sb.WriteString("\n未找到/未绑定:") - sb.WriteString(formatShortList(missing, 8)) - } - return telegramCommandReply{Text: sb.String()} -} - -func formatShortList(items []string, limit int) string { - if len(items) == 0 { - return "" - } - if limit < 1 { - limit = 1 - } - out := items - if len(out) > limit { - out = out[:limit] - } - text := "" + strings.Join(out, "、") + "" - if len(items) > limit { - text += fmt.Sprintf(" 等 %d 项", len(items)) - } - return text -} diff --git a/internal/service/telegram_user_self.go b/internal/service/telegram_user_self.go deleted file mode 100644 index 3588129..0000000 --- a/internal/service/telegram_user_self.go +++ /dev/null @@ -1,241 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "strconv" - "strings" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -func (s *TelegramBotService) cmdKick(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { - user := s.boundUser(ctx, msg.From.ID) - if user == nil { - return telegramCommandReply{Text: "请先绑定账号:/start 用户名 密码"} - } - if len(args) == 0 { - return telegramCommandReply{Text: "请指定要踢下线的设备:/kick all 或 /kick 设备编号。先用 /devices 查看编号。"} - } - target := strings.TrimSpace(args[0]) - if strings.EqualFold(target, "all") || target == "全部" { - if s.device != nil { - if err := s.device.KickAllDevices(ctx, user.ID); err != nil { - return telegramCommandReply{Text: "踢下线失败:" + err.Error()} - } - } else if err := s.repo.UserDevice.SetKickedByUser(ctx, user.ID, true); err != nil { - return telegramCommandReply{Text: "踢下线失败:" + err.Error()} - } - return telegramCommandReply{Text: "已踢下线此账号的全部设备。"} - } - devices, _ := s.listUserDevices(ctx, user.ID) - if len(devices) == 0 { - return telegramCommandReply{Text: "当前没有记录到登录设备。"} - } - var chosen *model.UserDevice - if n, err := strconv.Atoi(target); err == nil && n >= 1 && n <= len(devices) { - chosen = &devices[n-1] - } else { - for i := range devices { - if devices[i].ID == target || devices[i].DeviceID == target { - chosen = &devices[i] - break - } - } - } - if chosen == nil { - return telegramCommandReply{Text: "未找到该设备。请用 /devices 查看设备编号后重试。"} - } - if s.device != nil { - if err := s.device.KickDevice(ctx, user.ID, chosen.DeviceID); err != nil { - return telegramCommandReply{Text: "踢下线失败:" + err.Error()} - } - } else if fp := strings.TrimSpace(chosen.Fingerprint); fp != "" { - if err := s.repo.UserDevice.SetKickedByFingerprint(ctx, user.ID, fp, true); err != nil { - return telegramCommandReply{Text: "踢下线失败:" + err.Error()} - } - } else if err := s.repo.UserDevice.SetKicked(ctx, chosen.ID, true); err != nil { - return telegramCommandReply{Text: "踢下线失败:" + err.Error()} - } - return telegramCommandReply{Text: fmt.Sprintf("已踢下线:%s。", deviceLabel(chosen.DeviceName, chosen.Client))} -} - -func (s *TelegramBotService) cmdSetName(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { - if len(args) < 2 { - return telegramCommandReply{Text: "请发送:/setname 当前密码 新用户名"} - } - return s.selfSetName(ctx, msg, strings.Join(args, " ")) -} - -func (s *TelegramBotService) cmdSetPass(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { - if len(args) < 2 { - return telegramCommandReply{Text: "请发送:/setpass 当前密码 新密码"} - } - return s.selfSetPass(ctx, msg, strings.Join(args, " ")) -} - -func (s *TelegramBotService) replyAccount(ctx context.Context, msg *TelegramMessage) telegramCommandReply { - user := s.boundUser(ctx, msg.From.ID) - if user == nil { - return telegramCommandReply{Text: "请先绑定账号:/start 用户名 密码"} - } - streak := 0 - if rec, _ := s.repo.SignIn.Get(ctx, user.ID); rec != nil { - streak = rec.StreakDays - } - devices, _ := s.listUserDevices(ctx, user.ID) - text := fmt.Sprintf("我的账号\n\n用户名:%s\n状态:%s\n到期:%s\n连续签到:%d 天\n登录设备:%d 台", - user.Username, - map[bool]string{true: "正常", false: "已禁用"}[user.IsActive], - formatExpiry(user.ExpiredAt), streak, len(devices)) - return telegramCommandReply{Text: text, Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}} -} - -func (s *TelegramBotService) replySignIn(ctx context.Context, msg *TelegramMessage) telegramCommandReply { - user := s.boundUser(ctx, msg.From.ID) - if user == nil { - return telegramCommandReply{Text: "请先绑定账号后再签到。"} - } - res, err := s.signIn(ctx, user.ID) - if err != nil { - return telegramCommandReply{Text: "签到失败:" + err.Error()} - } - if res.AlreadySigned { - return telegramCommandReply{Text: fmt.Sprintf("今天已经签到过啦~\n连续签到 %d 天,累计 %d 天。", res.Streak, res.Total)} - } - return telegramCommandReply{Text: fmt.Sprintf("签到成功 ✅\n连续签到 %d 天,累计 %d 天。", res.Streak, res.Total)} -} - -func (s *TelegramBotService) replyDevices(ctx context.Context, msg *TelegramMessage) telegramCommandReply { - user := s.boundUser(ctx, msg.From.ID) - if user == nil { - return telegramCommandReply{Text: "请先绑定账号。"} - } - devices, _ := s.listUserDevices(ctx, user.ID) - if len(devices) == 0 { - return telegramCommandReply{Text: "当前没有记录到登录设备。"} - } - var sb strings.Builder - sb.WriteString("我的登录设备\n点击下方按钮可一键踢下线:\n") - var rows [][]telegramInlineButton - for i, d := range devices { - status := "" - if d.Kicked { - status = "(已踢下线)" - } else if d.Playing { - status = "(播放中)" - } else if d.Online { - status = "(在线)" - } - sb.WriteString(fmt.Sprintf("\n%d. %s%s\n 最近活跃:%s", i+1, deviceLabel(d.DeviceName, d.Client), status, d.LastSeenAt.Format("01-02 15:04"))) - if !d.Kicked && !strings.HasPrefix(d.ID, "rt:") { - rows = append(rows, []telegramInlineButton{{Text: "🚫 踢下线:" + deviceLabel(d.DeviceName, d.Client), Data: "kick:" + d.ID}}) - } - } - rows = append(rows, []telegramInlineButton{{Text: "⬅️ 返回菜单", Data: "menu_main"}}) - return telegramCommandReply{Text: sb.String(), Buttons: rows} -} - -func (s *TelegramBotService) replyKick(ctx context.Context, msg *TelegramMessage, deviceRowID string) telegramCommandReply { - user := s.boundUser(ctx, msg.From.ID) - if user == nil { - return telegramCommandReply{Text: "请先绑定账号。"} - } - var d model.UserDevice - if err := s.repo.DB.WithContext(ctx).Where("id = ? AND user_id = ?", deviceRowID, user.ID).First(&d).Error; err != nil { - return telegramCommandReply{Text: "未找到该设备。"} - } - if s.device != nil { - if err := s.device.KickDevice(ctx, user.ID, d.DeviceID); err != nil { - return telegramCommandReply{Text: "操作失败:" + err.Error()} - } - } else if fp := strings.TrimSpace(d.Fingerprint); fp != "" { - if err := s.repo.UserDevice.SetKickedByFingerprint(ctx, user.ID, fp, true); err != nil { - return telegramCommandReply{Text: "操作失败:" + err.Error()} - } - } else if err := s.repo.UserDevice.SetKicked(ctx, d.ID, true); err != nil { - return telegramCommandReply{Text: "操作失败:" + err.Error()} - } - return s.replyDevices(ctx, msg) -} - -func (s *TelegramBotService) listUserDevices(ctx context.Context, userID string) ([]model.UserDevice, error) { - if s.device != nil { - return s.device.ListDevices(ctx, userID) - } - return s.repo.UserDevice.ListByUser(ctx, userID) -} - -func (s *TelegramBotService) selfSetName(ctx context.Context, msg *TelegramMessage, input string) telegramCommandReply { - user := s.boundUser(ctx, msg.From.ID) - if user == nil { - return telegramCommandReply{Text: "请先绑定账号。"} - } - currentPassword, newName := splitCurrentPasswordAndValue(input) - if currentPassword == "" || newName == "" { - return telegramCommandReply{Text: "请发送:当前密码 新用户名。"} - } - newName = strings.TrimSpace(newName) - if len(newName) < 2 || strings.ContainsAny(newName, " \t\n") { - return telegramCommandReply{Text: "用户名至少 2 位且不能含空格,请重试。"} - } - if reply, ok := s.verifyTelegramSelfPassword(ctx, msg, user, currentPassword); !ok { - return reply - } - if existing, _ := s.repo.User.FindByUsername(ctx, newName); existing != nil && existing.ID != user.ID { - return telegramCommandReply{Text: "该用户名已被占用,请换一个。"} - } - if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"username": newName}); err != nil { - return telegramCommandReply{Text: "修改失败:" + err.Error()} - } - return telegramCommandReply{Text: fmt.Sprintf("用户名已修改为 %s。请用新用户名登录。", newName)} -} - -func (s *TelegramBotService) selfSetPass(ctx context.Context, msg *TelegramMessage, input string) telegramCommandReply { - user := s.boundUser(ctx, msg.From.ID) - if user == nil { - return telegramCommandReply{Text: "请先绑定账号。"} - } - currentPassword, newPass := splitCurrentPasswordAndValue(input) - if currentPassword == "" || newPass == "" { - return telegramCommandReply{Text: "请发送:当前密码 新密码。"} - } - newPass = strings.TrimSpace(newPass) - if s.auth == nil { - return telegramCommandReply{Text: "服务暂不可用。"} - } - if err := s.auth.ChangePassword(ctx, user.ID, currentPassword, newPass); err != nil { - if errors.Is(err, ErrInvalidCredentials) { - _ = s.unbindTelegramUser(ctx, msg.From.ID) - return telegramCommandReply{Text: "当前密码验证失败,绑定已自动解绑。请用新密码重新绑定账号。"} - } - return telegramCommandReply{Text: "修改失败:" + err.Error()} - } - if s.device != nil { - _ = s.device.KickAllDevices(ctx, user.ID) - } - return telegramCommandReply{Text: "密码已修改,请用新密码重新登录第三方客户端。"} -} - -func splitCurrentPasswordAndValue(input string) (string, string) { - fields := strings.Fields(strings.TrimSpace(input)) - if len(fields) < 2 { - return "", "" - } - return fields[0], strings.TrimSpace(strings.Join(fields[1:], " ")) -} - -func (s *TelegramBotService) verifyTelegramSelfPassword(ctx context.Context, msg *TelegramMessage, user *model.User, currentPassword string) (telegramCommandReply, bool) { - if s.auth == nil { - return telegramCommandReply{Text: "服务暂不可用。"}, false - } - if err := s.auth.VerifyPassword(ctx, user.ID, currentPassword); err != nil { - if errors.Is(err, ErrInvalidCredentials) { - _ = s.unbindTelegramUser(ctx, msg.From.ID) - return telegramCommandReply{Text: "当前密码验证失败,绑定已自动解绑。请用新密码重新绑定账号。"}, false - } - return telegramCommandReply{Text: "验证失败:" + err.Error()}, false - } - return telegramCommandReply{}, true -} diff --git a/internal/service/telegram_webhook_admin.go b/internal/service/telegram_webhook_admin.go deleted file mode 100644 index c3a70da..0000000 --- a/internal/service/telegram_webhook_admin.go +++ /dev/null @@ -1,31 +0,0 @@ -package service - -import ( - "context" - "time" - - "go.uber.org/zap" -) - -// SetWebhook 注册 Telegram Bot Webhook URL。 -func (s *TelegramBotService) SetWebhook(ctx context.Context, botToken, webhookURL string) error { - cfg := map[string]string{"bot_token": botToken} - if err := registerTelegramBotCommands(ctx, cfg); err != nil && s.log != nil { - s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err))) - } - payload := map[string]interface{}{ - "url": webhookURL, - "allowed_updates": []string{"message", "callback_query"}, - } - return telegramPostJSON(ctx, cfg, "setWebhook", payload, 15*time.Second) -} - -// GetWebhookInfo 获取 Webhook 配置信息。 -func (s *TelegramBotService) GetWebhookInfo(ctx context.Context, botToken string) (map[string]interface{}, error) { - cfg := map[string]string{"bot_token": botToken} - var result map[string]interface{} - if err := telegramGetJSONDecode(ctx, cfg, "getWebhookInfo", 10*time.Second, &result); err != nil { - return nil, err - } - return result, nil -} diff --git a/internal/service/transmission_adp.go b/internal/service/transmission_adp.go deleted file mode 100644 index b609d5b..0000000 --- a/internal/service/transmission_adp.go +++ /dev/null @@ -1,220 +0,0 @@ -// Package service — Transmission 下载适配器。 -// -// TransmissionAdapter 实现了 DownloadAdapter 接口,通过 Transmission RPC API -// 管理下载任务。 -package service - -import ( - "context" - "encoding/base64" - "fmt" - "net/http" - "strings" - "sync" - "time" -) - -// TransmissionAdapter 是 Transmission 的 DownloadAdapter 实现。 -type TransmissionAdapter struct { - mu sync.Mutex - cfg DownloadClientConfig - client *http.Client - tag int - sessionID string -} - -// NewTransmissionAdapter 创建新的 Transmission 适配器。 -func NewTransmissionAdapter() *TransmissionAdapter { - return &TransmissionAdapter{ - client: NewInternalHTTPClient(20 * time.Second), - } -} - -// AddTorrent 通过 URL 添加种子。 -func (a *TransmissionAdapter) AddTorrent(ctx context.Context, torrentURL, savePath string) (string, error) { - a.mu.Lock() - defer a.mu.Unlock() - args := map[string]interface{}{"filename": torrentURL} - return a.addTorrentLocked(ctx, args, savePath) -} - -// AddTorrentFile submits application-fetched .torrent bytes through -// Transmission's base64 metainfo field. This keeps private tracker cookies and -// signed URLs inside MediaStationGo instead of asking Transmission to refetch. -func (a *TransmissionAdapter) AddTorrentFile(ctx context.Context, data []byte, _ string, savePath string) (string, error) { - a.mu.Lock() - defer a.mu.Unlock() - args := map[string]interface{}{"metainfo": base64.StdEncoding.EncodeToString(data)} - return a.addTorrentLocked(ctx, args, savePath) -} - -func (a *TransmissionAdapter) addTorrentLocked(ctx context.Context, args map[string]interface{}, savePath string) (string, error) { - if savePath != "" { - args["download-dir"] = savePath - } - resp, err := a.rpcLocked(ctx, "torrent-add", args) - if err != nil { - return "", err - } - if added, ok := resp.Arguments["torrent-added"].(map[string]interface{}); ok { - if hashStr, ok := added["hashString"].(string); ok { - return hashStr, nil - } - } - if dup, ok := resp.Arguments["torrent-duplicate"].(map[string]interface{}); ok { - if hashStr, ok := dup["hashString"].(string); ok { - return hashStr, nil - } - } - return "", nil -} - -// AddMagnet 通过磁力链接添加种子。 -func (a *TransmissionAdapter) AddMagnet(ctx context.Context, magnet, savePath string) (string, error) { - return a.AddTorrent(ctx, magnet, savePath) -} - -// Pause 暂停种子。 -func (a *TransmissionAdapter) Pause(ctx context.Context, hash string) error { - a.mu.Lock() - defer a.mu.Unlock() - _, err := a.rpcLocked(ctx, "torrent-stop", map[string]interface{}{ - "ids": []string{hash}, - }) - return err -} - -// Resume 恢复种子。 -func (a *TransmissionAdapter) Resume(ctx context.Context, hash string) error { - a.mu.Lock() - defer a.mu.Unlock() - _, err := a.rpcLocked(ctx, "torrent-start", map[string]interface{}{ - "ids": []string{hash}, - }) - return err -} - -// Remove 删除种子。 -func (a *TransmissionAdapter) Remove(ctx context.Context, hash string, deleteFiles bool) error { - a.mu.Lock() - defer a.mu.Unlock() - _, err := a.rpcLocked(ctx, "torrent-remove", map[string]interface{}{ - "ids": []string{hash}, - "delete-local-data": deleteFiles, - }) - return err -} - -// List 列出种子。 -func (a *TransmissionAdapter) List(ctx context.Context, filter string) ([]TorrentInfo, error) { - a.mu.Lock() - defer a.mu.Unlock() - args := map[string]interface{}{ - "fields": []string{ - "hashString", "name", "totalSize", "percentDone", - "rateDownload", "rateUpload", "status", "downloadDir", - "peersSendingToUs", "peersGettingFromUs", "addedDate", - "doneDate", "labels", "isStalled", - }, - } - resp, err := a.rpcLocked(ctx, "torrent-get", args) - if err != nil { - return nil, err - } - - torrentsRaw, ok := resp.Arguments["torrents"].([]interface{}) - if !ok { - return nil, nil - } - - result := make([]TorrentInfo, 0, len(torrentsRaw)) - for _, tr := range torrentsRaw { - t, ok := tr.(map[string]interface{}) - if !ok { - continue - } - - hash, _ := t["hashString"].(string) - name, _ := t["name"].(string) - size := toInt64(t["totalSize"]) - progress := toFloat64(t["percentDone"]) - dlSpeed := toInt64(t["rateDownload"]) - upSpeed := toInt64(t["rateUpload"]) - savePath, _ := t["downloadDir"].(string) - numSeeds := int(toInt64(t["peersSendingToUs"])) - numLeechs := int(toInt64(t["peersGettingFromUs"])) - addedOn := int64(toFloat64(t["addedDate"])) - - // Transmission 状态码转字符串 - status := int(toFloat64(t["status"])) - state := canonicalTorrentState(transmissionStateStr(status), progress) - - // 过滤 - if filter != "" && !strings.EqualFold(state, filter) { - continue - } - - result = append(result, TorrentInfo{ - Hash: hash, - Name: name, - Size: size, - Progress: normalizedTorrentProgress(progress), - DLSpeed: dlSpeed, - UPSpeed: upSpeed, - State: state, - SavePath: savePath, - NumSeeds: numSeeds, - NumLeechs: numLeechs, - AddedOn: time.Unix(addedOn, 0), - Tags: toJSONLabels(t["labels"]), - ContentPath: downloaderPayloadPath(savePath, name), - CompletionOn: toInt64(t["doneDate"]), - }) - } - return result, nil -} - -// GetInfo 获取单个种子信息。 -func (a *TransmissionAdapter) GetInfo(ctx context.Context, hash string) (*TorrentInfo, error) { - a.mu.Lock() - defer a.mu.Unlock() - args := map[string]interface{}{ - "ids": []string{hash}, - "fields": []string{ - "hashString", "name", "totalSize", "percentDone", - "rateDownload", "rateUpload", "status", "downloadDir", - "peersSendingToUs", "peersGettingFromUs", "addedDate", "doneDate", "labels", - }, - } - resp, err := a.rpcLocked(ctx, "torrent-get", args) - if err != nil { - return nil, err - } - torrentsRaw, ok := resp.Arguments["torrents"].([]interface{}) - if !ok || len(torrentsRaw) == 0 { - return nil, fmt.Errorf("torrent %s not found", hash) - } - t, ok := torrentsRaw[0].(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("torrent %s: invalid response", hash) - } - - status := int(toFloat64(t["status"])) - info := &TorrentInfo{ - Hash: hash, - Name: strVal(t["name"]), - Size: toInt64(t["totalSize"]), - Progress: normalizedTorrentProgress(toFloat64(t["percentDone"])), - DLSpeed: toInt64(t["rateDownload"]), - UPSpeed: toInt64(t["rateUpload"]), - State: canonicalTorrentState(transmissionStateStr(status), toFloat64(t["percentDone"])), - SavePath: strVal(t["downloadDir"]), - NumSeeds: int(toInt64(t["peersSendingToUs"])), - NumLeechs: int(toInt64(t["peersGettingFromUs"])), - AddedOn: time.Unix(int64(toFloat64(t["addedDate"])), 0), - Tags: toJSONLabels(t["labels"]), - ContentPath: downloaderPayloadPath(strVal(t["downloadDir"]), strVal(t["name"])), - CompletionOn: toInt64(t["doneDate"]), - } - return info, nil -} diff --git a/internal/service/transmission_parse.go b/internal/service/transmission_parse.go deleted file mode 100644 index d8a4c63..0000000 --- a/internal/service/transmission_parse.go +++ /dev/null @@ -1,100 +0,0 @@ -package service - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" -) - -// transmissionStateStr 将 Transmission 状态码转为可读字符串。 -func transmissionStateStr(status int) string { - switch status { - case 0: - return "stopped" - case 1: - return "check_pending" - case 2: - return "checking" - case 3: - return "download_pending" - case 4: - return "downloading" - case 5: - return "seed_pending" - case 6: - return "seeding" - default: - return "unknown" - } -} - -// toInt64 安全地将 interface{} 转为 int64。 -func toInt64(v interface{}) int64 { - switch val := v.(type) { - case float64: - return int64(val) - case int: - return int64(val) - case int64: - return val - case json.Number: - n, _ := val.Int64() - return n - case string: - n, _ := strconv.ParseInt(val, 10, 64) - return n - default: - return 0 - } -} - -// toFloat64 安全地将 interface{} 转为 float64。 -func toFloat64(v interface{}) float64 { - switch val := v.(type) { - case float64: - return val - case int: - return float64(val) - case int64: - return float64(val) - case json.Number: - n, _ := val.Float64() - return n - case string: - n, _ := strconv.ParseFloat(val, 64) - return n - default: - return 0 - } -} - -// strVal 安全地提取字符串。 -func strVal(v interface{}) string { - if v == nil { - return "" - } - s, ok := v.(string) - if ok { - return s - } - return fmt.Sprintf("%v", v) -} - -// toJSONLabels 将 Transmission labels 转为逗号分隔字符串。 -func toJSONLabels(v interface{}) string { - if v == nil { - return "" - } - arr, ok := v.([]interface{}) - if !ok { - return "" - } - labels := make([]string, 0, len(arr)) - for _, item := range arr { - if s, ok := item.(string); ok { - labels = append(labels, s) - } - } - return strings.Join(labels, ",") -} diff --git a/internal/service/transmission_rpc.go b/internal/service/transmission_rpc.go deleted file mode 100644 index f17cbb1..0000000 --- a/internal/service/transmission_rpc.go +++ /dev/null @@ -1,141 +0,0 @@ -package service - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" -) - -// transmissionRPCRequest 是 Transmission RPC 请求的通用结构。 -type transmissionRPCRequest struct { - Method string `json:"method"` - Arguments map[string]interface{} `json:"arguments"` - Tag int `json:"tag,omitempty"` -} - -// transmissionRPCResponse 是 Transmission RPC 响应的通用结构。 -type transmissionRPCResponse struct { - Result string `json:"result"` - Arguments map[string]interface{} `json:"arguments"` - Tag int `json:"tag"` -} - -// Initialize 配置并初始化 Transmission RPC 连接。 -func (a *TransmissionAdapter) Initialize(ctx context.Context, cfg DownloadClientConfig) error { - a.mu.Lock() - defer a.mu.Unlock() - endpoint, err := normalizeDownloadClientEndpoint("transmission", cfg.Host) - if err != nil { - return err - } - cfg.Host = endpoint - a.cfg = cfg - a.sessionID = "" - a.tag = 0 - return a.pingLocked(ctx) -} - -// Ping 测试连接。 -func (a *TransmissionAdapter) Ping(ctx context.Context) error { - a.mu.Lock() - defer a.mu.Unlock() - return a.pingLocked(ctx) -} - -// pingLocked 内部 ping 实现(调用者必须持有锁)。 -func (a *TransmissionAdapter) pingLocked(ctx context.Context) error { - rpcURL, err := downloadClientRPCURL("transmission", a.cfg.Host) - if err != nil { - return err - } - req, err := newDownloadClientHTTPRequest(ctx, http.MethodGet, rpcURL, nil) - if err != nil { - return err - } - if a.cfg.Username != "" { - req.SetBasicAuth(a.cfg.Username, a.cfg.Password) - } - resp, err := a.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - _, _ = io.Copy(io.Discard, resp.Body) - if resp.StatusCode == 409 { - // 正常:需要 CSRF token - a.sessionID = resp.Header.Get("X-Transmission-Session-Id") - return nil - } - if resp.StatusCode >= 400 { - return fmt.Errorf("transmission rpc: %d", resp.StatusCode) - } - return nil -} - -// rpcLocked 发送 RPC 请求(调用者必须持有锁)。 -func (a *TransmissionAdapter) rpcLocked(ctx context.Context, method string, args map[string]interface{}) (*transmissionRPCResponse, error) { - rpcURL, err := downloadClientRPCURL("transmission", a.cfg.Host) - if err != nil { - return nil, err - } - - a.tag++ - body, err := json.Marshal(transmissionRPCRequest{ - Method: method, - Arguments: args, - Tag: a.tag, - }) - if err != nil { - return nil, err - } - - for attempt := 0; attempt < 2; attempt++ { - res, retry, err := func() (*transmissionRPCResponse, bool, error) { - req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, rpcURL, bytes.NewReader(body)) - if err != nil { - return nil, false, err - } - req.Header.Set("Content-Type", "application/json") - if a.sessionID != "" { - req.Header.Set("X-Transmission-Session-Id", a.sessionID) - } - if a.cfg.Username != "" { - req.SetBasicAuth(a.cfg.Username, a.cfg.Password) - } - - resp, err := a.client.Do(req) - if err != nil { - return nil, false, err - } - defer resp.Body.Close() - - if resp.StatusCode == 409 { - a.sessionID = resp.Header.Get("X-Transmission-Session-Id") - return nil, true, nil - } - if resp.StatusCode >= 400 { - raw, _ := io.ReadAll(resp.Body) - return nil, false, fmt.Errorf("transmission rpc error: %d: %s", resp.StatusCode, string(raw)) - } - - var result transmissionRPCResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, false, err - } - if result.Result != "success" { - return nil, false, fmt.Errorf("transmission rpc result: %s", result.Result) - } - return &result, false, nil - }() - if err != nil { - return nil, err - } - if !retry { - return res, nil - } - } - return nil, fmt.Errorf("transmission: failed after CSRF retry") -} diff --git a/internal/service/watcher.go b/internal/service/watcher.go index 7a37bfc..47cc4cd 100644 --- a/internal/service/watcher.go +++ b/internal/service/watcher.go @@ -108,9 +108,6 @@ func (w *WatcherService) Refresh(ctx context.Context) error { if !root.Enabled { continue } - if _, _, ok := parseCloudLibraryPath(root.Path); ok { - continue - } watchRoot, info, err := resolveAccessibleMappedPath(root.Path) if err != nil || !info.IsDir() { w.log.Warn("watch path inaccessible", diff --git a/web/index.html b/web/index.html index ce08c73..948f2b0 100644 --- a/web/index.html +++ b/web/index.html @@ -4,16 +4,17 @@ - - - + + + + - MediaStationGo + MMTL
diff --git a/web/package.json b/web/package.json index a9e8bcf..68cae85 100644 --- a/web/package.json +++ b/web/package.json @@ -1,5 +1,5 @@ { - "name": "mediastationgo-web", + "name": "mmtl-web", "private": true, "version": "0.1.0", "type": "module", diff --git a/web/public/brand/logo-192.png b/web/public/brand/logo-192.png new file mode 100644 index 0000000..674b950 Binary files /dev/null and b/web/public/brand/logo-192.png differ diff --git a/web/public/brand/logo-512.png b/web/public/brand/logo-512.png new file mode 100644 index 0000000..7daa368 Binary files /dev/null and b/web/public/brand/logo-512.png differ diff --git a/web/public/brand/logo-64.png b/web/public/brand/logo-64.png new file mode 100644 index 0000000..bc847c4 Binary files /dev/null and b/web/public/brand/logo-64.png differ diff --git a/web/public/brand/logo.png b/web/public/brand/logo.png new file mode 100644 index 0000000..a19a8b2 Binary files /dev/null and b/web/public/brand/logo.png differ diff --git a/web/public/brand/logo.svg b/web/public/brand/logo.svg new file mode 100644 index 0000000..cee18e6 --- /dev/null +++ b/web/public/brand/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/public/brand/mediastationgo-logo.svg b/web/public/brand/mediastationgo-logo.svg index f3a6ed4..cee18e6 100644 --- a/web/public/brand/mediastationgo-logo.svg +++ b/web/public/brand/mediastationgo-logo.svg @@ -1,12 +1,3 @@ - - - - - - - - - - - + + diff --git a/web/public/brand/mgo-emby-icon.svg b/web/public/brand/mgo-emby-icon.svg index f3a6ed4..cee18e6 100644 --- a/web/public/brand/mgo-emby-icon.svg +++ b/web/public/brand/mgo-emby-icon.svg @@ -1,12 +1,3 @@ - - - - - - - - - - - + + diff --git a/web/public/brand/mmtl-logo.png b/web/public/brand/mmtl-logo.png new file mode 100644 index 0000000..a19a8b2 Binary files /dev/null and b/web/public/brand/mmtl-logo.png differ diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 0000000..28d4d5a Binary files /dev/null and b/web/public/favicon.ico differ diff --git a/web/public/favicon.png b/web/public/favicon.png new file mode 100644 index 0000000..a41c626 Binary files /dev/null and b/web/public/favicon.png differ diff --git a/web/public/favicon.svg b/web/public/favicon.svg index f3a6ed4..cee18e6 100644 --- a/web/public/favicon.svg +++ b/web/public/favicon.svg @@ -1,12 +1,3 @@ - - - - - - - - - - - + + diff --git a/web/src/App.tsx b/web/src/App.tsx index 448bc60..536d549 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,7 +16,7 @@ class AppErrorBoundary extends Component<{ children: ReactNode }, { hasError: bo } componentDidCatch(error: Error, info: ErrorInfo) { - console.error('MediaStationGo UI crashed', error, info) + console.error('MMTL UI crashed', error, info) } render() { diff --git a/web/src/api/ai.ts b/web/src/api/ai.ts deleted file mode 100644 index 38e9e3d..0000000 --- a/web/src/api/ai.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { api } from './client' -import type { Media } from '../types' - -export interface SearchIntent { - query: string - year?: number - genre?: string - type?: string - sort?: string - language?: string -} - -export interface ExternalMediaResult { - source: string - media_type?: string - title: string - original_name?: string - overview?: string - poster_url?: string - backdrop_url?: string - year?: number - rating?: number - tmdb_id?: number - bangumi_id?: number - douban_id?: string - subscribe_keyword: string - subscribe_aliases?: string[] - total_episodes?: number - downloaded_episodes?: number - local_media_count?: number - missing_episodes?: number[] - in_library?: boolean -} - -export const aiAPI = { - status: () => - api - .get<{ enabled: boolean; provider: string; model: string }>('/ai/status') - .then((r) => r.data), - - smartSearch: (query: string) => - api - .post<{ intent: SearchIntent; items: Media[]; external_items: ExternalMediaResult[] }>( - '/ai/search', - { query }, - ) - .then((r) => r.data), - - recommend: () => api.get<{ titles: string[] }>('/ai/recommend').then((r) => r.data.titles), -} diff --git a/web/src/api/assistant.ts b/web/src/api/assistant.ts deleted file mode 100644 index e072689..0000000 --- a/web/src/api/assistant.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { api } from './client' - -export interface AssistantSession { - id: string - user_id: string - title: string - created_at: string - updated_at: string -} - -export interface AssistantMessage { - id: string - session_id: string - role: 'user' | 'assistant' | 'system' - content: string - operation_id?: string - created_at: string -} - -export interface SessionView { - session: AssistantSession - messages: AssistantMessage[] -} - -export const assistantAPI = { - listSessions: () => - api.get('/admin/assistant/sessions').then((r) => r.data), - - createSession: (title?: string) => - api - .post('/admin/assistant/sessions', { title }) - .then((r) => r.data), - - getSession: (id: string) => - api.get(`/admin/assistant/session/${id}`).then((r) => r.data), - - deleteSession: (id: string) => - api.delete(`/admin/assistant/session/${id}`).then((r) => r.data), - - chat: (sessionID: string, message: string) => - api - .post('/admin/assistant/chat', { - session_id: sessionID, - message, - }) - .then((r) => r.data), - - execute: (sessionID: string, action: Record) => - api - .post<{ op_id: string }>('/admin/assistant/execute', { - session_id: sessionID, - action, - }) - .then((r) => r.data), - - undo: (opID: string) => - api.post(`/admin/assistant/undo/${opID}`).then((r) => r.data), - - history: () => - api - .get<{ items: { op_id: string; session: string; created_at: string; content: string }[] }>( - '/admin/assistant/history', - ) - .then((r) => r.data.items), -} diff --git a/web/src/api/discover.ts b/web/src/api/discover.ts deleted file mode 100644 index 8370cf2..0000000 --- a/web/src/api/discover.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { api } from './client' -import type { Media } from '../types' - -// TMDb-derived "Match" rows used by trending/popular rails. We re-use the -// Media interface — only TMDb id / poster / overview are populated. -export interface DiscoverItem extends Partial { - source?: string - media_type?: string - tmdb_id?: number - douban_id?: string - bangumi_id?: number - title: string - poster_url?: string - backdrop_url?: string - overview?: string - year?: number - rating?: number - subscribe_keyword?: string - subscribe_aliases?: string[] - total_episodes?: number - downloaded_episodes?: number - local_media_count?: number - missing_episodes?: number[] - in_library?: boolean -} - -export interface DiscoverSection { - key: string - label: string - provider?: string -} - -export interface DiscoverFeedMeta { - page: number - has_next: boolean - duration_ms?: number - error?: string - warning?: string - stale?: boolean - disabled?: boolean -} - -export interface DiscoverFeedResult { - items: Record - meta: Record -} - -// 后端在 TMDb 不可达 / API key 缺失时统一返回 { items: [], error: "..." } -// 200 状态码——前端必须能区分这两种情况,不能简单用 items.length === 0 -// 推断"未配置 API key"。 -export interface DiscoverResp { - items: DiscoverItem[] - error?: string -} - -export const discoverAPI = { - trending: () => - api.get('/discover/trending').then((r) => ({ - items: r.data.items ?? [], - error: r.data.error, - })), - popular: () => - api.get('/discover/popular').then((r) => ({ - items: r.data.items ?? [], - error: r.data.error, - })), - sections: () => - api.get<{ sections: DiscoverSection[] }>('/discover/sections').then((r) => r.data.sections), - feed: (sectionKeys: string[], page = 1): Promise => - api - .get | null>>('/discover/feed', { - params: { sections: sectionKeys.join(','), page }, - }) - .then((r) => { - const raw = r.data - const meta = ((raw._meta as Record | undefined) ?? {}) - const items: Record = {} - for (const key of sectionKeys) { - const row = raw[key] - items[key] = Array.isArray(row) ? row : [] - } - return { items, meta } - }), -} diff --git a/web/src/api/download_clients.ts b/web/src/api/download_clients.ts deleted file mode 100644 index 2a923d4..0000000 --- a/web/src/api/download_clients.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { api } from './client' - -export type DownloadClientType = 'qbittorrent' | 'aria2' | 'transmission' - -// 与后端 model.DownloadClient 字段对齐(json:"host")。 -// 之前前端用的 `url` / `save_path` 字段会被后端忽略并触发 400 binding 错误。 -export interface DownloadClient { - id: string - name: string - type: DownloadClientType - host: string - username?: string - is_default: boolean - enabled: boolean - created_at: string - updated_at: string -} - -export interface DownloadClientInput { - name: string - type: DownloadClientType - host: string - username?: string - password?: string - is_default: boolean - enabled: boolean -} - -export const downloadClientsAPI = { - list: () => - api.get('/admin/download/clients').then((r) => r.data ?? []), - - create: (input: DownloadClientInput) => - api.post('/admin/download/clients', input).then((r) => r.data), - - update: (id: string, input: DownloadClientInput) => - api - .put(`/admin/download/clients/${id}`, input) - .then((r) => r.data), - - remove: (id: string) => - api.delete(`/admin/download/clients/${id}`).then((r) => r.data), - - test: (id: string) => - api - .post<{ ok: boolean; error?: string }>(`/admin/download/clients/${id}/test`) - .then((r) => r.data), - - aria2Stats: (clientID: string) => - api - .get('/admin/download/aria2/stats', { params: { client_id: clientID } }) - .then((r) => r.data), -} diff --git a/web/src/api/downloads.ts b/web/src/api/downloads.ts deleted file mode 100644 index c630ceb..0000000 --- a/web/src/api/downloads.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { api } from './client' -import type { DownloadTask, QBitTorrent } from '../types' - -export interface DownloadsState { - tasks: DownloadTask[] - torrents: QBitTorrent[] | null -} - -export interface AddDownloadInput { - url: string - save_path?: string - title?: string - poster_url?: string - backdrop_url?: string - overview?: string - media_type?: string - media_category?: string - source_category?: string -} - -export const downloadsAPI = { - list: () => api.get('/downloads').then((r) => r.data), - - add: (url: string, savePath = '', meta: Omit = {}) => - api - .post('/downloads', { url, save_path: savePath, ...meta }) - .then((r) => r.data), - - remove: (hash: string, clientID: string, deleteFiles = false) => - api - .delete(`/downloads/${hash}`, { params: { client_id: clientID, delete_files: deleteFiles } }) - .then((r) => r.data), - - reload: () => api.post('/downloads/reload').then((r) => r.data), -} diff --git a/web/src/api/duplicates.ts b/web/src/api/duplicates.ts deleted file mode 100644 index 434180f..0000000 --- a/web/src/api/duplicates.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { api } from './client' -import type { Media } from '../types' - -export interface DuplicateGroup { - hash: string - primary: Media - duplicates: Media[] -} - -export interface DuplicateReport { - total_scanned: number - groups_found: number - items_marked: number - missing_removed?: number - groups: DuplicateGroup[] -} - -export const duplicatesAPI = { - list: (libraryID = '') => - api - .get('/duplicates', { - params: libraryID ? { library_id: libraryID } : undefined, - }) - .then((r) => ({ ...r.data, groups: r.data.groups ?? [] })), - scan: (libraryID = '') => - api - .post('/duplicates/scan', null, { - params: libraryID ? { library_id: libraryID } : undefined, - }) - .then((r) => ({ ...r.data, groups: r.data.groups ?? [] })), - unmark: (libraryID = '') => - api - .post<{ unmarked: number }>('/duplicates/unmark', null, { - params: libraryID ? { library_id: libraryID } : undefined, - }) - .then((r) => r.data), -} diff --git a/web/src/api/license.ts b/web/src/api/license.ts deleted file mode 100644 index b475f35..0000000 --- a/web/src/api/license.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { api } from './client' - -// ── License Server types (generic, ready for MediaStationGo license integration) ── - -/** Response after binding/activating a license. */ -export interface LicenseActivation { - id: string - key_id: string - /** The license key string (e.g. MS-XXXX-XXXX-XXXX) */ - key?: string - device_id: string - device_name?: string - plan?: string - max_activations?: number - max_users?: number | null - unlimited_users?: boolean - /** ISO8601 — null means perpetual */ - expires_at?: string | null - valid: boolean - ip?: string - heartbeat_at?: string | null - created_at: string -} - -/** Status of the currently bound license on this device. */ -export interface LicenseStatus { - /** Whether a license is currently active */ - active: boolean - activation?: LicenseActivation - max_users?: number | null - unlimited_users?: boolean - /** Error or status message */ - message?: string -} - -// ── API methods ── - -export const licenseAPI = { - /** Bind / activate a license key for this device. */ - bind: (key: string) => - api - .post('/license/activate', { - key: key.trim(), - }) - .then((r) => r.data), - - /** Get the status of the currently active license. */ - status: () => - api.get('/license/status').then((r) => r.data), - - /** Refresh the heartbeat for the active license. */ - heartbeat: () => - api.post('/license/heartbeat').then((r) => r.data), -} diff --git a/web/src/api/notify_channels.ts b/web/src/api/notify_channels.ts deleted file mode 100644 index 8335f48..0000000 --- a/web/src/api/notify_channels.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { api } from './client' -import type { NotifyChannel } from '../types' - -// Payload accepted by create / update. `events` and `enabled` are optional. -export interface NotifyChannelInput { - name: string - type: NotifyChannel['type'] - config: Record - events?: string[] - enabled?: boolean -} - -// notifyChannelsAPI wraps the admin /admin/notify/channels surface. -export const notifyChannelsAPI = { - list: () => - api.get('/admin/notify/channels').then((r) => r.data ?? []), - - create: (input: NotifyChannelInput) => - api.post('/admin/notify/channels', input).then((r) => r.data), - - update: (id: string, input: NotifyChannelInput) => - api.put(`/admin/notify/channels/${id}`, input).then((r) => r.data), - - remove: (id: string) => - api.delete(`/admin/notify/channels/${id}`).then((r) => r.data), - - test: (id: string) => - api.post<{ message: string }>(`/admin/notify/channels/${id}/test`).then((r) => r.data), - - startTelegramPolling: () => - api.post<{ - message: string - started: number - already_running: number - skipped: number - errors?: string[] - }>('/admin/telegram/polling/start').then((r) => r.data), - - stopTelegramPolling: () => - api.post<{ message: string; stopped: number }>('/admin/telegram/polling/stop').then((r) => r.data), -} diff --git a/web/src/api/recycle.ts b/web/src/api/recycle.ts index 2ca423d..467e055 100644 --- a/web/src/api/recycle.ts +++ b/web/src/api/recycle.ts @@ -1,21 +1,12 @@ import { api } from './client' -import type { Media } from '../types' export const recycleAPI = { - list: () => api.get<{ items: Media[] }>('/recycle').then((r) => r.data.items), - softDelete: (id: string) => api.delete(`/media/${id}`).then((r) => r.data), restore: (id: string) => api.post(`/media/${id}/restore`).then((r) => r.data), - restoreMany: (ids: string[]) => - api.post<{ applied: number; errors?: string[] }>('/recycle/restore', { media_ids: ids }).then((r) => r.data), - purge: (id: string) => api.delete(`/media/${id}/purge`).then((r) => r.data), - purgeMany: (ids: string[]) => - api.post<{ applied: number; errors?: string[] }>('/recycle/purge', { media_ids: ids }).then((r) => r.data), - exportNFO: (id: string) => api.post<{ path: string }>(`/media/${id}/nfo`).then((r) => r.data), diff --git a/web/src/api/scheduler.ts b/web/src/api/scheduler.ts deleted file mode 100644 index 527d748..0000000 --- a/web/src/api/scheduler.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { api } from './client' - -export interface JobStatus { - name: string - interval: string - last_run?: string - last_err?: string - running?: boolean - started_at?: string -} - -export const schedulerAPI = { - status: () => api.get<{ jobs: JobStatus[] }>('/admin/scheduler').then((r) => r.data.jobs), - run: (name: string) => api.post(`/admin/scheduler/${name}/run`).then((r) => r.data), -} diff --git a/web/src/api/sites.ts b/web/src/api/sites.ts deleted file mode 100644 index 9fe6372..0000000 --- a/web/src/api/sites.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { api } from './client' - -// ─── TypeScript interfaces ────────────────────────────────────────────── -// Note: the canonical Site type lives in ../types/index.ts -// The backend uses json:"url" (not base_url) and json:"type" (not site_type). - -export interface SiteSearchResult { - site_name: string - site_id: string - title: string - subtitle?: string - torrent_url: string - download_url: string - category?: string - size: number - seeders: number - leechers: number - free: boolean -} - -export interface CreateSiteInput { - name: string - url: string - type?: string - auth_type?: string - cookie?: string - api_key?: string - auth_header?: string - enabled?: boolean - is_default?: boolean - extra?: string -} - -// ─── API client ───────────────────────────────────────────────────────── - -export const sitesAPI = { - // List all sites - list: () => api.get('/sites').then((r) => r.data), - - // Get single site with decrypted fields - get: (id: string | number) => api.get(`/sites/${id}`).then((r) => r.data), - - // Create a new site - create: (data: Record) => - api.post('/sites', data).then((r) => r.data), - - // Update existing site - update: (id: string | number, data: Record) => - api.put(`/sites/${id}`, data).then((r) => r.data), - - // Delete a site - remove: (id: string | number) => - api.delete(`/sites/${id}`).then((r) => r.data), - - // Test site connectivity - test: (id: string | number) => - api.post(`/sites/${id}/test`).then((r) => r.data), - - // Get supported site types - types: () => api.get('/sites/types').then((r) => r.data), - - // Get supported auth types - authTypes: () => api.get('/sites/auth-types').then((r) => r.data), - - // Search across all sites - search: (keyword: string) => - api - .get('/sites/search', { params: { keyword } }) - .then((r) => r.data), -} diff --git a/web/src/api/stats.ts b/web/src/api/stats.ts deleted file mode 100644 index d9a568d..0000000 --- a/web/src/api/stats.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { api } from './client' -import type { Hardware, StatsSnapshot } from '../types' - -export const statsAPI = { - snapshot: () => api.get('/stats').then((r) => r.data), - monitor: () => api.get('/stats/monitor').then((r) => r.data), -} diff --git a/web/src/api/storage_config.ts b/web/src/api/storage_config.ts deleted file mode 100644 index 0c0057d..0000000 --- a/web/src/api/storage_config.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { api, BATCH_REQUEST_TIMEOUT, LONG_REQUEST_TIMEOUT } from './client' - -export type StorageType = 'alist' | 'openlist' | 'webdav' | 'cloud115' | 'clouddrive2' - -export interface CloudEntry { - id: string - name: string - is_dir: boolean - size: number - pick_code?: string -} - -export interface QRSession { - uid: string - time: number - sign: string - qr_image_url: string -} - -export interface QRStatus { - state: 'waiting' | 'scanned' | 'confirmed' | 'expired' - cookie?: string -} - -export interface StorageConfig { - id: string - type: StorageType - config: Record - enabled: boolean - last_error?: string - created_at: string - updated_at: string -} - -export interface CloudUploadResult { - source_path: string - dest_path: string - uploaded: number - moved?: number - skipped: number - bytes: number - errors?: string[] - items?: Array<{ - source: string - target: string - action: 'upload' | 'move' | 'skip' | 'error' - size?: number - reason?: string - }> -} - -export interface CloudScanStatus { - library_id: string - provider: string - stage: string - state: string - dirs: number - discovered: number - visited: number - added: number - updated: number - skipped: number - removed: number - error?: string - resume_hint?: string - estimate_message?: string - files_per_second?: number -} - -export const storageAPI = { - status: () => - api - .get<{ items: StorageConfig[] }>('/admin/storage/status') - .then((r) => r.data.items), - - get: (type: StorageType) => - api.get(`/admin/storage/${type}`).then((r) => r.data), - - save: (type: StorageType, config: Record, enabled = true) => - api - .put(`/admin/storage/${type}`, { type, config, enabled }) - .then((r) => r.data), - - logout: (type: StorageType) => - api - .post(`/admin/storage/${type}/logout`) - .then((r) => r.data), - - test: (type: StorageType, config: Record) => - api - .post<{ ok: boolean; error?: string }>(`/admin/storage/${type}/test`, { - type, - config, - }, { - timeout: LONG_REQUEST_TIMEOUT, - }) - .then((r) => r.data), - - uploadLocal: ( - type: StorageType, - input: { - source_path: string - dest_path: string - recursive: boolean - include_sidecars: boolean - overwrite: boolean - transfer_mode?: 'copy' | 'move' - }, - ) => - api - .post<{ result: CloudUploadResult; error?: string }>(`/admin/storage/${type}/upload-local`, input, { - timeout: BATCH_REQUEST_TIMEOUT, - }) - .then((r) => r.data), - - scanAllCloud: () => - api - .post<{ items: CloudScanStatus[]; message?: string; estimate_message?: string }>('/admin/cloud/scan-all') - .then((r) => r.data), - - cancelCloudScan: (libraryID = '', provider = '') => - api - .post<{ cancelled: number; message?: string }>('/admin/cloud/scan/cancel', null, { - params: libraryID ? { library_id: libraryID } : provider ? { provider } : undefined, - }) - .then((r) => r.data), - - cloudScanStatus: () => - api - .get<{ items: CloudScanStatus[] }>('/admin/cloud/scan/status') - .then((r) => r.data), -} - -// cloudAPI drives 网盘 browsing, QR login and 302 import. -export const cloudAPI = { - list: (type: StorageType, dir = '') => - api - .get<{ items: CloudEntry[]; error?: string }>(`/admin/cloud/${type}/list`, { - params: { dir }, - timeout: LONG_REQUEST_TIMEOUT, - }) - .then((r) => r.data), - - mkdir: (type: StorageType, dir: string, name: string) => - api - .post<{ entry: CloudEntry }>(`/admin/cloud/${type}/mkdir`, { dir, name }, { - timeout: LONG_REQUEST_TIMEOUT, - }) - .then((r) => r.data), - - rename: (type: StorageType, ref: string, name: string) => - api - .put<{ entry: CloudEntry }>(`/admin/cloud/${type}/rename`, { ref, name }, { - timeout: LONG_REQUEST_TIMEOUT, - }) - .then((r) => r.data), - - import: (type: StorageType, ref: string, name: string, size: number) => - api - .post(`/admin/cloud/${type}/import`, { ref, name, size }) - .then((r) => r.data), - - mount: (type: StorageType, dir = '', name = '', media_type = 'movie', dir_path = '') => - api - .post(`/admin/cloud/${type}/mount`, { dir, dir_path, name, media_type }, { - timeout: LONG_REQUEST_TIMEOUT, - }) - .then((r) => r.data), - - qrStart: (type: StorageType) => - api.post(`/admin/cloud/${type}/qr/start`).then((r) => r.data), - - qrPoll: (type: StorageType, sess: QRSession) => - api.post(`/admin/cloud/${type}/qr/poll`, sess).then((r) => r.data), -} diff --git a/web/src/api/strm.ts b/web/src/api/strm.ts deleted file mode 100644 index a84acff..0000000 --- a/web/src/api/strm.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { api, BATCH_REQUEST_TIMEOUT } from './client' - -export type GenerateSTRMInput = { - library_id: string - output_dir?: string - base_url?: string - enabled?: boolean - overwrite?: boolean - include_local?: boolean - preserve_tree?: boolean - refresh_library?: boolean - scrape_after?: boolean -} - -export type GenerateSTRMResult = { - library_id: string - output_dir: string - generated: number - updated: number - skipped: number - cleaned: number - total?: number - remaining?: number - batch_limited?: boolean - ignored?: number - previewed?: number - errors?: string[] - ignored_items?: string[] - refresh?: STRMRefreshResult - items?: Array<{ - media_id: string - title: string - file_path: string - url?: string - action: string - reason?: string - }> -} - -export type GenerateSTRMTreeInput = { - provider: string - tree_text?: string - paths?: string[] - source_root?: string - output_prefix?: string - output_dir: string - base_url?: string - overwrite?: boolean - cleanup?: boolean - dry_run?: boolean - batch_limit?: number - recognize_rename?: boolean - transfer_subtitles?: boolean - missing_only?: boolean - refresh_library?: boolean - scrape_after?: boolean -} - -export type RepairSTRMInput = { - output_dir: string - base_url?: string - dry_run?: boolean - refresh_library?: boolean - scrape_after?: boolean -} - -export type RepairSTRMResult = { - output_dir: string - repaired: number - previewed?: number - skipped: number - errors?: string[] - refresh?: STRMRefreshResult - items?: Array<{ - file_path: string - before?: string - after?: string - action: string - reason?: string - }> -} - -export type STRMRefreshResult = { - requested: boolean - queued: boolean - reason?: string - scrape_requested?: boolean - scrape_queued?: boolean - scrape_reason?: string - targets?: Array<{ - library_id: string - root_id?: string - name: string - path: string - }> -} - -export type STRMOutputPreset = { - label: string - path: string - kind: 'default' | 'library' | string -} - -export const strmAPI = { - set: (mediaID: string, url: string) => - api.put(`/media/${mediaID}/strm`, { url }).then((r) => r.data), - clear: (mediaID: string) => api.delete(`/media/${mediaID}/strm`).then((r) => r.data), - outputPresets: () => - api.get<{ items: STRMOutputPreset[] }>('/strm/output-presets').then((r) => r.data.items), - importURL: (libraryID: string, title: string, url: string) => - api.post('/strm/import', { library_id: libraryID, title, url }).then((r) => r.data), - generate: (input: GenerateSTRMInput) => - api - .post('/strm/generate', input, { timeout: BATCH_REQUEST_TIMEOUT }) - .then((r) => r.data), - generateFromTree: (input: GenerateSTRMTreeInput) => - api - .post('/strm/generate-from-tree', input, { timeout: BATCH_REQUEST_TIMEOUT }) - .then((r) => r.data), - repair: (input: RepairSTRMInput) => - api - .post('/strm/repair', input, { timeout: BATCH_REQUEST_TIMEOUT }) - .then((r) => r.data), -} diff --git a/web/src/api/subscriptions.ts b/web/src/api/subscriptions.ts deleted file mode 100644 index 3c9b39d..0000000 --- a/web/src/api/subscriptions.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { api } from './client' -import type { Subscription } from '../types' - -export function buildSiteSearchFeedURL(keyword: string, source?: string, aliases: string[] = []) { - const params = new URLSearchParams() - params.set('keyword', keyword) - if (source) params.set('source', source) - const seen = new Set([keyword.trim().toLowerCase()]) - aliases - .map((alias) => alias.trim()) - .filter(Boolean) - .forEach((alias) => { - const key = alias.toLowerCase() - if (seen.has(key)) return - seen.add(key) - params.append('alias', alias) - }) - return `site-search://search?${params.toString()}` -} - -export function buildSubscriptionAliases(item: { - title?: string - original_name?: string - subscribe_keyword?: string - subscribe_aliases?: string[] - year?: number -}) { - const withYear = (value?: string) => { - const title = (value || '').trim() - if (!title) return '' - return item.year && item.year > 0 ? `${title} ${item.year}` : title - } - return [ - ...(item.subscribe_aliases || []), - item.title || '', - item.original_name || '', - withYear(item.title), - withYear(item.original_name), - item.subscribe_keyword || '', - ] -} - -export const subscriptionsAPI = { - list: () => - api - .get<{ items: Subscription[] }>('/subscriptions', subscriptionListRequestConfig()) - .then((r) => r.data.items), - - history: () => - api - .get<{ items: Subscription[] }>('/subscriptions/history', subscriptionListRequestConfig()) - .then((r) => r.data.items), - - create: (input: { - name: string - feed_url: string - filter?: string - media_type?: string - media_category?: string - save_path?: string - search_mode?: string - imdb_id?: string - source?: string - poster_url?: string - backdrop_url?: string - overview?: string - original_name?: string - year?: number - resolution?: string - quality?: string - effects?: string - release_groups?: string - exclude_words?: string - min_seeders?: number - max_seeders?: number - min_size_gb?: number - max_size_gb?: number - free_only?: boolean - wash_enabled?: boolean - wash_priority?: string - total_episodes?: number - priority?: number - enabled?: boolean - }) => - api.post('/subscriptions', input).then((r) => r.data), - - update: (id: string, input: Partial) => - api.put(`/subscriptions/${id}`, input).then((r) => r.data), - - remove: (id: string) => api.delete(`/subscriptions/${id}`).then((r) => r.data), - - restore: (id: string) => - api.post(`/subscriptions/${id}/restore`).then((r) => r.data), - - runNow: (id: string) => - api.post<{ queued: number }>(`/subscriptions/${id}/run`).then((r) => r.data), -} - -function subscriptionListRequestConfig() { - return { - headers: { 'Cache-Control': 'no-cache' }, - params: { _ts: Date.now() }, - } -} diff --git a/web/src/api/tasks.ts b/web/src/api/tasks.ts deleted file mode 100644 index 9a7600f..0000000 --- a/web/src/api/tasks.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { api } from './client' -import type { QBitTorrent } from '../types' - -export interface ActiveTranscode { - media_id: string - encoder: string - started_at: string - playlist_ok: boolean -} - -export type TaskItemStatus = 'pending' | 'running' | 'succeeded' | 'failed' - -export interface TaskItem { - id: string - kind: string // organize | scan | scrape - status: TaskItemStatus - name: string - source?: string - dest_path?: string - library_id?: string - error?: string -} - -export interface BackgroundTask { - id: string - kind: string - name: string - status: 'running' | 'completed' | 'failed' - stage?: string - source_path?: string - dest_path?: string - message?: string - error?: string - details?: string[] - metrics?: Record - items?: TaskItem[] - started_at: string - updated_at: string - finished_at?: string -} - -export interface BackgroundTaskSnapshot { - active: BackgroundTask[] - recent: BackgroundTask[] -} - -export interface TasksSnapshot { - transcodes: ActiveTranscode[] - torrents: QBitTorrent[] | null - background_tasks?: BackgroundTaskSnapshot -} - -export const tasksAPI = { - snapshot: () => api.get('/tasks').then((r) => r.data), -} diff --git a/web/src/appRoutes.tsx b/web/src/appRoutes.tsx index 5ce167e..331fe44 100644 --- a/web/src/appRoutes.tsx +++ b/web/src/appRoutes.tsx @@ -5,7 +5,6 @@ import { Navigate } from 'react-router-dom' 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(() => @@ -14,49 +13,19 @@ const PlaylistDetailPage = lazy(() => 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 }))) export type AppRoute = { path?: string @@ -69,38 +38,19 @@ export const appRoutes: AppRoute[] = [ { index: true, element: }, { path: 'libraries', element: }, { path: 'library/:id', element: }, - { path: 'discover', element: }, - { path: 'search', element: }, { path: 'favourites', element: }, { path: 'playlists', element: }, { path: 'playlist/:id', element: }, { path: 'media/:id', element: }, { path: 'play/:id', element: }, - { path: 'downloads', element: }, - { path: 'subscriptions', element: }, { path: 'profile', element: }, { path: 'dlna', element: }, { path: 'history', element: }, { path: 'poster-wall', element: }, - { path: 'site-search', element: }, - { path: 'ai', element: }, { path: 'play-profiles', element: }, - { path: 'api-configs', element: }, - { path: 'tools', element: }, - { path: 'sites', element: , adminOnly: true }, + { path: 'api-configs', element: }, + { path: 'tools', element: }, { path: 'files', element: , adminOnly: true }, - { path: 'storage', element: , adminOnly: true }, - { path: 'duplicates', element: , adminOnly: true }, - { path: 'scheduler', element: , adminOnly: true }, - { path: 'tasks', element: , adminOnly: true }, - { path: 'recycle', element: , adminOnly: true }, - { path: 'strm', element: , adminOnly: true }, - { path: 'notify-channels', element: , adminOnly: true }, { path: 'settings', element: , adminOnly: true }, - { path: 'assistant', element: , adminOnly: true }, - { path: 'download-clients', element: , adminOnly: true }, - { path: 'license', element: , adminOnly: true }, - { path: 'storage-config', element: , adminOnly: true }, - { path: 'stats', element: , adminOnly: true }, { path: 'admin', element: , adminOnly: true }, ] diff --git a/web/src/components/AppFooter.tsx b/web/src/components/AppFooter.tsx deleted file mode 100644 index 1d8150f..0000000 --- a/web/src/components/AppFooter.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useEffect, useState } from 'react' -import { Github, Globe, Send } from 'lucide-react' - -import { publicAPI } from '../api/public' -import { useAuthStore } from '../stores/auth' - -const LINKS = [ - { href: 'https://github.com/ShukeBta/MediaStationGo', icon: Github, label: '开源仓库' }, - { href: 'https://github.com/ShukeBta', icon: Globe, label: '作者主页' }, - { href: 'https://t.me/MediaStationGo', icon: Send, label: 'TG 群组' }, -] - -export function AppFooter({ className = '' }: { className?: string }) { - const role = useAuthStore((state) => state.user?.role) - const [hideForUsers, setHideForUsers] = useState(false) - - useEffect(() => { - publicAPI.uiConfig().then((config) => setHideForUsers(config.hide_community_links_for_users)).catch(() => undefined) - }, []) - - if (hideForUsers && role !== 'admin') return null - return ( -
- {LINKS.map((link, i) => ( -
- {i > 0 && } - - - {link.label} - - - ))} - - ) -} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 35ba82a..bcf0169 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -3,17 +3,36 @@ import { useLocation, useNavigate } from 'react-router-dom' import { useAuthStore } from '../stores/auth' import { usePlayProfileStore } from '../stores/playProfile' import { - LayoutFrameFooter, LayoutHeader, LayoutSidebars, LayoutWorkspace, } from './LayoutSections' -import { useLayoutSearch } from './useLayoutSearch' import { useLayoutPermissions } from './useLayoutPermissions' import { useLayoutProfiles } from './useLayoutProfiles' import { useLayoutSidebar } from './useLayoutSidebar' import { useThemeMode } from './useThemeMode' +function isMediaView(pathname: string, search: string): boolean { + const params = new URLSearchParams(search) + // 从设置/管理后台菜单进入(携带 from=admin 或 from=settings 或 manage=1)时展示左侧栏 + if (params.get('from') === 'admin' || params.get('from') === 'settings' || params.get('manage') === '1') { + return false + } + + return ( + pathname === '/' || + pathname === '/libraries' || + pathname.startsWith('/library') || + pathname.startsWith('/media') || + pathname.startsWith('/play') || + pathname === '/favourites' || + pathname === '/playlists' || + pathname.startsWith('/playlist') || + pathname === '/history' || + pathname === '/poster-wall' + ) +} + export function Layout() { const navigate = useNavigate() const location = useLocation() @@ -22,11 +41,6 @@ export function Layout() { const activeProfileId = usePlayProfileStore((s) => s.activeProfileId) const setActiveProfile = usePlayProfileStore((s) => s.setActiveProfile) const theme = useThemeMode() - const search = useLayoutSearch({ - pathname: location.pathname, - locationSearch: location.search, - navigate, - }) const permissions = useLayoutPermissions(user) const sidebar = useLayoutSidebar(location.pathname) const profile = useLayoutProfiles({ activeProfileId, setActiveProfile, user }) @@ -34,18 +48,19 @@ export function Layout() { const handleLogout = () => { logout(); navigate('/login') } const closeProfileAndLogout = () => { profile.setIsProfileOpen(false); handleLogout() } + const showSidebar = !isMediaView(location.pathname, location.search) + const hideSearch = location.pathname.startsWith('/settings') + return (
sidebar.setIsMobileDrawerOpen(true)} @@ -53,9 +68,10 @@ export function Layout() { activeProfileId={activeProfileId} profile={profile} onLogout={closeProfileAndLogout} + showSidebar={showSidebar} + hideSearch={hideSearch} /> -
) diff --git a/web/src/components/LayoutHeaderSections.tsx b/web/src/components/LayoutHeaderSections.tsx index fd39c8d..fd8f9bb 100644 --- a/web/src/components/LayoutHeaderSections.tsx +++ b/web/src/components/LayoutHeaderSections.tsx @@ -1,15 +1,16 @@ -import { Link } from 'react-router-dom' -import { Menu, MessageSquareText, Search, Sparkles } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { AnimatePresence, motion } from 'framer-motion' +import { Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react' -import type { PlayProfile, User } from '../types' -import { LayoutSearchBox } from './LayoutSearchBox' +import { imageURL } from '../api/client' +import { mediaAPI } from '../api/library' +import type { Media, PlayProfile, User } from '../types' import { LayoutThemeToggle } from './LayoutThemeToggle' import { LayoutUserMenu } from './LayoutUserMenu' import type { useLayoutProfiles } from './useLayoutProfiles' -import type { useLayoutSearch } from './useLayoutSearch' import type { ThemeMode, useThemeMode } from './useThemeMode' -type LayoutSearchState = ReturnType type LayoutProfileState = ReturnType type LayoutThemeState = ReturnType @@ -19,7 +20,6 @@ type LayoutPermissionState = { } type LayoutHeaderProps = { - search: LayoutSearchState permissions: LayoutPermissionState theme: LayoutThemeState onOpenMobileDrawer: () => void @@ -27,10 +27,11 @@ type LayoutHeaderProps = { activeProfileId: string | null profile: LayoutProfileState onLogout: () => void + showSidebar?: boolean + hideSearch?: boolean } export function LayoutHeader({ - search, permissions, theme, onOpenMobileDrawer, @@ -38,10 +39,43 @@ export function LayoutHeader({ activeProfileId, profile, onLogout, + showSidebar, + hideSearch, }: LayoutHeaderProps) { return ( -
- +
+ {/* Left: Mobile Menu button or Brand Logo */} +
+ + + MMTL + + MMTL + + +
+ + {/* Middle: Search Box */} +
+ {!hideSearch && } +
+ + {/* Right: Actions (Theme Toggle & User Menu) */} void -}) { +function LayoutHeaderSearch() { + const [query, setQuery] = useState('') + const [isOpen, setIsOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [results, setResults] = useState([]) + const containerRef = useRef(null) + const navigate = useNavigate() + + useEffect(() => { + const trimmed = query.trim() + if (!trimmed) { + setResults([]) + setLoading(false) + return + } + + setLoading(true) + const timer = setTimeout(async () => { + try { + const res = await mediaAPI.search(trimmed, 8) + setResults(res.items || []) + setIsOpen(true) + } catch { + setResults([]) + } finally { + setLoading(false) + } + }, 250) + + return () => clearTimeout(timer) + }, [query]) + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const handleSelect = (mediaId: string) => { + setIsOpen(false) + setQuery('') + navigate(`/media/${mediaId}`) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setIsOpen(false) + } else if (e.key === 'Enter' && results.length > 0) { + handleSelect(results[0].id) + } + } + return ( -
- - +
+
+ + { + setQuery(e.target.value) + if (e.target.value.trim()) setIsOpen(true) + }} + onFocus={() => { + if (results.length > 0) setIsOpen(true) + }} + onKeyDown={handleKeyDown} + placeholder="搜索电影、剧集、演员…" + className="w-full h-10 pl-10 pr-9 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]" + /> + {loading ? ( + + ) : query ? ( + + ) : null} +
+ + {/* Search Dropdown Results */} + + {isOpen && query.trim() && ( + + {results.length === 0 && !loading ? ( +
+ 未搜索到与 “{query}” 相关的媒体内容 +
+ ) : ( +
+ {results.map((item) => ( + + ))} +
+ )} +
+ )} +
) } @@ -108,7 +273,6 @@ type LayoutHeaderActionsProps = { } function LayoutHeaderActions({ - permissions, themeMode, onThemeChange, user, @@ -124,17 +288,16 @@ function LayoutHeaderActions({ }: LayoutHeaderActionsProps) { return (
- - ) } - -function LayoutQuickActions({ permissions }: { permissions: LayoutPermissionState }) { - return ( - <> - - - - {permissions.can('can_view_discover') && ( - - - 发现新片 - - )} - {permissions.isAdmin && ( - - - - )} - - ) -} - -function LayoutProfileMenu({ - user, - isProfileOpen, - profiles, - activeProfileId, - activeProfile, - onToggleProfile, - onCloseProfile, - onUseDefaultProfile, - onSwitchProfile, - onLogout, -}: Omit) { - return ( - - ) -} diff --git a/web/src/components/LayoutSearchBox.tsx b/web/src/components/LayoutSearchBox.tsx deleted file mode 100644 index 703029d..0000000 --- a/web/src/components/LayoutSearchBox.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { FormEvent } from 'react' -import { Link } from 'react-router-dom' -import { AnimatePresence, motion } from 'framer-motion' -import { Library as LibraryIcon, Search } from 'lucide-react' -import clsx from 'clsx' - -import { imageURL } from '../api/client' -import { seriesCardLink, type SeriesCard } from '../utils/groupSeries' - -type LayoutSearchBoxProps = { - query: string - focused: boolean - loading: boolean - error: string - cards: SeriesCard[] - total: number - onQueryChange: (value: string) => void - onFocusedChange: (focused: boolean) => void - onSubmit: (event: FormEvent) => void -} - -export function LayoutSearchBox({ - query, - focused, - loading, - error, - cards, - total, - onQueryChange, - onFocusedChange, - onSubmit, -}: LayoutSearchBoxProps) { - const trimmedQuery = query.trim() - - return ( -
- - - - onQueryChange(event.target.value)} - onMouseDown={() => onFocusedChange(true)} - onClick={() => onFocusedChange(true)} - onFocus={() => onFocusedChange(true)} - onBlur={() => window.setTimeout(() => onFocusedChange(false), 120)} - placeholder="搜索电影、电视剧、演员、种子站点..." - className="w-full rounded-full border border-[var(--app-border)] bg-[var(--app-control-bg)] py-2.5 pl-11 pr-12 text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] outline-none transition-all duration-300 focus:border-brand-500 focus:bg-[var(--app-panel)] focus:ring-4 focus:ring-brand-100/40" - /> -
- - Enter - -
- - {focused && trimmedQuery && ( - event.preventDefault()} - className="absolute left-0 right-0 top-full z-50 mt-3 overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl" - > -
- {loading && ( -
- - 搜索中... -
- )} - {!loading && error && ( -
{error}
- )} - {!loading && !error && cards.length === 0 && ( -
没有找到匹配的本地媒体
- )} - {!loading && !error && cards.length > 0 && ( -
- {cards.map((card) => ( - onFocusedChange(false)} - /> - ))} -
- )} -
- onFocusedChange(false)} - className="flex items-center justify-between border-t border-[var(--app-border)] px-4 py-3 text-sm font-semibold text-brand-500 hover:bg-[var(--app-hover)]" - > - 查看全部搜索结果 - - {total > 0 ? `${total} 个条目` : 'Enter'} - - -
- )} -
-
- ) -} - -function SearchResultItem({ card, onClick }: { card: SeriesCard; onClick: () => void }) { - return ( - -
- {card.rep.poster_url ? ( - {card.rep.title} - ) : ( -
- -
- )} -
-
-
- {card.rep.title || card.rep.original_name || '未命名媒体'} -
-
- {card.rep.year ? {card.rep.year} : null} - {card.count > 1 ? `${card.count} 集/条目` : '单条媒体'} - {card.rep.width ? {card.rep.width}x{card.rep.height} : null} -
-
- - ) -} diff --git a/web/src/components/LayoutSections.tsx b/web/src/components/LayoutSections.tsx index d369e9f..bc66fe7 100644 --- a/web/src/components/LayoutSections.tsx +++ b/web/src/components/LayoutSections.tsx @@ -2,7 +2,6 @@ import { Outlet } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' import clsx from 'clsx' -import { AppFooter } from './AppFooter' import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent' import { RouteErrorBoundary } from './RouteErrorBoundary' import type { useLayoutSidebar } from './useLayoutSidebar' @@ -22,9 +21,10 @@ type LayoutMobileSidebarProps = { type LayoutSidebarsProps = Omit< LayoutSidebarContentProps, - 'isSidebarOpen' | 'isMobileDrawerOpen' | 'openGroups' | 'isRouteIn' | 'onToggleGroup' | 'onToggleSidebar' | 'onCloseMobileDrawer' + 'isSidebarOpen' | 'isMobileDrawerOpen' | 'onToggleSidebar' | 'onCloseMobileDrawer' > & { sidebar: LayoutSidebarState + showSidebar: boolean } type LayoutWorkspaceProps = { @@ -76,26 +76,33 @@ export function LayoutMobileSidebar({ children, isOpen, onClose }: LayoutMobileS export function LayoutSidebars({ sidebar, isAdmin, - username, can, - onLogout, + showSidebar, }: LayoutSidebarsProps) { const content = ( sidebar.setIsSidebarOpen((current) => !current)} + onToggleSidebar={sidebar.toggleSidebar} onCloseMobileDrawer={() => sidebar.setIsMobileDrawerOpen(false)} - onLogout={onLogout} /> ) + if (!showSidebar) { + return ( + <> + sidebar.setIsMobileDrawerOpen(false)} + > + {content} + + + ) + } + return ( <> {content} @@ -131,10 +138,4 @@ export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) { ) } -export function LayoutFrameFooter() { - return ( - - ) -} - export { LayoutSidebarContent } diff --git a/web/src/components/LayoutSidebarContent.tsx b/web/src/components/LayoutSidebarContent.tsx index d127c0d..e0e9651 100644 --- a/web/src/components/LayoutSidebarContent.tsx +++ b/web/src/components/LayoutSidebarContent.tsx @@ -1,44 +1,29 @@ import { Link } from 'react-router-dom' import { motion } from 'framer-motion' -import { LogOut, Menu, X } from 'lucide-react' +import { ArrowLeft, Menu, X } from 'lucide-react' import clsx from 'clsx' -import { LAYOUT_NAV_GROUPS, NAV_GROUP_PATHS, type LayoutNavGroup, type LayoutNavItem } from './layoutNavigation' -import { SidebarGroup, SidebarLink } from './LayoutSidebarNav' +import { LAYOUT_NAV_ITEMS, type LayoutNavItem } from './layoutNavigation' +import { SidebarLink } from './LayoutSidebarNav' export type LayoutSidebarContentProps = { isSidebarOpen: boolean isMobileDrawerOpen: boolean - openGroups: Record isAdmin: boolean - username?: string can: (key: string) => boolean - isRouteIn: (paths: string[]) => boolean - onToggleGroup: (id: string) => void onToggleSidebar: () => void onCloseMobileDrawer: () => void - onLogout: () => void -} - -type VisibleLayoutNavGroup = { - group: LayoutNavGroup - items: LayoutNavItem[] } export function LayoutSidebarContent({ isSidebarOpen, isMobileDrawerOpen, - openGroups, isAdmin, - username, can, - isRouteIn, - onToggleGroup, onToggleSidebar, onCloseMobileDrawer, - onLogout, }: LayoutSidebarContentProps) { const sidebarExpanded = isSidebarOpen || isMobileDrawerOpen - const visibleGroups = visibleSidebarGroups({ isAdmin, can }) + const visibleItems = visibleSidebarItems({ isAdmin, can }) return (
@@ -47,28 +32,19 @@ export function LayoutSidebarContent({ onToggleSidebar={onToggleSidebar} onCloseMobileDrawer={onCloseMobileDrawer} /> - - + +
) } -function visibleSidebarGroups({ +function visibleSidebarItems({ isAdmin, can, -}: Pick): VisibleLayoutNavGroup[] { - const isItemVisible = (item: LayoutNavItem) => - (!item.adminOnly || isAdmin) && (!item.permission || can(item.permission)) - return LAYOUT_NAV_GROUPS - .filter((group) => !group.adminOnly || isAdmin) - .map((group) => ({ group, items: group.items.filter(isItemVisible) })) - .filter(({ items }) => items.length > 0) +}: Pick): LayoutNavItem[] { + return LAYOUT_NAV_ITEMS.filter( + (item) => (!item.adminOnly || isAdmin) && (!item.permission || can(item.permission)), + ) } function LayoutSidebarHeader({ @@ -84,8 +60,8 @@ function LayoutSidebarHeader({
MediaStationGo {sidebarExpanded && ( @@ -94,7 +70,7 @@ function LayoutSidebarHeader({ animate={{ opacity: 1, x: 0 }} className="font-display text-lg font-extrabold tracking-tight text-[var(--app-text)]" > - MediaStationGo + MMTL )} @@ -109,102 +85,47 @@ function LayoutSidebarHeader({ } function LayoutSidebarNav({ - groups, - sidebarExpanded, - openGroups, - isRouteIn, - onToggleGroup, -}: { - groups: VisibleLayoutNavGroup[] - sidebarExpanded: boolean - openGroups: Record - isRouteIn: (paths: string[]) => boolean - onToggleGroup: (id: string) => void -}) { - return ( - - ) -} - -function LayoutSidebarNavGroup({ - group, items, sidebarExpanded, - open, - active, - onToggleGroup, }: { - group: LayoutNavGroup items: LayoutNavItem[] sidebarExpanded: boolean - open: boolean - active: boolean - onToggleGroup: (id: string) => void }) { - const GroupIcon = group.icon return ( - } - label={group.label} - collapsed={!sidebarExpanded} - open={open} - active={active} - onToggle={onToggleGroup} - > + ) } -function LayoutSidebarLogout({ - sidebarExpanded, - username, - onLogout, -}: { - sidebarExpanded: boolean - username?: string - onLogout: () => void -}) { +function LayoutSidebarHomeBack({ sidebarExpanded }: { sidebarExpanded: boolean }) { return (
- + + {sidebarExpanded && 返回系统首页} +
) } @@ -229,4 +150,4 @@ function SidebarIconButton({ {children} ) -} +} \ No newline at end of file diff --git a/web/src/components/LayoutUserMenu.tsx b/web/src/components/LayoutUserMenu.tsx index 02830d7..01d525e 100644 --- a/web/src/components/LayoutUserMenu.tsx +++ b/web/src/components/LayoutUserMenu.tsx @@ -1,11 +1,9 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef } from 'react' import { Link, useLocation } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' -import { ChevronDown, Loader2, LogOut, RotateCw, Settings, User as UserIcon, UserCog } from 'lucide-react' +import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, UserCog } from 'lucide-react' import clsx from 'clsx' -import toast from 'react-hot-toast' -import { adminAPI } from '../api/admin' import type { PlayProfile } from '../types' type LayoutUser = { @@ -41,7 +39,6 @@ export function LayoutUserMenu({ const location = useLocation() const rootRef = useRef(null) const lastLocationRef = useRef(`${location.pathname}${location.search}`) - const [updating, setUpdating] = useState(false) useEffect(() => { if (!isOpen) return undefined @@ -71,22 +68,6 @@ export function LayoutUserMenu({ if (isOpen) onClose() }, [isOpen, location.pathname, location.search, onClose]) - const applySystemUpdate = async () => { - if (updating) return - setUpdating(true) - try { - const status = await adminAPI.systemUpdateApply() - toast.success(status.message || '系统更新任务已启动') - onClose() - } catch (err: unknown) { - const msg = - (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '启动系统更新失败' - toast.error(msg) - } finally { - setUpdating(false) - } - } - return (
- )} + } label="设置" onClick={onClose} /> + } label="我的收藏" onClick={onClose} /> + } label="播放列表" onClick={onClose} /> + } label="观看历史" onClick={onClose} /> + } label="DLNA投屏" onClick={onClose} />

diff --git a/web/src/components/MediaCard.tsx b/web/src/components/MediaCard.tsx index 1c368a7..f7a95f0 100644 --- a/web/src/components/MediaCard.tsx +++ b/web/src/components/MediaCard.tsx @@ -6,7 +6,7 @@ import { imageURL } from '../api/client' import type { Media } from '../types' export const MediaCard = ({ - media, progress, count, rating, linkTo, onClick, actions, + media, progress, count, rating, linkTo, onClick, actions, compact, }: { media: Media progress?: number @@ -15,6 +15,7 @@ export const MediaCard = ({ linkTo?: string onClick?: () => void actions?: ReactNode + compact?: boolean }) => { const ref = useRef(null) const href = linkTo ?? `/media/${media.id}` @@ -95,20 +96,26 @@ export const MediaCard = ({ )} {/* Premium Hover Overlay */} -

+
- - + + 立即观影 -

- {media.overview || "暂无简介内容"} -

+ {!compact && ( +

+ {media.overview || "暂无简介内容"} +

+ )}
@@ -124,8 +131,12 @@ export const MediaCard = ({
{/* Media Metadata Info */} -
-

+

+

{media.title}

diff --git a/web/src/components/RouteErrorBoundary.tsx b/web/src/components/RouteErrorBoundary.tsx index 27b0f78..3730322 100644 --- a/web/src/components/RouteErrorBoundary.tsx +++ b/web/src/components/RouteErrorBoundary.tsx @@ -43,7 +43,7 @@ export class RouteErrorBoundary extends Component<{ children: ReactNode }, { has } componentDidCatch(error: Error, info: ErrorInfo) { - console.error('MediaStationGo route crashed', error, info) + console.error('MMTL route crashed', error, info) } render() { diff --git a/web/src/components/layoutNavigation.ts b/web/src/components/layoutNavigation.ts index fc7ed36..5887c2d 100644 --- a/web/src/components/layoutNavigation.ts +++ b/web/src/components/layoutNavigation.ts @@ -1,29 +1,12 @@ import type { LucideIcon } from 'lucide-react' import { - Activity, - Cast, - Clock, - CloudDownload, - Compass, - FileText, - Globe, - HardDrive, - Heart, - Home, - Image, - KeySquare, + FolderOpen, Library, - ListMusic, - Rss, - Search, Settings, - Sliders, - Sparkles, User, + Users, } from 'lucide-react' -export type LayoutNavGroupID = 'media' | 'personal' | 'downloads' | 'tools' | 'system' - export type LayoutNavItem = { to: string label: string @@ -33,80 +16,10 @@ export type LayoutNavItem = { adminOnly?: boolean } -export type LayoutNavGroup = { - id: LayoutNavGroupID - label: string - icon: LucideIcon - activePaths: string[] - adminOnly?: boolean - items: LayoutNavItem[] -} - -export const LAYOUT_NAV_GROUPS: LayoutNavGroup[] = [ - { - id: 'media', - label: '媒体浏览', - icon: Home, - activePaths: ['/', '/libraries', '/library', '/poster-wall', '/discover', '/search', '/dlna', '/ai'], - items: [ - { to: '/', label: '系统首页', icon: Home, end: true }, - { to: '/libraries', label: '媒体库', icon: Library }, - { to: '/poster-wall', label: '海报墙', icon: Image }, - { to: '/discover', label: '精彩发现', icon: Compass, permission: 'can_view_discover' }, - { to: '/search', label: '智能搜索', icon: Search, permission: 'can_use_ai' }, - { to: '/dlna', label: 'DLNA 投屏', icon: Cast, permission: 'can_cast' }, - { to: '/ai', label: 'AI 助理', icon: Sparkles, permission: 'can_use_ai_assistant' }, - ], - }, - { - id: 'personal', - label: '个人观影', - icon: User, - activePaths: ['/favourites', '/playlists', '/playlist', '/history', '/profile', '/play-profiles'], - items: [ - { to: '/favourites', label: '我的收藏', icon: Heart }, - { to: '/playlists', label: '播放列表', icon: ListMusic }, - { to: '/history', label: '观看历史', icon: Clock }, - ], - }, - { - id: 'downloads', - label: '下载与订阅', - icon: CloudDownload, - activePaths: ['/downloads', '/download-clients', '/subscriptions', '/site-search', '/sites', '/tasks'], - items: [ - { to: '/downloads', label: '下载中心', icon: CloudDownload, permission: 'can_manage_downloads' }, - { to: '/subscriptions', label: '订阅管理', icon: Rss, permission: 'can_manage_subscriptions' }, - { to: '/sites', label: '站点管理', icon: Globe, permission: 'can_manage_sites' }, - { to: '/tasks', label: '实时任务', icon: Activity }, - ], - }, - { - id: 'tools', - label: '文件与自动化', - icon: HardDrive, - activePaths: ['/storage', '/storage-config', '/files', '/strm', '/duplicates', '/scheduler', '/recycle', '/stats'], - adminOnly: true, - items: [ - { to: '/storage', label: '存储与文件', icon: HardDrive }, - { to: '/strm', label: 'STRM 管理', icon: FileText }, - ], - }, - { - id: 'system', - label: '系统配置', - icon: Settings, - activePaths: ['/admin', '/sites', '/notify-channels', '/license', '/settings', '/assistant'], - adminOnly: true, - items: [ - { to: '/admin', label: '媒体与用户', icon: Settings }, - { to: '/settings', label: '系统设置', icon: Sliders }, - { to: '/license', label: '授权许可', icon: KeySquare }, - ], - }, -] - -export const NAV_GROUP_PATHS: Record = LAYOUT_NAV_GROUPS.reduce( - (paths, group) => ({ ...paths, [group.id]: group.activePaths }), - {} as Record, -) +export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [ + { to: '/profile', label: '个人资料', icon: User }, + { to: '/libraries?from=admin', label: '媒体库', icon: Library }, + { to: '/admin', label: '用户管理', icon: Users, adminOnly: true }, + { to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true }, + { to: '/settings', label: '系统设置', icon: Settings, adminOnly: true }, +] \ No newline at end of file diff --git a/web/src/components/manageLibrariesDialog.tsx b/web/src/components/manageLibrariesDialog.tsx new file mode 100644 index 0000000..3cc8de5 --- /dev/null +++ b/web/src/components/manageLibrariesDialog.tsx @@ -0,0 +1,51 @@ +import { createRoot } from 'react-dom/client' +import { X } from 'lucide-react' + +import { AdminLibraryPanel } from '../pages/AdminLibraryPanel' + +export function openManageLibrariesDialog(): Promise { + return new Promise((resolve) => { + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + + const close = () => { + root.unmount() + host.remove() + resolve() + } + + root.render() + }) +} + +function ManageLibrariesDialog({ onClose }: { onClose: () => void }) { + return ( +
+
event.stopPropagation()} + > +
+

管理媒体库

+ +
+
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/web/src/components/useLayoutSearch.ts b/web/src/components/useLayoutSearch.ts deleted file mode 100644 index 5cf0485..0000000 --- a/web/src/components/useLayoutSearch.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react' -import type { NavigateFunction } from 'react-router-dom' - -import { mediaAPI } from '../api/library' -import type { Media } from '../types' -import { groupSeries } from '../utils/groupSeries' - -type UseLayoutSearchOptions = { - pathname: string - locationSearch: string - navigate: NavigateFunction -} - -export function useLayoutSearch({ pathname, locationSearch, navigate }: UseLayoutSearchOptions) { - const [focused, setFocused] = useState(false) - const [query, setQuery] = useState('') - const [items, setItems] = useState([]) - const [loading, setLoading] = useState(false) - const [total, setTotal] = useState(0) - const [error, setError] = useState('') - const searchSeq = useRef(0) - const cards = useMemo(() => groupSeries(items).slice(0, 8), [items]) - - useEffect(() => { - if (pathname === '/search') { - setQuery(new URLSearchParams(locationSearch).get('q') ?? '') - } - }, [pathname, locationSearch]) - - useEffect(() => { - const trimmedQuery = query.trim() - const seq = ++searchSeq.current - if (!focused || !trimmedQuery) { - setItems([]) - setTotal(0) - setError('') - setLoading(false) - return - } - - setLoading(true) - setError('') - const timer = window.setTimeout(() => { - mediaAPI - .search(trimmedQuery, 24) - .then((data) => { - if (seq !== searchSeq.current) return - setItems(data.items ?? []) - setTotal(data.total ?? (data.items ?? []).length) - }) - .catch(() => { - if (seq !== searchSeq.current) return - setItems([]) - setTotal(0) - setError('搜索失败,请稍后再试') - }) - .finally(() => { - if (seq === searchSeq.current) setLoading(false) - }) - }, 220) - - return () => window.clearTimeout(timer) - }, [focused, query]) - - const submit = (event: FormEvent) => { - event.preventDefault() - const trimmedQuery = query.trim() - if (trimmedQuery) { - navigate(`/search?q=${encodeURIComponent(trimmedQuery)}`) - setFocused(false) - } - } - - return { - cards, - error, - focused, - loading, - query, - total, - setFocused, - setQuery, - submit, - } -} diff --git a/web/src/components/useLayoutSidebar.ts b/web/src/components/useLayoutSidebar.ts index b7e6587..d4043b2 100644 --- a/web/src/components/useLayoutSidebar.ts +++ b/web/src/components/useLayoutSidebar.ts @@ -1,11 +1,8 @@ import { useCallback, useEffect, useState } from 'react' -import { NAV_GROUP_PATHS } from './layoutNavigation' - export function useLayoutSidebar(pathname: string) { const [isSidebarOpen, setIsSidebarOpen] = useState(true) const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false) - const [openGroups, setOpenGroups] = useState>({ media: true }) useEffect(() => { const handleResize = () => { @@ -20,31 +17,15 @@ export function useLayoutSidebar(pathname: string) { setIsMobileDrawerOpen(false) }, [pathname]) - const isRouteIn = useCallback( - (paths: string[]) => - paths.some((path) => (path === '/' ? pathname === '/' : pathname.startsWith(path))), - [pathname], - ) - - const toggleGroup = useCallback( - (key: string) => setOpenGroups((current) => ({ ...current, [key]: !current[key] })), - [], - ) - - useEffect(() => { - const active = Object.entries(NAV_GROUP_PATHS).find(([, paths]) => isRouteIn(paths))?.[0] - if (active) { - setOpenGroups((current) => (current[active] ? current : { ...current, [active]: true })) - } - }, [isRouteIn]) + const toggleSidebar = useCallback(() => { + setIsSidebarOpen((current) => !current) + }, []) return { isMobileDrawerOpen, - isRouteIn, isSidebarOpen, - openGroups, setIsMobileDrawerOpen, setIsSidebarOpen, - toggleGroup, + toggleSidebar, } -} +} \ No newline at end of file diff --git a/web/src/pages/AIAssistantExternalResults.tsx b/web/src/pages/AIAssistantExternalResults.tsx deleted file mode 100644 index 1781615..0000000 --- a/web/src/pages/AIAssistantExternalResults.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useState } from 'react' - -import type { ExternalMediaResult } from '../api/ai' -import type { DiscoverItem } from '../api/discover' -import { imageURL } from '../api/client' -import { DiscoverDetailModal } from './DiscoverDetailModal' - -type AIAssistantExternalResultsProps = { - items: ExternalMediaResult[] -} - -export function AIAssistantExternalResults({ items }: AIAssistantExternalResultsProps) { - const [activeItem, setActiveItem] = useState(null) - - if (items.length === 0) return null - - return ( - <> -
- {items.map((item) => { - const keyword = item.subscribe_keyword || item.title - const key = `${item.source}:${keyword}` - return ( -
setActiveItem(item)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') setActiveItem(item) - }} - className="cursor-pointer rounded-2xl border border-gray-200 bg-gray-50 p-3 transition hover:-translate-y-0.5 hover:border-primary-300" - > -
-
- {item.poster_url ? ( - {item.title} - ) : null} -
-
-
- {item.source} - {item.media_type && {item.media_type}} - {item.year ? {item.year} : null} -
-

{item.title}

-

- {item.overview || `订阅关键词:${keyword}`} -

-

详情 / 订阅设置

-
-
-
- ) - })} -
- {activeItem && ( - setActiveItem(null)} - /> - )} - - ) -} diff --git a/web/src/pages/AIAssistantHeader.tsx b/web/src/pages/AIAssistantHeader.tsx deleted file mode 100644 index 0ee83f8..0000000 --- a/web/src/pages/AIAssistantHeader.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Sparkles } from 'lucide-react' - -export type AIAssistantStatus = { - enabled: boolean - provider: string - model: string -} - -type AIAssistantHeaderProps = { - status: AIAssistantStatus | null -} - -export function AIAssistantHeader({ status }: AIAssistantHeaderProps) { - return ( -
-
-
- -
-
-

AI 助手

-

自然语言搜索 · 基于观影历史的智能推荐

-
-
- {status && ( -
- - {status.enabled - ? `已连接 · ${status.provider}${status.model ? ' / ' + status.model : ''}` - : '未配置 AI 服务,使用本地规则解析'} -
- )} -
- ) -} diff --git a/web/src/pages/AIAssistantPage.tsx b/web/src/pages/AIAssistantPage.tsx deleted file mode 100644 index 391a675..0000000 --- a/web/src/pages/AIAssistantPage.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Link } from 'react-router-dom' - -import { AIAssistantHeader } from './AIAssistantHeader' -import { AIAssistantRecommendationsSection } from './AIAssistantRecommendationsSection' -import { AIAssistantSearchSection } from './AIAssistantSearchSection' -import { useAIAssistantPage } from './useAIAssistantPage' - -// AIAssistantPage exposes the two AI helpers backed by the Go server: -// - smart search: parses a natural-language query into a SearchIntent + -// a list of matching local media items. -// - recommendations: returns a list of recommended titles based on the -// current user's recent watch history. -// -// The Vue version had a full chat surface; the Go backend has no chat or -// operation-execute endpoints, so we render the same two capabilities as -// a focused two-panel screen. -export function AIAssistantPage() { - const assistant = useAIAssistantPage() - - return ( -
- - - - - - - {/* Decorative footer (mirrors the Vue page hint that AI runs locally). */} - {!assistant.status?.enabled && ( -

- 提示: 当前未配置外部 AI Provider,系统将使用本地规则引擎解析查询。 - 管理员可在 API 配置{' '} - 中接入 OpenAI / DeepSeek 等服务以获得更好效果。 -

- )} -
- ) -} diff --git a/web/src/pages/AIAssistantRecommendationsSection.tsx b/web/src/pages/AIAssistantRecommendationsSection.tsx deleted file mode 100644 index 39536bd..0000000 --- a/web/src/pages/AIAssistantRecommendationsSection.tsx +++ /dev/null @@ -1,47 +0,0 @@ -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 && recs.length > 0 && ( -
    - {recs.map((title, index) => ( -
  • - - {title} - - -
  • - ))} -
- )} - - {recs && recs.length === 0 && ( -

还没有推荐结果 — 先去看几部片子,我再给你挑。

- )} -
- ) -} diff --git a/web/src/pages/AIAssistantSearchSection.tsx b/web/src/pages/AIAssistantSearchSection.tsx deleted file mode 100644 index badfc9e..0000000 --- a/web/src/pages/AIAssistantSearchSection.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import type { FormEvent } from 'react' -import { Loader2, Search } from 'lucide-react' - -import type { ExternalMediaResult, SearchIntent } from '../api/ai' -import { MediaCard } from '../components/MediaCard' -import type { Media } from '../types' -import type { SeriesCard } from '../utils/groupSeries' -import { seriesCardLink } from '../utils/groupSeries' -import { AIAssistantExternalResults } from './AIAssistantExternalResults' - -type AIAssistantSearchSectionProps = { - query: string - searching: boolean - intent: SearchIntent | null - items: Media[] - localCards: SeriesCard[] - externalItems: ExternalMediaResult[] - onSearch: (event: FormEvent) => void - setQuery: (query: string) => void -} - -const quickHints = [ - '2023 年的科幻电影', - '评分高的动漫', - '最近添加的纪录片', - '中文剧集', -] - -export function AIAssistantSearchSection({ - query, - searching, - intent, - items, - localCards, - externalItems, - onSearch, - setQuery, -}: AIAssistantSearchSectionProps) { - return ( -
-

智能搜索

-
- setQuery(e.target.value)} - /> - -
- -
- {quickHints.map((hint) => ( - - ))} -
- - - - -
- ) -} - -function SearchIntentSummary({ intent }: { intent: SearchIntent | null }) { - if (!intent) return null - return ( -
-
解析结果
-
- - 查询: {intent.query || '—'} - - {intent.year !== undefined && intent.year > 0 && ( - - 年份: {intent.year} - - )} - {intent.genre && ( - - 类型: {intent.genre} - - )} - {intent.type && ( - - 分类: {intent.type} - - )} - {intent.sort && ( - - 排序: {intent.sort} - - )} - {intent.language && ( - - 语言: {intent.language} - - )} -
-
- ) -} - -function LocalMediaResults({ - localCards, - itemCount, -}: { - localCards: SeriesCard[] - itemCount: number -}) { - if (localCards.length === 0) return null - return ( -
-
- 本地媒体库 · {localCards.length} 个合集 / {itemCount} 个条目 -
-
- {localCards.map((card) => ( - - ))} -
-
- ) -} diff --git a/web/src/pages/AdminLibraryPanel.tsx b/web/src/pages/AdminLibraryPanel.tsx index b59107e..195b68b 100644 --- a/web/src/pages/AdminLibraryPanel.tsx +++ b/web/src/pages/AdminLibraryPanel.tsx @@ -1,12 +1,13 @@ import { AdminLibraryCreateForm } from './AdminLibraryPanelSections' import { AdminLibraryTable } from './AdminLibraryTable' +import { LibraryStorageStats } from './LibraryStorageStats' import { useAdminLibraryPanel } from './useAdminLibraryPanel' export function AdminLibraryPanel() { const { libs, createForm, editableRoots, rootActions, libraryActions } = useAdminLibraryPanel() return ( -
+
+
) } diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx index d4dc0c2..90fa3e1 100644 --- a/web/src/pages/AdminPage.tsx +++ b/web/src/pages/AdminPage.tsx @@ -1,71 +1,21 @@ -import { useEffect, useState } from 'react' -import { useSearchParams } from 'react-router-dom' +import { Users } from 'lucide-react' -import { APIConfigsPanel } from '../components/APIConfigsPanel' -import { ManagementShortcuts } from '../components/ManagementShortcuts' -import { AdminLibraryPanel } from './AdminLibraryPanel' import { AdminUsersPanel } from './AdminUsersPanel' -type AdminTab = 'library' | 'users' | 'api' - -function parseAdminTab(value: string | null): AdminTab { - if (value === 'users' || value === 'api') return value - return 'library' -} - export function AdminPage() { - const [searchParams, setSearchParams] = useSearchParams() - const [tab, setTab] = useState(() => parseAdminTab(searchParams.get('tab'))) - const tabs = [ - { key: 'library' as const, label: '媒体库' }, - { key: 'users' as const, label: '用户' }, - { key: 'api' as const, label: '外部API' }, - ] - - useEffect(() => { - setTab(parseAdminTab(searchParams.get('tab'))) - }, [searchParams]) - - const selectTab = (next: AdminTab) => { - setTab(next) - setSearchParams(next === 'library' ? {} : { tab: next }) - } - return (
-

管理后台

- -
- {tabs.map((k) => ( - - ))} -
+
+ +
+

用户管理

+

+ 管理系统用户账号、角色权限以及密码重置与启禁用操作。 +

+
+
- {tab === 'library' && } - {tab === 'users' && } - {tab === 'api' && } +
) } diff --git a/web/src/pages/AdminUsersPanel.tsx b/web/src/pages/AdminUsersPanel.tsx index 6c426f8..ec1342c 100644 --- a/web/src/pages/AdminUsersPanel.tsx +++ b/web/src/pages/AdminUsersPanel.tsx @@ -2,7 +2,6 @@ import { FormEvent, useEffect, useState } from 'react' import toast from 'react-hot-toast' import { adminAPI } from '../api/admin' -import { licenseAPI, type LicenseStatus } from '../api/license' import type { User } from '../types' import { confirmAction } from '../components/confirmAction' import { requestPassword } from '../components/requestPassword' @@ -11,19 +10,13 @@ import { AdminUsersTable } from './AdminUsersTable' export function AdminUsersPanel() { const [users, setUsers] = useState([]) - const [licenseStatus, setLicenseStatus] = useState(null) const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [editingID, setEditingID] = useState(null) const [editingUsername, setEditingUsername] = useState('') const [resettingPasswordID, setResettingPasswordID] = useState(null) const refresh = async () => { - const [nextUsers, nextLicense] = await Promise.all([ - adminAPI.listUsers(), - licenseAPI.status().catch(() => null), - ]) - setUsers(nextUsers) - setLicenseStatus(nextLicense) + setUsers(await adminAPI.listUsers()) } useEffect(() => { refresh().catch(() => undefined) @@ -31,12 +24,9 @@ export function AdminUsersPanel() { return () => window.clearInterval(timer) }, []) - const unlimitedUsers = - licenseStatus?.active === true && - (licenseStatus.unlimited_users === true || licenseStatus.max_users == null) - const maxUsers = unlimitedUsers ? null : (licenseStatus?.max_users ?? 20) - const userLimitReached = maxUsers != null && users.length >= maxUsers - const userLimitLabel = unlimitedUsers ? '不限制' : String(maxUsers) + const maxUsers = 20 + const userLimitReached = users.length >= maxUsers + const userLimitLabel = String(maxUsers) const handleCreate = async (e: FormEvent) => { e.preventDefault() diff --git a/web/src/pages/AssistantChatPage.tsx b/web/src/pages/AssistantChatPage.tsx deleted file mode 100644 index 8dbfdc1..0000000 --- a/web/src/pages/AssistantChatPage.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { FormEvent, useEffect, useRef, useState } from 'react' -import toast from 'react-hot-toast' - -import { - assistantAPI, - type AssistantMessage, - type AssistantSession, - type SessionView, -} from '../api/assistant' -import { confirmAction } from '../components/confirmAction' -import { AssistantConversationPane, AssistantSessionsSidebar } from './AssistantChatSections' - -// AssistantChatPage is the multi-turn chat surface backed by the Go -// AssistantService. It complements the older AIAssistantPage which is -// limited to single-turn smart search + recommendations. -export function AssistantChatPage() { - const [sessions, setSessions] = useState([]) - const [active, setActive] = useState(null) - const [draft, setDraft] = useState('') - const [sending, setSending] = useState(false) - const [loading, setLoading] = useState(true) - const messagesEndRef = useRef(null) - - const refreshSessions = async () => { - try { - const list = await assistantAPI.listSessions() - setSessions(list) - // Auto-select the most recent if nothing is open. - if (list.length > 0 && !active) { - const view = await assistantAPI.getSession(list[0].id) - setActive(view) - } - } finally { - setLoading(false) - } - } - - useEffect(() => { - refreshSessions().catch(() => undefined) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [active?.messages.length]) - - const onNew = async () => { - try { - const sess = await assistantAPI.createSession('New chat') - const view = await assistantAPI.getSession(sess.id) - setActive(view) - await refreshSessions() - } catch { - toast.error('创建会话失败') - } - } - - const onSelect = async (id: string) => { - try { - setActive(await assistantAPI.getSession(id)) - } catch { - toast.error('加载会话失败') - } - } - - const onDelete = async (id: string) => { - if (!(await confirmAction({ title: '删除会话', message: '删除此会话?', confirmText: '删除' }))) return - try { - await assistantAPI.deleteSession(id) - if (active?.session.id === id) setActive(null) - await refreshSessions() - } catch { - toast.error('删除失败') - } - } - - const onSend = async (e: FormEvent) => { - e.preventDefault() - if (!draft.trim() || !active) return - setSending(true) - const text = draft.trim() - setDraft('') - // Optimistic append so the user's turn shows immediately. - setActive((s) => - s - ? { - ...s, - messages: [ - ...s.messages, - { - id: 'pending-' + Date.now(), - session_id: s.session.id, - role: 'user', - content: text, - created_at: new Date().toISOString(), - } as AssistantMessage, - ], - } - : s, - ) - try { - const view = await assistantAPI.chat(active.session.id, text) - setActive(view) - } catch (err: unknown) { - const msg = - (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? - '发送失败' - toast.error(msg) - } finally { - setSending(false) - } - } - - return ( -
- - -
- ) -} diff --git a/web/src/pages/AssistantChatSections.tsx b/web/src/pages/AssistantChatSections.tsx deleted file mode 100644 index bb88e60..0000000 --- a/web/src/pages/AssistantChatSections.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import type { FormEvent, RefObject } from 'react' -import { Loader2, MessageSquare, Plus, Send, Trash2 } from 'lucide-react' - -import type { AssistantMessage, AssistantSession, SessionView } from '../api/assistant' - -export function AssistantSessionsSidebar({ - sessions, - activeSessionId, - loading, - onNew, - onSelect, - onDelete, -}: { - sessions: AssistantSession[] - activeSessionId?: string - loading: boolean - onNew: () => void - onSelect: (id: string) => void - onDelete: (id: string) => void -}) { - return ( - - ) -} - -export function AssistantConversationPane({ - active, - draft, - sending, - messagesEndRef, - onDraftChange, - onSend, -}: { - active: SessionView | null - draft: string - sending: boolean - messagesEndRef: RefObject - onDraftChange: (value: string) => void - onSend: (event: FormEvent) => void -}) { - return ( -
- {!active && ( -
- -

选择或创建一个会话开始对话

-
- )} - {active && ( - <> -
-

- {active.session.title || '未命名'} -

-
-
- {active.messages.length === 0 && ( -

说点什么开始对话…

- )} - {active.messages.map((message) => ( - - ))} -
-
-
- onDraftChange(event.target.value)} - disabled={sending} - /> - -
- - )} -
- ) -} - -function Bubble({ message }: { message: AssistantMessage }) { - const mine = message.role === 'user' - return ( -
-
- {message.content} -
-
- ) -} diff --git a/web/src/pages/CloudBrowser.tsx b/web/src/pages/CloudBrowser.tsx deleted file mode 100644 index fdad1e8..0000000 --- a/web/src/pages/CloudBrowser.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type { StorageType } from '../api/storage_config' -import { CloudBrowserToolbar } from './CloudBrowserToolbar' -import { CloudEntryList } from './CloudEntryList' -import { CloudMountList } from './CloudMountList' -import { CloudScanPanel } from './CloudScanPanel' -import { useCloudBrowser } from './useCloudBrowser' - -// Lists cloud directories and imports a file as a 302-backed media. -export function CloudBrowser({ type, enabled = true }: { type: StorageType; enabled?: boolean }) { - const browser = useCloudBrowser(type, enabled) - - return ( -
- - - - -
- ) -} diff --git a/web/src/pages/CloudBrowserToolbar.tsx b/web/src/pages/CloudBrowserToolbar.tsx deleted file mode 100644 index 3a3c77f..0000000 --- a/web/src/pages/CloudBrowserToolbar.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { ArrowUp, FolderPlus } from 'lucide-react' - -interface CloudBrowserToolbarProps { - stack: { id: string; name: string }[] - mountMediaType: string - mounting: boolean - batchMounting: boolean - loading: boolean - hasDirectories: boolean - onGoTo: (index: number) => void - onGoUp: () => void - onCreateFolder: () => void - onMediaTypeChange: (value: string) => void - onMountCurrent: () => void - onMountVisibleDirectories: () => void -} - -export function CloudBrowserToolbar({ - stack, - mountMediaType, - mounting, - batchMounting, - loading, - hasDirectories, - onGoTo, - onGoUp, - onCreateFolder, - onMediaTypeChange, - onMountCurrent, - onMountVisibleDirectories, -}: CloudBrowserToolbarProps) { - return ( -
-
- 网盘资源: - {stack.map((item, index) => ( - - - {index < stack.length - 1 && /} - - ))} -
-

- 挂载后不会复制网盘文件;后台会递归读取该目录里的子文件夹和媒体文件,扫描到的影片会自动加入对应媒体库。小目录通常几十秒,大目录取决于网盘接口速度。 - 如果已有同名同类型媒体库,会在首页和 Emby/SenPlayer 中自动归并显示。 -

-
- - - - - -
-
- ) -} diff --git a/web/src/pages/CloudEntryList.tsx b/web/src/pages/CloudEntryList.tsx deleted file mode 100644 index 8dd35dc..0000000 --- a/web/src/pages/CloudEntryList.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { FileVideo, Folder, Loader2, Pencil } from 'lucide-react' - -import type { CloudEntry } from '../api/storage_config' - -interface CloudEntryListProps { - loading: boolean - error: string - items: CloudEntry[] - onEnter: (entry: CloudEntry) => void - onImport: (entry: CloudEntry) => void - onRename: (entry: CloudEntry) => void -} - -export function CloudEntryList({ loading, error, items, onEnter, onImport, onRename }: CloudEntryListProps) { - if (loading) { - return ( -
- -
- ) - } - - if (error) return

{error}

- if (items.length === 0) return

该目录为空

- - return ( -
    - {items.map((entry) => ( -
  • - {entry.is_dir ? : } - {entry.is_dir ? ( - <> - - - - ) : ( - <> - {entry.name} - - - )} -
  • - ))} -
- ) -} diff --git a/web/src/pages/CloudMountList.tsx b/web/src/pages/CloudMountList.tsx deleted file mode 100644 index c99ee2d..0000000 --- a/web/src/pages/CloudMountList.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Trash2 } from 'lucide-react' - -import type { Library } from '../types' -import { cloudLibraryLabel } from './storageConfigModel' - -interface CloudMountListProps { - mounts: Library[] - onRemove: (library: Library) => void -} - -export function CloudMountList({ mounts, onRemove }: CloudMountListProps) { - if (mounts.length === 0) return null - - return ( -
-
已挂载目录
-
- {mounts.map((lib) => ( -
- - {lib.name} · {cloudLibraryLabel(lib.path)} - - -
- ))} -
-
- ) -} diff --git a/web/src/pages/CloudScanPanel.tsx b/web/src/pages/CloudScanPanel.tsx deleted file mode 100644 index 1258386..0000000 --- a/web/src/pages/CloudScanPanel.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Loader2, PauseCircle, RefreshCw } from 'lucide-react' - -import type { CloudScanStatus } from '../api/storage_config' - -interface CloudScanPanelProps { - scanBusy: boolean - cancelBusy: boolean - scanStatuses: CloudScanStatus[] - onScanAll: () => void - onCancelScans: () => void -} - -export function CloudScanPanel({ - scanBusy, - cancelBusy, - scanStatuses, - onScanAll, - onCancelScans, -}: CloudScanPanelProps) { - return ( -
-
-
-
网盘媒体库扫描
-

- 只需在系统设置填写公开域名,扫描会自动为网盘媒体生成 STRM/302 播放入口;中断后再次扫描会去重补齐。 -

-
-
- - -
-
- {scanStatuses.length > 0 && ( -
- {scanStatuses.slice(0, 6).map((item) => ( -
- {item.state} - {' · '} - {item.provider} - {' · 目录 '} - {item.dirs} - {' · 发现 '} - {item.discovered} - {' · 入库 '} - {item.added + item.updated} - {item.error ? · {item.error} : null} -
- ))} -
- )} -
- ) -} diff --git a/web/src/pages/DiscoverContentRow.tsx b/web/src/pages/DiscoverContentRow.tsx deleted file mode 100644 index b15f2d3..0000000 --- a/web/src/pages/DiscoverContentRow.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import { ChevronLeft, ChevronRight, Info } from 'lucide-react' - -import type { DiscoverItem } from '../api/discover' -import { imageURL } from '../api/client' -import { discoverItemSource } from './discoverPageModel' - -export function ContentRow({ - title, - items, - page = 1, - canNext = false, - imageVersion, - refreshImageVersion, - onPageChange, - onSelect, -}: { - title: string - items: DiscoverItem[] - page?: number - canNext?: boolean - imageVersion?: string - refreshImageVersion?: string - onPageChange?: (delta: number) => void - onSelect: (item: DiscoverItem) => void -}) { - return ( -
-
-

{title}

- {onPageChange && ( -
- - 第 {page} 页 - -
- )} -
-
- {items.map((item, index) => ( - - ))} -
-
- ) -} - -export function DiscoverSkeleton() { - return ( -
- {[1, 2, 3].map((section) => ( -
-
-
- {[1, 2, 3, 4, 5, 6, 7, 8].map((item) => ( -
- ))} -
-
- ))} -
- ) -} - -function DiscoverCard({ - item, - imageVersion, - refreshImageVersion, - onSelect, -}: { - item: DiscoverItem - imageVersion?: string - refreshImageVersion?: string - onSelect: (item: DiscoverItem) => void -}) { - const source = discoverItemSource(item) - const imageCandidates = useMemo( - () => - [item.poster_url, item.backdrop_url] - .map((value) => value?.trim()) - .filter((value, index, values): value is string => Boolean(value) && values.indexOf(value) === index), - [item.poster_url, item.backdrop_url], - ) - const [imageIndex, setImageIndex] = useState(0) - const [posterRetry, setPosterRetry] = useState(0) - const [posterUnavailable, setPosterUnavailable] = useState(false) - const posterVersion = [imageVersion, posterRetry > 0 ? `r${posterRetry}` : ''].filter(Boolean).join('-') - const activeImage = imageCandidates[imageIndex] ?? '' - const shouldRefreshCache = Boolean( - (imageVersion && refreshImageVersion === imageVersion) || posterRetry > 0, - ) - const posterSrc = useMemo( - () => - imageURL(activeImage, posterVersion, { - refreshCache: shouldRefreshCache, - retryFailed: true, - }), - [activeImage, posterVersion, shouldRefreshCache], - ) - - useEffect(() => { - setImageIndex(0) - setPosterRetry(0) - setPosterUnavailable(false) - }, [item.poster_url, item.backdrop_url, imageVersion]) - - useEffect(() => { - if (!posterUnavailable) return - if (imageIndex + 1 < imageCandidates.length) { - const timer = window.setTimeout(() => { - setImageIndex((current) => Math.min(current + 1, imageCandidates.length - 1)) - setPosterRetry(0) - setPosterUnavailable(false) - }, 150) - return () => window.clearTimeout(timer) - } - if (posterRetry >= 3) return - const timer = window.setTimeout(() => { - setPosterRetry((current) => current + 1) - setPosterUnavailable(false) - }, 1200 * (posterRetry + 1)) - return () => window.clearTimeout(timer) - }, [imageCandidates.length, imageIndex, posterRetry, posterUnavailable]) - - const markPosterUnavailable = () => setPosterUnavailable(true) - - if (!posterSrc || posterUnavailable) return null - - return ( - - ) -} - -function discoverKey(item: DiscoverItem, index: number): string { - return `${item.source || 'source'}:${item.tmdb_id || item.douban_id || item.bangumi_id || item.title}:${index}` -} diff --git a/web/src/pages/DiscoverDetailModal.tsx b/web/src/pages/DiscoverDetailModal.tsx deleted file mode 100644 index fc29f62..0000000 --- a/web/src/pages/DiscoverDetailModal.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useState } from 'react' -import toast from 'react-hot-toast' - -import type { DiscoverItem } from '../api/discover' -import { subscriptionsAPI } from '../api/subscriptions' -import { discoverItemSource } from './discoverPageModel' -import { - DiscoverArtworkPanel, - DiscoverModalHeader, - DiscoverOverviewPanel, - DiscoverSubscriptionRules, -} from './DiscoverDetailModalSections' -import { - apiErrorMessage, - buildDiscoverSubscriptionInput, - initialDiscoverSubscriptionForm, -} from './discoverDetailModalModel' - -export function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: () => void }) { - const source = discoverItemSource(item) - const [form, setForm] = useState(() => initialDiscoverSubscriptionForm(item)) - const [busy, setBusy] = useState(false) - - const submit = async () => { - setBusy(true) - try { - const sub = await subscriptionsAPI.create(buildDiscoverSubscriptionInput(item, form, source)) - if (form.run_now) { - const run = await subscriptionsAPI.runNow(sub.id) - toast.success(run.queued > 0 ? `已订阅并加入 ${run.queued} 个下载` : '已订阅,暂未命中可下载资源') - } else { - toast.success('已创建订阅') - } - onClose() - } catch (err) { - toast.error(apiErrorMessage(err, '订阅失败')) - } finally { - setBusy(false) - } - } - - return ( -
-
- -
- -
- - setForm((current) => ({ ...current, ...patch }))} - onSubmit={submit} - /> -
-
-
-
- ) -} diff --git a/web/src/pages/DiscoverDetailModalSections.tsx b/web/src/pages/DiscoverDetailModalSections.tsx deleted file mode 100644 index 6221828..0000000 --- a/web/src/pages/DiscoverDetailModalSections.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { Download, Rss, X } from 'lucide-react' - -import type { DiscoverItem } from '../api/discover' -import { imageURL } from '../api/client' -import { discoverItemMetaText, type DiscoverSubscriptionForm } from './discoverDetailModalModel' - -type FormPatch = Partial - -type FieldOption = { - label: string - value: string -} - -const searchModeOptions: FieldOption[] = [ - { value: 'keyword', label: '标题关键词' }, - { value: 'imdb', label: 'IMDB ID' }, -] - -const mediaTypeOptions: FieldOption[] = [ - { value: '', label: '自动识别' }, - { value: 'movie', label: '电影' }, - { value: 'tv', label: '电视剧' }, - { value: 'anime', label: '动漫' }, - { value: 'variety', label: '综艺' }, -] - -const resolutionOptions: FieldOption[] = [ - { value: 'best', label: '自动择优' }, - { value: '2160p', label: '2160p / 4K' }, - { value: '1080p', label: '1080p' }, - { value: '720p', label: '720p' }, -] - -const qualityOptions: FieldOption[] = [ - { value: '', label: '不限' }, - { value: 'remux', label: 'REMUX' }, - { value: 'bluray', label: 'BluRay' }, - { value: 'web-dl', label: 'WEB-DL' }, - { value: 'hdtv', label: 'HDTV' }, -] - -const washPriorityOptions: FieldOption[] = [ - { value: 'balanced', label: '均衡' }, - { value: 'resolution', label: '分辨率优先' }, - { value: 'quality', label: '片源质量优先' }, - { value: 'effects', label: 'HDR/DV/Atmos 优先' }, - { value: 'seeders', label: '做种数优先' }, -] - -export function DiscoverModalHeader({ item, source, onClose }: { item: DiscoverItem; source: string; onClose: () => void }) { - return ( -
-
-

{source}

-

{item.title}

-

{discoverItemMetaText(item)}

-
- -
- ) -} - -export function DiscoverArtworkPanel({ item }: { item: DiscoverItem }) { - return ( -
-
- {item.poster_url ? ( - {item.title} - ) : ( -
无海报
- )} -
- {item.backdrop_url && ( - - )} -
- ) -} - -export function DiscoverOverviewPanel({ overview }: { overview?: string }) { - return ( -
-

简介

-

{overview || '当前数据源没有返回简介。'}

-
- ) -} - -export function DiscoverSubscriptionRules({ - form, - busy, - onChange, - onSubmit, -}: { - form: DiscoverSubscriptionForm - busy: boolean - onChange: (patch: FormPatch) => void - onSubmit: () => void -}) { - return ( -
-

- - 订阅下载规则 -

-
- onChange({ keyword })} className="md:col-span-2" /> - onChange({ search_mode })} /> - onChange({ imdb_id })} /> - onChange({ media_type })} /> - onChange({ resolution })} /> - onChange({ quality })} /> - onChange({ effects })} /> - onChange({ wash_priority })} /> - onChange({ wash_enabled })} /> - onChange({ release_groups })} /> - onChange({ exclude_words })} /> - onChange({ media_category })} /> - onChange({ save_path })} /> -
- onChange({ run_now })} onSubmit={onSubmit} /> -
- ) -} - -function TextField({ label, value, placeholder, className = '', onChange }: { label: string; value: string; placeholder?: string; className?: string; onChange: (value: string) => void }) { - return ( - - ) -} - -function SelectField({ label, value, options, disabled, onChange }: { label: string; value: string; options: FieldOption[]; disabled?: boolean; onChange: (value: string) => void }) { - return ( - - ) -} - -function CheckboxField({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) { - return ( - - ) -} - -function DiscoverSubmitBar({ busy, runNow, onRunNowChange, onSubmit }: { busy: boolean; runNow: boolean; onRunNowChange: (value: boolean) => void; onSubmit: () => void }) { - return ( -
- - -
- ) -} diff --git a/web/src/pages/DiscoverPage.tsx b/web/src/pages/DiscoverPage.tsx deleted file mode 100644 index 60d9821..0000000 --- a/web/src/pages/DiscoverPage.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' - -import { discoverAPI, type DiscoverItem, type DiscoverSection } from '../api/discover' -import { DiscoverSkeleton } from './DiscoverContentRow' -import { DiscoverDetailModal } from './DiscoverDetailModal' -import { DiscoverEmptySelection, DiscoverHeader, DiscoverResults } from './DiscoverPageSections' -import { - defaultSections, - discoverStorageKey, - readCachedDiscoverRows, - readSavedSections, - serializeSavedSections, - writeCachedDiscoverRow, -} from './discoverPageModel' - -export function DiscoverPage() { - const [sections, setSections] = useState([]) - const [selected, setSelected] = useState([]) - const [rows, setRows] = useState>({}) - const [rowPages, setRowPages] = useState>({}) - const [rowCanNext, setRowCanNext] = useState>({}) - const [rowLoading, setRowLoading] = useState>({}) - const [rowErrors, setRowErrors] = useState>({}) - const [sectionsReady, setSectionsReady] = useState(false) - const [loading, setLoading] = useState(false) - const [activeItem, setActiveItem] = useState(null) - const [reloadSeq, setReloadSeq] = useState(0) - const [imageVersion, setImageVersion] = useState(() => String(Date.now())) - const [refreshImageVersion, setRefreshImageVersion] = useState() - - useEffect(() => { - let cancelled = false - setSectionsReady(false) - discoverAPI - .sections() - .then((items) => { - if (cancelled) return - setSections(items) - const saved = readSavedSections(items) - const available = new Set(items.map((item) => item.key)) - const fallback = defaultSections.filter((key) => available.has(key)) - const nextSelected = saved.length > 0 ? saved : fallback - const cached = readCachedDiscoverRows(nextSelected) - setSelected(nextSelected) - setRowPages(Object.fromEntries(nextSelected.map((key) => [key, 1]))) - setRows(cached.rows) - setRowCanNext(cached.rowCanNext) - setSectionsReady(true) - }) - .catch(() => { - if (cancelled) return - setSections([]) - setSelected([]) - setSectionsReady(true) - }) - return () => { - cancelled = true - } - }, []) - - useEffect(() => { - if (!sectionsReady) return - const available = new Set(sections.map((section) => section.key)) - const activeSelected = selected.filter((key) => available.has(key)) - if (activeSelected.length !== selected.length) { - setSelected(activeSelected) - return - } - if (selected.length === 0) { - setRows({}) - setRowLoading({}) - setRowCanNext({}) - setRowErrors({}) - setLoading(false) - return - } - let cancelled = false - setLoading(true) - setRowErrors({}) - setRowLoading(Object.fromEntries(selected.map((key) => [key, true]))) - setRows((current) => { - const next: Record = {} - for (const key of selected) { - next[key] = current[key] ?? [] - } - return next - }) - window.localStorage.setItem(discoverStorageKey, serializeSavedSections(selected)) - - let pending = selected.length - const markDone = () => { - pending -= 1 - if (!cancelled && pending <= 0) setLoading(false) - } - for (const key of selected) { - const page = rowPages[key] ?? 1 - discoverAPI - .feed([key], page) - .then((feed) => { - if (cancelled) return - const error = feed.meta[key]?.error - const nextItems = feed.items[key] ?? [] - const nextCanNext = Boolean(feed.meta[key]?.has_next) - setRows((current) => { - if (error && nextItems.length === 0 && (current[key]?.length ?? 0) > 0) { - return current - } - return { ...current, [key]: nextItems } - }) - setRowCanNext((current) => { - if (error && nextItems.length === 0 && key in current) { - return current - } - return { ...current, [key]: nextCanNext } - }) - if (!error) { - writeCachedDiscoverRow(key, page, nextItems, nextCanNext) - } - setRowErrors((current) => updateDiscoverRowError(current, key, error)) - }) - .catch((err) => { - if (cancelled) return - const message = discoverRequestErrorMessage(err) - setRows((current) => ((current[key]?.length ?? 0) > 0 ? current : { ...current, [key]: [] })) - setRowCanNext((current) => (key in current ? current : { ...current, [key]: false })) - setRowErrors((current) => ({ ...current, [key]: message })) - }) - .finally(() => { - if (!cancelled) { - setRowLoading((current) => ({ ...current, [key]: false })) - } - markDone() - }) - } - return () => { - cancelled = true - } - }, [sections, sectionsReady, selected, rowPages, reloadSeq]) - - const sectionMap = useMemo( - () => new Map(sections.map((section) => [section.key, section])), - [sections], - ) - const hasContent = selected.some((key) => (rows[key] ?? []).length > 0) - const sectionLabel = (key: string) => sectionMap.get(key)?.label ?? key - - const toggleSection = (key: string) => { - setSelected((current) => { - if (current.includes(key)) { - return current.filter((item) => item !== key) - } - return [...current, key] - }) - setRowPages((current) => ({ ...current, [key]: current[key] ?? 1 })) - } - - const changeDiscoverPage = (key: string, delta: number) => { - setRowPages((current) => { - const nextPage = Math.max(1, (current[key] ?? 1) + delta) - if (nextPage === (current[key] ?? 1)) return current - return { ...current, [key]: nextPage } - }) - } - - const refreshDiscover = () => { - const nextImageVersion = String(Date.now()) - setImageVersion(nextImageVersion) - setRefreshImageVersion(nextImageVersion) - setReloadSeq((current) => current + 1) - } - - return ( -
- - - {!sectionsReady && } - - {sectionsReady && !loading && selected.length === 0 && ( - - )} - - {sectionsReady && selected.length > 0 && ( - - )} - - {activeItem && ( - setActiveItem(null)} - /> - )} -
- ) -} - -function updateDiscoverRowError( - current: Record, - key: string, - error?: string, -): Record { - if (error) return { ...current, [key]: error } - if (!(key in current)) return current - const next = { ...current } - delete next[key] - return next -} - -function discoverRequestErrorMessage(err: unknown): string { - const raw = err instanceof Error ? err.message : String(err) - const lower = raw.toLowerCase() - if (lower.includes('timeout') || lower.includes('deadline')) { - return '推荐源请求超时,已跳过本次加载' - } - if (lower.includes('network')) { - return '推荐源网络不可用,已跳过本次加载' - } - return '推荐源暂时不可用,已跳过本次加载' -} diff --git a/web/src/pages/DiscoverPageSections.tsx b/web/src/pages/DiscoverPageSections.tsx deleted file mode 100644 index 6ffafc4..0000000 --- a/web/src/pages/DiscoverPageSections.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { AlertTriangle, RefreshCw, Sparkles } from 'lucide-react' - -import type { DiscoverItem, DiscoverSection } from '../api/discover' -import { ContentRow } from './DiscoverContentRow' - -type SectionLabel = (key: string) => string - -export function DiscoverHeader({ - sections, - selected, - sectionsReady, - loading, - onRefresh, - onToggleSection, -}: { - sections: DiscoverSection[] - selected: string[] - sectionsReady: boolean - loading: boolean - onRefresh: () => void - onToggleSection: (key: string) => void -}) { - return ( -
-
-
- -
-
-

- 发现 -

-

- 多源推荐:TMDb / 豆瓣 / Bangumi,可按需组合显示 -

-
-
- -
- -
- {sections.map((section) => { - const active = selected.includes(section.key) - return ( - - ) - })} -
-
-
- ) -} - -export function DiscoverEmptySelection() { - return ( -
- 至少选择一个推荐源,小宇宙才会开始转动。 -
- ) -} - -export function DiscoverResults({ - selected, - rows, - rowLoading, - rowErrors, - rowPages, - rowCanNext, - loading, - hasContent, - imageVersion, - refreshImageVersion, - sectionLabel, - onPageChange, - onSelect, -}: { - selected: string[] - rows: Record - rowLoading: Record - rowErrors: Record - rowPages: Record - rowCanNext: Record - loading: boolean - hasContent: boolean - imageVersion: string - refreshImageVersion?: string - sectionLabel: SectionLabel - onPageChange: (key: string, delta: number) => void - onSelect: (item: DiscoverItem) => void -}) { - const hasRowErrors = Object.keys(rowErrors).length > 0 - - return ( -
- {selected.map((key) => { - const items = rows[key] ?? [] - if (items.length === 0) { - if (rowLoading[key]) { - return - } - return null - } - return ( - onPageChange(key, delta)} - onSelect={onSelect} - /> - ) - })} - - {hasRowErrors && ( - - )} - - {!loading && !hasContent && !hasRowErrors && } -
- ) -} - -function DiscoverRowErrors({ - rowErrors, - sectionLabel, -}: { - rowErrors: Record - sectionLabel: SectionLabel -}) { - return ( -
- -
-

部分推荐源暂不可用,其他已加载内容不受影响。

- {Object.entries(rowErrors).map(([key, message]) => ( -

{sectionLabel(key)}:{message}

- ))} -
-
- ) -} - -function DiscoverNoContent() { - return ( -
-

- 当前选择的推荐源暂未返回内容,可切换豆瓣 / Bangumi 或检查网络代理。 -

-
- ) -} - -function DiscoverRowSkeleton({ title }: { title: string }) { - return ( -
-

{title}

-
- {[1, 2, 3, 4, 5, 6, 7, 8].map((item) => ( -
- ))} -
-
- ) -} diff --git a/web/src/pages/DownloadClientCard.tsx b/web/src/pages/DownloadClientCard.tsx deleted file mode 100644 index 86afd13..0000000 --- a/web/src/pages/DownloadClientCard.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Loader2, Pencil, Send, Trash2 } from 'lucide-react' - -import type { DownloadClient } from '../api/download_clients' - -export function DownloadClientCard({ - client, - testing, - onDelete, - onEdit, - onTest, -}: { - client: DownloadClient - testing: boolean - onDelete: (client: DownloadClient) => void - onEdit: (client: DownloadClient) => void - onTest: (id: string) => void -}) { - return ( -
-
-
- {client.name} - - {client.type} - - {client.is_default && ( - - 默认 - - )} - {!client.enabled && ( - - 已禁用 - - )} -
-
- {client.host} - {client.username && ` · ${client.username}`} -
-
-
- - - -
-
- ) -} diff --git a/web/src/pages/DownloadClientFormModal.tsx b/web/src/pages/DownloadClientFormModal.tsx deleted file mode 100644 index 55b6cd9..0000000 --- a/web/src/pages/DownloadClientFormModal.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { type FormEvent, type ReactNode, useState } from 'react' -import { Loader2 } from 'lucide-react' -import toast from 'react-hot-toast' - -import { - downloadClientsAPI, - type DownloadClient, - type DownloadClientInput, - type DownloadClientType, -} from '../api/download_clients' -import { apiErrorMessage } from './downloadClientPageModel' - -export function ClientFormModal({ - editing, - onClose, - onSaved, -}: { - editing: DownloadClient | null - onClose: () => void - onSaved: () => void | Promise -}) { - const [form, setForm] = useState(() => ({ - name: editing?.name ?? '', - type: editing?.type ?? 'qbittorrent', - host: editing?.host ?? '', - username: editing?.username ?? '', - password: '', - is_default: editing?.is_default ?? false, - enabled: editing?.enabled ?? true, - })) - const [saving, setSaving] = useState(false) - - const onSubmit = async (e: FormEvent) => { - e.preventDefault() - if (saving) return - setSaving(true) - try { - if (editing) await downloadClientsAPI.update(editing.id, form) - else await downloadClientsAPI.create(form) - toast.success('已保存') - setSaving(false) - onSaved() - } catch (err: unknown) { - const msg = apiErrorMessage(err, '保存失败') - toast.error(msg) - setSaving(false) - } - } - - const update = (k: K, v: DownloadClientInput[K]) => - setForm((f) => ({ ...f, [k]: v })) - - const placeholder = ( - { - qbittorrent: 'http://127.0.0.1:8080', - aria2: 'http://127.0.0.1:6800/jsonrpc', - transmission: 'http://127.0.0.1:9091/transmission/rpc', - } as Record - )[form.type] - - return ( -
-
-

- {editing ? '编辑下载器' : '添加下载器'} -

-
- - update('name', e.target.value)} - /> - - - - - - update('host', e.target.value)} - /> - - {form.type !== 'aria2' && ( - <> - - update('username', e.target.value)} - /> - - - update('password', e.target.value)} - /> - - - )} - {form.type === 'aria2' && ( - - update('password', e.target.value)} - /> - - )} -
- - -
-
- - -
-
-
-
- ) -} - -function Field({ label, children }: { label: string; children: ReactNode }) { - return ( - - ) -} diff --git a/web/src/pages/DownloadClientsPage.tsx b/web/src/pages/DownloadClientsPage.tsx deleted file mode 100644 index 7f2dde6..0000000 --- a/web/src/pages/DownloadClientsPage.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { useEffect, useState } from 'react' -import { Loader2, Plus, Server } from 'lucide-react' -import toast from 'react-hot-toast' - -import { - downloadClientsAPI, - type DownloadClient, -} from '../api/download_clients' -import { confirmAction } from '../components/confirmAction' -import { DownloadClientCard } from './DownloadClientCard' -import { ClientFormModal } from './DownloadClientFormModal' -import { apiErrorMessage } from './downloadClientPageModel' - -// DownloadClientsPage manages multiple downloader integrations. -// Replaces the Vue UI's DownloadView "clients" tab with a typed CRUD -// surface and a per-client Test button. -export function DownloadClientsPage() { - const [clients, setClients] = useState([]) - const [loading, setLoading] = useState(true) - const [editing, setEditing] = useState(null) - const [showForm, setShowForm] = useState(false) - const [testing, setTesting] = useState>({}) - - const refresh = async () => { - setLoading(true) - try { - setClients(await downloadClientsAPI.list()) - } finally { - setLoading(false) - } - } - - useEffect(() => { - refresh().catch(() => undefined) - }, []) - - const onTest = async (id: string) => { - if (testing[id]) return - setTesting((current) => ({ ...current, [id]: true })) - try { - const r = await downloadClientsAPI.test(id) - if (r.ok) toast.success('连接成功') - else toast.error(r.error ?? '连接失败') - } catch (err: unknown) { - const msg = apiErrorMessage(err, '测试失败') - toast.error(msg) - } finally { - setTesting((current) => ({ ...current, [id]: false })) - } - } - - const onDelete = async (c: DownloadClient) => { - if (!(await confirmAction({ title: '删除下载器', message: `确定删除「${c.name}」?`, confirmText: '删除' }))) return - try { - await downloadClientsAPI.remove(c.id) - toast.success('已删除') - await refresh() - } catch (err: unknown) { - const msg = apiErrorMessage(err, '删除失败') - toast.error(msg) - } - } - - return ( -
-
-
-
- -
-
-

下载器管理

-

- qBittorrent / Aria2 / Transmission · 多客户端 + 连接测试 -

-
-
- -
- - {loading && ( -
- -
- )} - - {!loading && clients.length === 0 && ( -
暂无下载器
- )} - - {!loading && clients.length > 0 && ( -
- {clients.map((c) => ( - { - setEditing(client) - setShowForm(true) - }} - onTest={onTest} - /> - ))} -
- )} - - {showForm && ( - setShowForm(false)} - onSaved={async () => { - setShowForm(false) - refresh().catch((err: unknown) => toast.error(apiErrorMessage(err, '刷新下载器列表失败'))) - }} - /> - )} -
- ) -} diff --git a/web/src/pages/DownloadTaskCard.tsx b/web/src/pages/DownloadTaskCard.tsx deleted file mode 100644 index faaaa16..0000000 --- a/web/src/pages/DownloadTaskCard.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import type { ReactNode } from 'react' -import { ArrowDown, ArrowUp, Film, HardDrive, Rss, Trash2 } from 'lucide-react' - -import { imageURL } from '../api/client' -import type { DownloadCardItem } from './downloadTaskCardModel' - -export function DownloadTaskCard({ - item, - removable, - onRemove, -}: { - item: DownloadCardItem - removable?: boolean - onRemove?: () => Promise -}) { - const progress = pct(item.progress) - const visual = item.poster_url || item.backdrop_url - const downloaded = item.downloaded || (item.size ? Math.round(item.size * (item.progress || 0)) : 0) - - return ( -
-
-
- {visual ? ( - {item.title} - ) : ( -
- - {item.title} -
- )} - - {stateLabel(item)} - -
- -
-
-

- {item.title} -

-

- {item.overview || item.save_path || '已隐藏原始种子 URL,避免泄露私有 Token。'} -

-
- -
-
- 进度 {progress.toFixed(1)}% - {fmtBytes(downloaded)} / {fmtBytes(item.size)} -
-
-
-
-
- -
- } value={fmtSpeed(item.dlspeed)} /> - } value={fmtSpeed(item.upspeed)} /> - } value={`${item.num_seeds ?? 0} / ${item.num_leechs ?? 0}`} /> - } value={fmtBytes(item.size)} /> -
- -
- - {item.save_path || '默认下载目录'} - - {item.created_at && {new Date(item.created_at).toLocaleString()}} -
-
-
- - {removable && onRemove && ( -
- -
- )} -
- ) -} - -function DownloadMetric({ icon, value }: { icon: ReactNode; value: string }) { - return ( -
- {icon} - {value} -
- ) -} - -function fmtBytes(n?: number): string { - if (!n || n <= 0) return '0 B' - const u = ['B', 'KB', 'MB', 'GB', 'TB'] - let v = n - let i = 0 - while (v >= 1024 && i < u.length - 1) { - v /= 1024 - i++ - } - return `${v.toFixed(v >= 100 ? 0 : 1)} ${u[i]}` -} - -function fmtSpeed(n?: number): string { - return `${fmtBytes(n)}/s` -} - -function pct(progress?: number): number { - if (!Number.isFinite(progress)) return 0 - return Math.min(100, Math.max(0, Math.round((progress ?? 0) * 1000) / 10)) -} - -function stateLabel(item: DownloadCardItem): string { - const state = (item.state || item.status || 'queued').toLowerCase() - if (state.includes('down') || state.includes('meta')) return '下载中' - if (state.includes('up') || state.includes('seed')) return '做种中' - if (state.includes('pause')) return '已暂停' - if (state.includes('error')) return '出错' - if (state.includes('complete') || pct(item.progress) >= 100) return '已完成' - if (state.includes('queue')) return '排队中' - return item.state || item.status || '等待中' -} - -function statusTone(item: DownloadCardItem): string { - const state = stateLabel(item) - if (state === '已完成' || state === '做种中') return 'bg-emerald-50 text-emerald-600' - if (state === '出错') return 'bg-red-50 text-red-500' - if (state === '已暂停') return 'bg-amber-50 text-amber-600' - return 'bg-primary-400/10 text-brand-500' -} diff --git a/web/src/pages/DownloadsPage.tsx b/web/src/pages/DownloadsPage.tsx deleted file mode 100644 index 961b3aa..0000000 --- a/web/src/pages/DownloadsPage.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { FormEvent, useEffect, useState } from 'react' -import { Link } from 'react-router-dom' -import toast from 'react-hot-toast' -import { Download, ShieldCheck } from 'lucide-react' - -import { downloadsAPI } from '../api/downloads' -import { useAuthStore } from '../stores/auth' -import { confirmAction } from '../components/confirmAction' -import type { DownloadTask, QBitTorrent } from '../types' -import { DownloadTaskCard } from './DownloadTaskCard' -import { toLiveCard, toTaskCard } from './downloadTaskCardModel' - -export function DownloadsPage() { - const role = useAuthStore((s) => s.user?.role) - const [tasks, setTasks] = useState([]) - const [torrents, setTorrents] = useState(null) - const [url, setURL] = useState('') - const [savePath, setSavePath] = useState('') - - const refresh = () => - downloadsAPI.list().then((d) => { - setTasks(d.tasks) - setTorrents(d.torrents) - }) - - useEffect(() => { - void refresh().catch(() => undefined) - const id = window.setInterval(() => void refresh().catch(() => undefined), 5_000) - return () => window.clearInterval(id) - }, []) - - const onAdd = async (e: FormEvent) => { - e.preventDefault() - try { - await downloadsAPI.add(url, savePath) - toast.success('已加入下载队列') - setURL('') - setSavePath('') - await refresh() - } catch (err: unknown) { - const msg = - (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? - '提交失败' - toast.error(msg) - } - } - - return ( -
-
-

下载管理

-

- - 页面仅展示安全标题、海报和进度信息,不再暴露种子原始 URL 或私有 Token。 -

-
- -
- setURL(e.target.value)} - /> - setSavePath(e.target.value)} - /> - -
- -
-
-

当前下载

- 每 5 秒自动刷新 -
- {torrents === null && ( -
- 尚未连接到下载器 —{' '} - {role === 'admin' ? ( - <> - 请到{' '} - - 下载器 - {' '} - 页面添加并测试连接。 - - ) : ( - '请联系管理员添加并测试下载器连接。' - )} -
- )} - {torrents && torrents.length === 0 && ( -
暂无运行中任务。
- )} - {torrents && torrents.length > 0 && ( -
- {torrents.map((torrent) => ( - { - if (!(await confirmAction({ title: '删除下载任务', message: `删除「${torrent.title || torrent.name}」?`, confirmText: '删除' }))) return - await downloadsAPI.remove(torrent.hash, torrent.client_id, false) - toast.success('已删除任务') - await refresh() - }} - /> - ))} -
- )} -
- -
-

下载历史

- {tasks.length === 0 ? ( -
暂无历史下载。
- ) : ( -
- {tasks.map((task) => ( - - ))} -
- )} -
-
- ) -} diff --git a/web/src/pages/DuplicatesPage.tsx b/web/src/pages/DuplicatesPage.tsx deleted file mode 100644 index f39e385..0000000 --- a/web/src/pages/DuplicatesPage.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { useEffect, useState } from 'react' -import toast from 'react-hot-toast' -import { Copy, Trash2 } from 'lucide-react' - -import { duplicatesAPI, type DuplicateReport } from '../api/duplicates' -import { libraryAPI } from '../api/library' -import { confirmAction } from '../components/confirmAction' -import type { Library } from '../types' - -function fmtBytes(n: number): string { - if (!n) return '0 B' - const u = ['B', 'KB', 'MB', 'GB', 'TB'] - let v = n - let i = 0 - while (v >= 1024 && i < u.length - 1) { - v /= 1024 - i++ - } - return `${v.toFixed(1)} ${u[i]}` -} - -export function DuplicatesPage() { - const [libs, setLibs] = useState([]) - const [libID, setLibID] = useState('') - const [report, setReport] = useState(null) - const [scanning, setScanning] = useState(false) - - useEffect(() => { - libraryAPI.list().then(setLibs) - }, []) - - useEffect(() => { - duplicatesAPI.list(libID).then(setReport).catch(() => setReport(null)) - }, [libID]) - - const scan = async () => { - setScanning(true) - try { - const r = await duplicatesAPI.scan(libID) - setReport(r) - const cleaned = r.missing_removed ? `, 清理 ${r.missing_removed} 条失效记录` : '' - toast.success(`扫描完成: ${r.groups_found} 组重复, ${r.items_marked} 项标记${cleaned}`) - } catch (err: unknown) { - const msg = - (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? - '扫描失败' - toast.error(msg) - } finally { - setScanning(false) - } - } - - const unmark = async () => { - if (!(await confirmAction({ title: '清除重复标记', message: '清除所有重复标记?(磁盘文件不会被删除)', confirmText: '清除' }))) return - const r = await duplicatesAPI.unmark(libID) - toast.success(`已清除 ${r.unmarked} 项`) - setReport(null) - } - - return ( -
-
- -
-

重复文件

-

- 通过稀疏采样 MD5(头部 / 中部 / 尾部各 1 MiB + 文件大小)检测重复媒体, - 同一组中保留刮削过的较大文件作为主条目,其余标记为重复。 -

-
-
- -
- - - -
- - {report && report.groups_found === 0 && ( -

扫描了 {report.total_scanned} 项,未发现重复。

- )} - - {report && report.missing_removed ? ( -

- 已清理 {report.missing_removed} 条文件不存在的媒体记录,统计容量会在刷新后恢复正常。 -

- ) : null} - - {report && (report.groups ?? []).map((g) => ( -
-
-

{g.hash}

- - 主条目 - -
-

{g.primary.title}

-

- {g.primary.path} · {fmtBytes(g.primary.size_bytes)} -

-
-

- 重复 ({g.duplicates.length}) -

- {g.duplicates.map((d) => ( -
- {d.title} · {d.path} · {fmtBytes(d.size_bytes)} -
- ))} -
-
- ))} -
- ) -} diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index 41c8842..f5f0d92 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -1,62 +1,157 @@ import { useEffect, useMemo, useState } from 'react' -import { libraryAPI, mediaAPI } from '../api/library' +import { libraryAPI } from '../api/library' import { playbackAPI, type HistoryItem } from '../api/playback' import type { Library, Media } from '../types' import { groupSeries, type SeriesCard } from '../utils/groupSeries' import { ContinueWatchingSection, + HomeCarouselSection, HomeEmptyState, - HomeFeaturedSection, + HomeLibrariesSection, + HomeLibraryRowSection, HomeLoadingState, - RecentMediaSection, } from './HomePageSections' +const CAROUSEL_STORAGE_KEY = 'mmtl.home.carousel_libraries' const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url) -const asArray = (value: unknown): T[] => (Array.isArray(value) ? value as T[] : []) +const asArray = (value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []) export function HomePage() { const [libraries, setLibraries] = useState([]) - const [recentCards, setRecentCards] = useState([]) + const [libraryData, setLibraryData] = useState>({}) + const [selectedLibraryIds, setSelectedLibraryIds] = useState(() => { + try { + const saved = localStorage.getItem(CAROUSEL_STORAGE_KEY) + return saved ? JSON.parse(saved) : [] + } catch { + return [] + } + }) const [history, setHistory] = useState([]) const [loading, setLoading] = useState(true) useEffect(() => { let cancelled = false + async function load() { setLoading(true) try { - const [libs, recentItems, hist] = await Promise.all([ + const [libs, hist] = await Promise.all([ libraryAPI.list().then((rows) => asArray(rows)).catch(() => [] as Library[]), - mediaAPI.recent(24).then((rows) => asArray(rows)).catch(async () => { - const fallback = await mediaAPI.search('', 120).then((d) => asArray(d?.items)).catch(() => [] as Media[]) - return groupSeries(fallback).slice(0, 24) - }), playbackAPI.recentHistory().then((rows) => asArray(rows)).catch(() => [] as HistoryItem[]), ]) + if (cancelled) return setLibraries(libs) - setRecentCards(recentItems) setHistory(hist.filter((h) => h && !h.completed && !!h.media)) + + // Set default selected libraries if none saved yet + setSelectedLibraryIds((current) => { + if (current.length > 0) return current + const allIds = libs.map((l) => l.id) + try { + localStorage.setItem(CAROUSEL_STORAGE_KEY, JSON.stringify(allIds)) + } catch { + // ignore + } + return allIds + }) + + // Fetch media items for all libraries in parallel + const results = await Promise.allSettled( + libs.map(async (lib) => { + const page = await libraryAPI.listMedia(lib.id, 1, 30) + const items = asArray(page?.items) + const cards = groupSeries(items) + return { + id: lib.id, + cards, + items, + total: page?.total ?? items.length, + } + }), + ) + + if (cancelled) return + const mapData: Record = {} + for (const res of results) { + if (res.status === 'fulfilled' && res.value) { + mapData[res.value.id] = { + cards: res.value.cards, + items: res.value.items, + total: res.value.total, + } + } + } + setLibraryData(mapData) } finally { if (!cancelled) setLoading(false) } } + load() - return () => { cancelled = true } + return () => { + cancelled = true + } }, []) - const featuredItem = useMemo(() => { - const candidates = [ - ...(history.map((h) => h.media).filter(Boolean) as Media[]), - ...recentCards.map((card) => card.rep), - ] - return candidates.find(hasArtwork) ?? candidates[0] ?? null - }, [history, recentCards]) - const featuredVisual = featuredItem?.backdrop_url || featuredItem?.poster_url || '' - const featuredPoster = featuredItem?.poster_url || featuredItem?.backdrop_url || '' - const featuredMark = (featuredItem?.title || 'MS').trim().slice(0, 4).toUpperCase() - const empty = !loading && libraries.length === 0 && recentCards.length === 0 && history.length === 0 + // Quick lookup map for libraries + const libraryMap = useMemo(() => { + const map = new Map() + for (const lib of libraries) { + map.set(lib.id, lib) + } + return map + }, [libraries]) + + // Counts lookup + const libraryCounts = useMemo(() => { + const counts: Record = {} + for (const lib of libraries) { + counts[lib.id] = libraryData[lib.id]?.total ?? 0 + } + return counts + }, [libraries, libraryData]) + + // Compute items to show in the Hero Carousel + const carouselItems = useMemo(() => { + const candidateMedia: Media[] = [] + const effectiveSelectedIds = + selectedLibraryIds.length > 0 ? selectedLibraryIds : libraries.map((l) => l.id) + + for (const libId of effectiveSelectedIds) { + const data = libraryData[libId] + if (data && data.items.length > 0) { + // Pick representative items from series cards or raw items + for (const card of data.cards) { + if (hasArtwork(card.rep)) { + candidateMedia.push(card.rep) + } + } + } + } + + // Sort by artwork score / rating or shuffle / interleave + if (candidateMedia.length === 0) { + // Fallback to all loaded items with artwork + for (const lib of libraries) { + const data = libraryData[lib.id] + if (data) { + for (const card of data.cards) { + candidateMedia.push(card.rep) + } + } + } + } + + return candidateMedia.slice(0, 10) + }, [selectedLibraryIds, libraries, libraryData]) + + const empty = + !loading && + libraries.length === 0 && + history.length === 0 if (loading) { return @@ -67,18 +162,38 @@ export function HomePage() { } return ( -
- {featuredItem && ( - + {/* 1. 顶部海报轮播区 */} + {carouselItems.length > 0 && ( + + )} + + {/* 2. 继续观看(若有历史) */} + {history.length > 0 && } + + {/* 3. 媒体库卡片区 */} + {libraries.length > 0 && ( + )} - {history.length > 0 && } - {recentCards.length > 0 && } + {/* 4. 各媒体库内容展示行 */} +
+ {libraries.map((lib) => { + const cards = libraryData[lib.id]?.cards || [] + if (cards.length === 0) return null + return ( + + ) + })} +
) } diff --git a/web/src/pages/HomePageSections.tsx b/web/src/pages/HomePageSections.tsx index 9b81c81..c4aa643 100644 --- a/web/src/pages/HomePageSections.tsx +++ b/web/src/pages/HomePageSections.tsx @@ -1,23 +1,69 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react' import { Link } from 'react-router-dom' -import { motion } from 'framer-motion' -import { ArrowRight, Clock, Film, Play, Sparkles } from 'lucide-react' +import { AnimatePresence, motion } from 'framer-motion' +import { + ArrowRight, + ChevronLeft, + ChevronRight, + Clock, + Film, + FolderOpen, + Library as LibraryIcon, + Music, + Play, + PlayCircle, + Sparkles, + Star, + Tv, +} from 'lucide-react' import { imageURL } from '../api/client' import { MediaCard } from '../components/MediaCard' import type { HistoryItem } from '../api/playback' -import type { Media } from '../types' +import type { Library, Media } from '../types' import type { SeriesCard } from '../utils/groupSeries' import { seriesCardLink } from '../utils/groupSeries' +import { getLibraryArtworks } from './librariesPageModel' + +const TYPE_ICONS: Record = { + movie: , + movies: , + tv: , + series: , + anime: , + shows: , + variety: , + music: , + adult: , +} + +const TYPE_LABELS: Record = { + movie: '电影', + movies: '电影', + tv: '剧集', + series: '剧集', + anime: '动漫', + shows: '综艺', + variety: '综艺', + music: '音乐', + adult: 'Adult', +} export function HomeLoadingState() { return (
- +
- 首页内容准备中… + + 首页内容准备中… +
) @@ -31,165 +77,512 @@ export function HomeEmptyState() {

您的家庭影视站暂无内容

- 前往管理后台添加媒体目录,扫描后首页将展示本周力荐、继续观看和最近入库。 + 前往媒体库添加媒体目录,扫描后首页将展示海报轮播、媒体库和最新入库。

- - 前往管理后台 + + 前往媒体库
) } -export function HomeFeaturedSection({ - featuredItem, - featuredVisual, - featuredPoster, - featuredMark, +/* ========================================================================= + 1. 海报轮播区 (Hero Carousel Section) + ========================================================================= */ + +export function HomeCarouselSection({ + items, + libraryMap, }: { - featuredItem: Media - featuredVisual: string - featuredPoster: string - featuredMark: string + items: Media[] + libraryMap: Map }) { + const [currentIndex, setCurrentIndex] = useState(0) + const [isPaused, setIsPaused] = useState(false) + + const currentItem = items[currentIndex] || items[0] + const count = items.length + + useEffect(() => { + if (count <= 1 || isPaused) return + const timer = setInterval(() => { + setCurrentIndex((prev) => (prev + 1) % count) + }, 5000) + return () => clearInterval(timer) + }, [count, isPaused]) + + if (!currentItem) return null + + const handlePrev = () => { + setCurrentIndex((prev) => (prev - 1 + count) % count) + } + + const handleNext = () => { + setCurrentIndex((prev) => (prev + 1) % count) + } + + const visual = currentItem.backdrop_url || currentItem.poster_url || '' + const poster = currentItem.poster_url || currentItem.backdrop_url || '' + const lib = libraryMap.get(currentItem.display_library_id || currentItem.library_id) + return ( -
+
setIsPaused(true)} + onMouseLeave={() => setIsPaused(false)} + className="group relative overflow-hidden rounded-[2rem] border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_24px_80px_var(--app-shadow)] min-h-[420px] md:min-h-[460px] flex flex-col justify-end" + > + {/* Background Backdrop Image with Crossfade */}
- {featuredVisual && ( - { event.currentTarget.style.display = 'none' }} - /> - )} + + {visual && ( + { + e.currentTarget.style.display = 'none' + }} + /> + )} +
-
+
-
-
-
- - 本周力荐 / Featured -
- -
-
- - {featuredMark} + {/* Main Content Layout */} +
+ {/* Left Column: Metadata & Actions */} +
+ {/* Tag / Library Label */} +
+
+ + 焦点推荐
-

- {featuredItem.title} -

+ {lib && ( + + {TYPE_ICONS[lib.type] || } + {lib.name} + + )}
-

- {featuredItem.overview || '家庭私人媒体中心收藏。支持多端播放、外部播放器、智能刮削与订阅下载。'} + {/* Title */} +

+ + + {currentItem.title} + + + {currentItem.original_name && currentItem.original_name !== currentItem.title && ( +

+ {currentItem.original_name} +

+ )} +
+ + {/* Metadata Chips */} +
+ {currentItem.rating > 0 && ( + + + {currentItem.rating.toFixed(1)} + + )} + {currentItem.year > 0 && ( + + {currentItem.year} 年 + + )} + {currentItem.video_codec && ( + + {currentItem.video_codec} + + )} + {currentItem.container && ( + + {currentItem.container} + + )} +
+ + {/* Overview */} +

+ {currentItem.overview || '家庭私人媒体中心收藏。支持多端播放、高码率串流与智能刮削。'}

-
- {featuredItem.year > 0 && ( - {featuredItem.year} 年 - )} - {featuredItem.video_codec && ( - - {featuredItem.video_codec} - - )} - {featuredItem.container && ( - - {featuredItem.container} - - )} -
- + {/* Action Buttons */}
- + 立即播放 - - 发现更多精彩 + + 查看详情
-
-
+ {/* Right Column: Floating 3D Poster Card */} +
+
-
+
- {featuredItem.title} + + {currentItem.title} +
- {featuredPoster && ( + {poster && ( {featuredItem.title} { event.currentTarget.style.display = 'none' }} + onError={(e) => { + e.currentTarget.style.display = 'none' + }} /> )}
+ + {/* Carousel Controls & Dots */} + {count > 1 && ( +
+ {/* Navigation Indicators */} +
+ {items.map((_, idx) => ( +
+ + {/* Slide Number & Arrow Buttons */} +
+ + {String(currentIndex + 1).padStart(2, '0')} / {String(count).padStart(2, '0')} + +
+ + +
+
+
+ )}
) } -export function ContinueWatchingSection({ history }: { history: HistoryItem[] }) { +/* ========================================================================= + 2. 媒体库卡片区 (Libraries Grid) + ========================================================================= */ + +export function HomeLibrariesSection({ + libraries, + libraryData, + libraryCounts, +}: { + libraries: Library[] + libraryData?: Record + libraryCounts: Record +}) { return ( -
-
- - - -

继续观看

- {history.length} 个记录 +
+
+
+ + + +
+

+ 媒体库 +

+

+ 点击卡片浏览对应媒体库精选内容 +

+
+
+ + + 全部媒体库 + +
-
- {history.slice(0, 8).map((h) => { - const media = h.media! - const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0 - return + {/* Libraries Grid */} +
+ {libraries.map((lib) => { + const count = libraryCounts[lib.id] ?? 0 + const cards = libraryData?.[lib.id]?.cards || [] + const artwork = getLibraryArtworks(lib, cards) + + return ( + + {/* 封面图片展示区:和媒体库页面一样,显示设置好的或生成的图片 */} +
1 ? 'grid grid-cols-2 gap-0.5' : '' + }`} + > + {artwork.length > 0 ? ( + artwork.map(({ src, version }, index) => ( + { + event.currentTarget.style.visibility = 'hidden' + }} + /> + )) + ) : ( +
+ {TYPE_ICONS[lib.type] || } +
+ )} + + {/* 浮动类型标签 */} +
+ {TYPE_LABELS[lib.type] || '自定义'} +
+
+ + {/* 媒体库信息 */} +
+

+ {lib.name} +

+

+ {count > 0 ? `${count} 部媒体` : '暂无条目'} +

+
+ + ) })}
) } -export function RecentMediaSection({ recentCards }: { recentCards: SeriesCard[] }) { +/* ========================================================================= + 3. 单个媒体库内容行 (Horizontal Scroll Row) + ========================================================================= */ + +export function HomeLibraryRowSection({ + library, + cards, +}: { + library: Library + cards: SeriesCard[] +}) { + const scrollRef = useRef(null) + + if (!cards || cards.length === 0) return null + + const scroll = (direction: 'left' | 'right') => { + if (scrollRef.current) { + const scrollAmount = direction === 'left' ? -480 : 480 + scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' }) + } + } + return ( -
+
+ {/* Row Header */} +
+
+ + {TYPE_ICONS[library.type] || } + +
+

+ {library.name} +

+ + 共 {cards.length} 部精选内容 + +
+
+ +
+ + 查看全部 + + + + {/* Scroll Buttons */} +
+ + +
+
+
+ + {/* Horizontal Carousel Row */} +
+ {cards.map((card) => ( +
+ +
+ ))} +
+
+ ) +} + +/* ========================================================================= + 4. 继续观看行 (Continue Watching) + ========================================================================= */ + +export function ContinueWatchingSection({ history }: { history: HistoryItem[] }) { + const scrollRef = useRef(null) + + const scroll = (direction: 'left' | 'right') => { + if (scrollRef.current) { + const scrollAmount = direction === 'left' ? -400 : 400 + scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' }) + } + } + + return ( +
-

最近入库

-

按整部电影、剧集、番剧和综艺合集展示新增内容。

+

+ 继续观看 +

+ + {history.length} 条播放记录 +
- - 海报墙 - - + +
+ + +
-
- {recentCards.map((card) => ( - - ))} +
+ {history.slice(0, 12).map((h) => { + const media = h.media! + const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0 + return ( +
+ +
+ ) + })}
) @@ -197,42 +590,50 @@ export function RecentMediaSection({ recentCards }: { recentCards: SeriesCard[] function ContinueCard({ media, progress }: { media: Media; progress: number }) { return ( - -
+ +
{media.poster_url ? ( ) : ( -
- +
+
)} -
- +
+
-
-

+ +

+

{media.title}

-
-
- + {media.year > 0 && {media.year}} + {media.season_num !== undefined && media.episode_num !== undefined && ( + + S{media.season_num}E{media.episode_num} + + )} +
+ + {/* Progress Bar */} + {progress > 0 && progress < 1 && ( +
+
-

- 已观看到 {Math.round(progress * 100)}% -

-
+ )}
) diff --git a/web/src/pages/LibrariesPageSections.tsx b/web/src/pages/LibrariesPageSections.tsx index 4345be7..1547eec 100644 --- a/web/src/pages/LibrariesPageSections.tsx +++ b/web/src/pages/LibrariesPageSections.tsx @@ -6,9 +6,10 @@ import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle import { imageURL } from '../api/client' import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle' import { MediaCard } from '../components/MediaCard' -import { artworkScore, seriesCardLink, type SeriesCard } from '../utils/groupSeries' +import { openManageLibrariesDialog } from '../components/manageLibrariesDialog' +import { seriesCardLink } from '../utils/groupSeries' import { libraryDisplayPath } from './libraryDisplayModel' -import { mediaTime, type LibraryPreview } from './librariesPageModel' +import { libraryArtworkItems, type LibraryPreview } from './librariesPageModel' const TYPE_ICONS: Record = { movie: , @@ -71,10 +72,9 @@ export function LibrariesHeader({ {repairing ? '正在启动…' : '全库修复+重刮'} - +
) @@ -223,14 +223,3 @@ function LibraryShelf({ preview }: { preview: LibraryPreview }) {
) } - -function libraryArtworkItems(cards: SeriesCard[]): Array<{ src: string; version?: string }> { - return [...cards] - .sort((a, b) => artworkScore(b.rep) - artworkScore(a.rep) || mediaTime(b.rep) - mediaTime(a.rep)) - .map((card) => ({ - src: card.rep.poster_url || card.rep.backdrop_url || '', - version: card.rep.updated_at, - })) - .filter((item) => Boolean(item.src)) - .slice(0, 4) -} diff --git a/web/src/pages/LibrarySettingsPanel.tsx b/web/src/pages/LibrarySettingsPanel.tsx new file mode 100644 index 0000000..7b673a7 --- /dev/null +++ b/web/src/pages/LibrarySettingsPanel.tsx @@ -0,0 +1,305 @@ +import { useEffect, useState } from 'react' +import { + Check, + CheckSquare, + Film, + FolderOpen, + HeartHandshake, + Layers, + Loader2, + Music, + Save, + SlidersHorizontal, + Square, + Tv, +} from 'lucide-react' +import toast from 'react-hot-toast' + +import { adminAPI } from '../api/admin' +import { imageURL } from '../api/client' +import { libraryAPI } from '../api/library' +import type { Library, Setting } from '../types' +import { groupSeries, type SeriesCard } from '../utils/groupSeries' +import { getLibraryArtworks } from './librariesPageModel' + +const CAROUSEL_STORAGE_KEY = 'mmtl.home.carousel_libraries' +const SETTING_KEY_CAROUSEL = 'home.carousel_libraries' + +const TYPE_ICONS: Record = { + movie: , + movies: , + tv: , + series: , + anime: , + shows: , + variety: , + music: , + adult: , +} + +const TYPE_LABELS: Record = { + movie: '电影', + movies: '电影', + tv: '剧集', + series: '剧集', + anime: '动漫', + shows: '综艺', + variety: '综艺', + music: '音乐', + adult: 'Adult', +} + +export function LibrarySettingsPanel() { + const [libraries, setLibraries] = useState([]) + const [libraryCards, setLibraryCards] = useState>({}) + const [selectedIds, setSelectedIds] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [dirty, setDirty] = useState(false) + + useEffect(() => { + async function load() { + setLoading(true) + try { + const [libs, settings] = await Promise.all([ + libraryAPI.list({ includeHidden: true }).catch(() => [] as Library[]), + adminAPI.listSettings().catch(() => [] as Setting[]), + ]) + + const libList = Array.isArray(libs) ? libs : [] + setLibraries(libList) + + // 异步拉取各个媒体库的前几个条目用于封面展示 + Promise.allSettled( + libList.map(async (lib) => { + const page = await libraryAPI.listMedia(lib.id, 1, 10) + const items = Array.isArray(page?.items) ? page.items : [] + return { id: lib.id, cards: groupSeries(items) } + }), + ).then((results) => { + const map: Record = {} + for (const r of results) { + if (r.status === 'fulfilled' && r.value) { + map[r.value.id] = r.value.cards + } + } + setLibraryCards(map) + }) + + // 优先读取系统配置,其次读取 localStorage,默认全选 + const settingItem = Array.isArray(settings) + ? settings.find((s) => s.key === SETTING_KEY_CAROUSEL) + : undefined + + let initialIds: string[] | null = null + if (settingItem?.value) { + try { + initialIds = JSON.parse(settingItem.value) + } catch { + // ignore + } + } + + if (!initialIds) { + try { + const saved = localStorage.getItem(CAROUSEL_STORAGE_KEY) + if (saved) initialIds = JSON.parse(saved) + } catch { + // ignore + } + } + + if (Array.isArray(initialIds)) { + setSelectedIds(initialIds) + } else { + setSelectedIds(libList.map((l) => l.id)) + } + } finally { + setLoading(false) + } + } + + load() + }, []) + + const toggleLibrary = (id: string) => { + setSelectedIds((prev) => { + const next = prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id] + setDirty(true) + return next + }) + } + + const selectAll = () => { + setSelectedIds(libraries.map((l) => l.id)) + setDirty(true) + } + + const deselectAll = () => { + setSelectedIds([]) + setDirty(true) + } + + const handleSave = async () => { + setSaving(true) + try { + const jsonValue = JSON.stringify(selectedIds) + await adminAPI.updateSetting(SETTING_KEY_CAROUSEL, jsonValue) + try { + localStorage.setItem(CAROUSEL_STORAGE_KEY, jsonValue) + } catch { + // ignore + } + toast.success('海报轮播设置已保存') + setDirty(false) + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? + '保存设置失败' + toast.error(msg) + } finally { + setSaving(false) + } + } + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+ {/* 头部说明与快捷操作 */} +
+
+
+ +
+
+

首页海报轮播设置

+

+ 选择参与首页顶部大图海报轮播推荐的媒体库。勾选的媒体库内容将轮流展示在系统首页顶部。 +

+
+
+ +
+ + +
+
+ + {/* 媒体库列表卡片 */} + {libraries.length === 0 ? ( +
+ 暂无可用媒体库,请先添加媒体库后再配置海报轮播。 +
+ ) : ( +
+ {libraries.map((lib) => { + const isSelected = selectedIds.includes(lib.id) + const cards = libraryCards[lib.id] || [] + const artwork = getLibraryArtworks(lib, cards) + + return ( +
toggleLibrary(lib.id)} + className={`flex cursor-pointer items-center justify-between rounded-2xl border p-4 transition-all duration-200 select-none ${ + isSelected + ? 'border-brand-500/60 bg-primary-400/10 shadow-sm' + : 'border-gray-200 bg-white/70 hover:border-gray-300' + }`} + > +
+
1 ? 'grid-cols-2' : 'grid-cols-1' + } ${ + isSelected ? 'ring-2 ring-brand-500/30' : '' + }`} + > + {artwork.length > 0 ? ( + artwork.map(({ src, version }, index) => ( + { + e.currentTarget.style.display = 'none' + }} + /> + )) + ) : ( +
+ {TYPE_ICONS[lib.type] || } +
+ )} +
+
+
+ + {lib.name} + + + {TYPE_LABELS[lib.type] || '自定义'} + +
+

+ {isSelected ? '已启用轮播' : '未参与轮播'} +

+
+
+ +
+ +
+
+ ) + })} +
+ )} + + {/* 底部保存按钮 */} +
+ + 已选择 {selectedIds.length} / {libraries.length} 个媒体库参与轮播 + + +
+
+ ) +} diff --git a/web/src/pages/LibraryStorageStats.tsx b/web/src/pages/LibraryStorageStats.tsx new file mode 100644 index 0000000..a57aeff --- /dev/null +++ b/web/src/pages/LibraryStorageStats.tsx @@ -0,0 +1,180 @@ +import { useEffect, useState } from 'react' +import { Database, HardDrive, PieChart, RefreshCw } from 'lucide-react' + +import { storageAPI, type StorageBreakdown } from '../api/storage' + +export function fmtBytes(n: number): string { + if (!n) return '0 B' + const u = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] + let v = n + let i = 0 + while (v >= 1024 && i < u.length - 1) { + v /= 1024 + i++ + } + return `${v.toFixed(2)} ${u[i]}` +} + +export function fmtHours(seconds: number): string { + if (!seconds) return '—' + const h = Math.floor(seconds / 3600) + return `${h.toLocaleString()} h` +} + +export function LibraryStorageStats() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + + const loadData = () => { + setRefreshing(true) + storageAPI + .breakdown() + .then(setData) + .finally(() => { + setLoading(false) + setRefreshing(false) + }) + } + + useEffect(() => { + loadData() + }, []) + + if (loading) { + return ( +
+ 加载统计数据中… +
+ ) + } + + if (!data) { + return ( +
+ 无法获取存储数据 +
+ ) + } + + const totalBytes = data.total_bytes || 1 + + return ( +
+
+

存储与统计

+ +
+ +
+ } label="总占用" value={fmtBytes(data.total_bytes)} /> + } label="媒体库" value={`${data.by_library.length}`} /> + } label="累计时长" value={fmtHours(data.total_seconds)} /> +
+ +
+

按媒体库

+
+ + + + + + + + + + + + {data.by_library.length === 0 ? ( + + + + ) : ( + data.by_library.map((l) => { + const pct = (l.total_bytes / totalBytes) * 100 + return ( + + + + + + + + ) + }) + )} + +
名称类型媒体数占用占比
+ 暂无媒体库数据 +
{l.name}{l.type}{l.media_count}{fmtBytes(l.total_bytes)} +
+
+
+
+ {pct.toFixed(1)}% +
+
+
+
+ +
+

按容器格式

+ {data.by_container.length === 0 ? ( +
+ 暂无格式统计 +
+ ) : ( +
+ {data.by_container.map((c) => ( +
+
+

{c.container || '未知'}

+

{c.count} 项

+
+

{fmtBytes(c.bytes)}

+
+ ))} +
+ )} +
+
+ ) +} + +function StatTile({ + icon, + label, + value, +}: { + icon: React.ReactNode + label: string + value: string +}) { + return ( +
+
+ {icon} +
+
+

{label}

+

{value}

+
+
+ ) +} diff --git a/web/src/pages/LicensePage.tsx b/web/src/pages/LicensePage.tsx deleted file mode 100644 index 5fbf7d6..0000000 --- a/web/src/pages/LicensePage.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { FormEvent, useCallback, useEffect, useState } from 'react' -import toast from 'react-hot-toast' - -import { licenseAPI, type LicenseStatus } from '../api/license' -import { - LicenseBindPanel, - LicenseHeader, - LicenseInactiveTip, - LicenseStatusPanel, -} from './LicensePageSections' - -export function LicensePage() { - const [status, setStatus] = useState(null) - const [loadingStatus, setLoadingStatus] = useState(true) - const [bindKey, setBindKey] = useState('') - const [binding, setBinding] = useState(false) - - const refreshStatus = useCallback(async () => { - setLoadingStatus(true) - try { - const s = await licenseAPI.status() - setStatus(s) - } catch { - setStatus({ active: false }) - } finally { - setLoadingStatus(false) - } - }, []) - - useEffect(() => { - refreshStatus() - }, [refreshStatus]) - - const onBind = async (e: FormEvent) => { - e.preventDefault() - const key = bindKey.trim() - if (!key) { - toast.error('请输入许可证密钥') - return - } - setBinding(true) - try { - const activation = await licenseAPI.bind(key) - toast.success('许可证绑定成功!') - setBindKey('') - // Optimistically update status - setStatus({ - active: true, - activation, - max_users: activation.max_users, - unlimited_users: activation.unlimited_users, - message: '已激活', - }) - } catch (err: unknown) { - const msg = - (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? - '绑定失败,请检查密钥是否正确' - toast.error(msg) - } finally { - setBinding(false) - } - } - - const onHeartbeat = async () => { - try { - await licenseAPI.heartbeat() - toast.success('心跳上报成功') - } catch { - toast.error('心跳上报失败') - } - } - - // ── Derive display values ── - const active = status?.active === true - const activation = status?.activation - const isExpired = - activation?.expires_at != null && new Date(activation.expires_at).getTime() < Date.now() - - return ( -
- - - - -
- ) -} diff --git a/web/src/pages/LicensePageSections.tsx b/web/src/pages/LicensePageSections.tsx deleted file mode 100644 index 4ddc0fb..0000000 --- a/web/src/pages/LicensePageSections.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import type { FormEvent } from 'react' -import { - CheckCircle2, - KeySquare, - Link2, - Loader2, - RefreshCw, - XCircle, -} from 'lucide-react' - -import type { LicenseStatus } from '../api/license' - -export function LicenseHeader() { - return ( -
-
- -
-
-

许可证

-

绑定授权密钥以提升多用户容量

-
-
- ) -} - -export function LicenseBindPanel({ - bindKey, - binding, - onBind, - onBindKeyChange, -}: { - bindKey: string - binding: boolean - onBind: (event: FormEvent) => void - onBindKeyChange: (value: string) => void -}) { - return ( -
-
- -

绑定许可证

-
-
- onBindKeyChange(event.target.value)} - disabled={binding} - /> - -
-

- 输入从授权服务器获取的许可证密钥,激活后按授权额度开放更多平台用户。 -

-
- ) -} - -export function LicenseStatusPanel({ - status, - loadingStatus, - active, - isExpired, - onRefresh, - onHeartbeat, -}: { - status: LicenseStatus | null - loadingStatus: boolean - active: boolean - isExpired: boolean - onRefresh: () => void - onHeartbeat: () => void -}) { - const activation = status?.activation - - if (loadingStatus) { - return ( -
- -
- ) - } - - return ( -
-
-
- {active && !isExpired ? ( - - ) : ( - - )} -

当前状态

-
-
- - {active && ( - - )} -
-
- - {!active && ( -
-

- 尚未绑定许可证。请在上方输入密钥完成激活。 -

-
- )} - - {active && activation && ( -
- - - - - - - - - -
- )} - - {isExpired && ( -
- 此许可证已过期,部分功能可能受限。请获取新的许可证密钥。 -
- )} -
- ) -} - -export function LicenseInactiveTip({ active, loadingStatus }: { active: boolean; loadingStatus: boolean }) { - if (active || loadingStatus) return null - return ( -
-

- 需要获取许可证?请联系管理员获取 MediaStationGo 授权密钥。 -

-

- 授权服务器地址可在系统设置中配置 -

-
- ) -} - -function StatusBadge({ - label, - value, - mono, - className, -}: { - label: string - value: string - mono?: boolean - className?: string -}) { - return ( -
-

{label}

-

- {value} -

-
- ) -} - -function fmtDate(iso: string | null | undefined): string { - if (!iso) return '永久' - return new Date(iso).toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }) -} - -function fmtDateTime(iso: string | null | undefined): string { - if (!iso) return '—' - return new Date(iso).toLocaleString('zh-CN') -} - -function fmtUserLimit(maxUsers: number | null | undefined, unlimited?: boolean): string { - if (unlimited || maxUsers == null) return '不限制' - return `${maxUsers} 人` -} diff --git a/web/src/pages/LoginPageSections.tsx b/web/src/pages/LoginPageSections.tsx index e223068..99edb70 100644 --- a/web/src/pages/LoginPageSections.tsx +++ b/web/src/pages/LoginPageSections.tsx @@ -2,8 +2,6 @@ import type { FormEvent, ReactNode } from 'react' import { motion } from 'framer-motion' import { ArrowRight, Eye, EyeOff, Lock, User } from 'lucide-react' -import { AppFooter } from '../components/AppFooter' - type LoginPageShellProps = { children: ReactNode } @@ -37,7 +35,6 @@ export function LoginPageShell({ children }: LoginPageShellProps) {
{children} -
) } @@ -75,8 +72,8 @@ function LoginBrandHeader() { initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={{ delay: 0.15, type: 'spring', stiffness: 200 }} - src="/brand/mediastationgo-logo.svg" - alt="MediaStationGo" + src="/brand/logo-192.png" + alt="MMTL" className="mb-4 h-14 w-14 rounded-2xl object-contain shadow-sm" /> @@ -86,7 +83,7 @@ function LoginBrandHeader() { transition={{ delay: 0.25 }} className="font-display text-2xl font-extrabold tracking-tight text-gray-900" > - MediaStationGo + MMTL ) } - -function LoginFooter() { - return ( - - - - ) -} diff --git a/web/src/pages/NotifyChannelCard.tsx b/web/src/pages/NotifyChannelCard.tsx deleted file mode 100644 index dff8c2e..0000000 --- a/web/src/pages/NotifyChannelCard.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Loader2, Pencil, Send, Trash2 } from 'lucide-react' - -import type { NotifyChannel } from '../types' -import { channelSummary, eventSummary, TYPE_LABELS } from './notifyChannelsModel' - -type NotifyChannelCardProps = { - channel: NotifyChannel - onTest: () => void - testing?: boolean - onEdit: () => void - onDelete: () => void -} - -export function NotifyChannelCard({ - channel, - onTest, - testing, - onEdit, - onDelete, -}: NotifyChannelCardProps) { - const summary = channelSummary(channel) - return ( -
-
-
- {channel.name} - - {TYPE_LABELS[channel.type] ?? channel.type} - - {!channel.enabled && ( - 已禁用 - )} -
-
{summary}
-
{eventSummary(channel.events)}
-
-
- - - -
-
- ) -} diff --git a/web/src/pages/NotifyChannelConfigControls.tsx b/web/src/pages/NotifyChannelConfigControls.tsx deleted file mode 100644 index 35d8350..0000000 --- a/web/src/pages/NotifyChannelConfigControls.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { Field } from './NotifyChannelFormField' - -type ConfigControlProps = { - config: Record - updateConfig: (key: string, value: string) => void -} - -type ConfigInputProps = ConfigControlProps & { - label: string - name: string - placeholder?: string - required?: boolean - type?: string -} - -export function ConfigInput({ - config, - updateConfig, - label, - name, - placeholder, - required, - type, -}: ConfigInputProps) { - return ( - - updateConfig(name, event.target.value)} - /> - - ) -} - -type ConfigSelectProps = ConfigControlProps & { - label: string - name: string - options: Array<{ value: string; label: string }> - defaultValue?: string -} - -export function ConfigSelect({ - config, - updateConfig, - label, - name, - options, - defaultValue = '', -}: ConfigSelectProps) { - return ( - - - - ) -} - -type ConfigTextareaProps = ConfigControlProps & { - label: string - name: string - placeholder?: string - rows: number -} - -export function ConfigTextarea({ - config, - updateConfig, - label, - name, - placeholder, - rows, -}: ConfigTextareaProps) { - return ( - -