+ if token == "" {
+ if authHeader := c.GetHeader("Authorization"); authHeader != "" {
+ // Strip "Bearer " or "Emby " prefix
+ for _, prefix := range []string{"Bearer ", "Emby "} {
+ if len(authHeader) > len(prefix) && authHeader[:len(prefix)] == prefix {
+ token = authHeader[len(prefix):]
+ break
+ }
+ }
+ if token == "" {
+ token = authHeader
+ }
+ }
+ }
+
+ // 3. URL 参数 token
+ if token == "" {
+ if t := c.Query("token"); t != "" {
+ token = t
+ }
+ }
+
+ if token == "" {
+ c.JSON(http.StatusUnauthorized, gin.H{
+ "Code": 40101,
+ "Message": "Unauthorized",
+ })
+ c.Abort()
+ return
+ }
+
+ // 解析 JWT
+ claims := &Claims{}
+ parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
+ if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, errors.New("unexpected signing method")
+ }
+ return []byte(secret), nil
+ })
+
+ if err != nil || !parsed.Valid || claims.UserID == "" {
+ c.JSON(http.StatusUnauthorized, gin.H{
+ "Code": 40101,
+ "Message": "Invalid token",
+ })
+ c.Abort()
+ return
+ }
+
+ c.Set(EmbyCtxUserID, claims.UserID)
+ c.Set(CtxUserID, claims.UserID)
+ c.Set(CtxUserRole, claims.Role)
+ c.Set(CtxUserTier, claims.Tier)
+ c.Next()
+ }
+}
+
+// GetEmbyUserID 从上下文中获取 Emby 用户 ID。
+func GetEmbyUserID(c *gin.Context) string {
+ if uid, exists := c.Get(EmbyCtxUserID); exists {
+ return uid.(string)
+ }
+ return ""
+}
diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go
index 3688b95..f5afe3b 100644
--- a/internal/middleware/middleware.go
+++ b/internal/middleware/middleware.go
@@ -1,5 +1,5 @@
-// Package middleware exposes Gin middlewares used by the HTTP server:
-// request logging, CORS, JWT authentication and admin guard.
+// Package middleware 暴露 Gin 中间件,用于 HTTP 服务器:
+// 请求日志、CORS、JWT 认证、管理员守卫和权限检查。
package middleware
import (
@@ -17,6 +17,7 @@ import (
const (
CtxUserID = "ctx_user_id"
CtxUserRole = "ctx_user_role"
+ CtxUserTier = "ctx_user_tier"
)
// RequestLogger logs one structured line per request.
@@ -65,6 +66,7 @@ func CORS(origins []string) gin.HandlerFunc {
type Claims struct {
UserID string `json:"uid"`
Role string `json:"role"`
+ Tier string `json:"tier,omitempty"`
jwt.RegisteredClaims
}
@@ -74,7 +76,7 @@ func AuthRequired(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
raw := extractToken(c)
if raw == "" {
- c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "message": "missing token"})
return
}
claims := &Claims{}
@@ -85,11 +87,12 @@ func AuthRequired(secret string) gin.HandlerFunc {
return []byte(secret), nil
})
if err != nil || claims.UserID == "" {
- c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "message": "invalid token"})
return
}
c.Set(CtxUserID, claims.UserID)
c.Set(CtxUserRole, claims.Role)
+ c.Set(CtxUserTier, claims.Tier)
c.Next()
}
}
@@ -99,13 +102,65 @@ func AdminRequired() gin.HandlerFunc {
return func(c *gin.Context) {
role, _ := c.Get(CtxUserRole)
if role != "admin" {
- c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin only"})
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40301, "message": "admin only"})
return
}
c.Next()
}
}
+// PlusOrAdminRequired enforces role == "admin" or tier == "plus".
+func PlusOrAdminRequired() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ role, _ := c.Get(CtxUserRole)
+ tier, _ := c.Get(CtxUserTier)
+ if role != "admin" && tier != "plus" {
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40301, "message": "plus or admin only"})
+ return
+ }
+ c.Next()
+ }
+}
+
+// GetUserID extracts the user ID from the Gin context.
+func GetUserID(c *gin.Context) string {
+ if uid, exists := c.Get(CtxUserID); exists {
+ return uid.(string)
+ }
+ return ""
+}
+
+// GetUserRole extracts the user role from the Gin context.
+func GetUserRole(c *gin.Context) string {
+ if role, exists := c.Get(CtxUserRole); exists {
+ return role.(string)
+ }
+ return ""
+}
+
+// GetUserTier extracts the user tier from the Gin context.
+func GetUserTier(c *gin.Context) string {
+ if tier, exists := c.Get(CtxUserTier); exists {
+ return tier.(string)
+ }
+ return ""
+}
+
+// IsAdmin checks if the current user is an admin.
+func IsAdmin(c *gin.Context) bool {
+ return GetUserRole(c) == "admin"
+}
+
+// IsPlus checks if the current user is a plus subscriber.
+func IsPlus(c *gin.Context) bool {
+ return GetUserTier(c) == "plus" || GetUserRole(c) == "admin"
+}
+
+// IsSuperUser checks if the current user is a super user (admin or plus).
+func IsSuperUser(c *gin.Context) bool {
+ return IsAdmin(c) || IsPlus(c)
+}
+
func extractToken(c *gin.Context) string {
if h := c.GetHeader("Authorization"); strings.HasPrefix(h, "Bearer ") {
return strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
diff --git a/internal/middleware/permission.go b/internal/middleware/permission.go
new file mode 100644
index 0000000..3502c44
--- /dev/null
+++ b/internal/middleware/permission.go
@@ -0,0 +1,111 @@
+// Package middleware — 权限检查中间件。
+// 注意:实际的权限检查在 handler 层通过 PermissionService 实现。
+// 此中间件主要用于设置上下文和基本的角色/等级检查。
+package middleware
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// RequirePermission 创建权限检查中间件标记。
+// 实际的权限检查由 handler 中的 PermissionService 执行。
+// 此中间件确保请求已经过身份验证。
+func RequirePermission(permissionKey string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ userID := GetUserID(c)
+ role := GetUserRole(c)
+ tier := GetUserTier(c)
+
+ if userID == "" {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
+ "code": 40101,
+ "message": "authentication required",
+ "data": nil,
+ })
+ return
+ }
+
+ // admin 和 plus 用户拥有所有权限
+ if role == "admin" || tier == "plus" {
+ c.Next()
+ return
+ }
+
+ // 将权限键存储到上下文中供 handler 使用
+ c.Set("permission_key", permissionKey)
+ c.Next()
+ }
+}
+
+// RequireAnyPermission 创建需要任意一个权限的中间件标记。
+func RequireAnyPermission(permissionKeys ...string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ userID := GetUserID(c)
+ role := GetUserRole(c)
+ tier := GetUserTier(c)
+
+ if userID == "" {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
+ "code": 40101,
+ "message": "authentication required",
+ "data": nil,
+ })
+ return
+ }
+
+ // admin 和 plus 用户拥有所有权限
+ if role == "admin" || tier == "plus" {
+ c.Next()
+ return
+ }
+
+ // 将权限键数组存储到上下文中供 handler 使用
+ c.Set("permission_keys", permissionKeys)
+ c.Next()
+ }
+}
+
+// RequireAllPermissions 创建需要所有权限的中间件标记。
+func RequireAllPermissions(permissionKeys ...string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ userID := GetUserID(c)
+ role := GetUserRole(c)
+ tier := GetUserTier(c)
+
+ if userID == "" {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
+ "code": 40101,
+ "message": "authentication required",
+ "data": nil,
+ })
+ return
+ }
+
+ // admin 和 plus 用户拥有所有权限
+ if role == "admin" || tier == "plus" {
+ c.Next()
+ return
+ }
+
+ c.Set("permission_keys", permissionKeys)
+ c.Next()
+ }
+}
+
+// GetPermissionKey 从上下文中获取存储的权限键。
+func GetPermissionKey(c *gin.Context) string {
+ if key, exists := c.Get("permission_key"); exists {
+ return key.(string)
+ }
+ return ""
+}
+
+// GetPermissionKeys 从上下文中获取存储的权限键数组。
+func GetPermissionKeys(c *gin.Context) []string {
+ if keys, exists := c.Get("permission_keys"); exists {
+ return keys.([]string)
+ }
+ return nil
+}
diff --git a/internal/model/api_config.go b/internal/model/api_config.go
new file mode 100644
index 0000000..3409909
--- /dev/null
+++ b/internal/model/api_config.go
@@ -0,0 +1,62 @@
+// Package model 定义第三方 API 配置数据模型。
+package model
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// ApiConfig 存储第三方 API 密钥和配置信息。
+// APIKey 字段在 JSON 序列化时隐藏(json:"-"),通过加密存储。
+type ApiConfig struct {
+ ID string `gorm:"primaryKey;size:36" json:"id"`
+ Provider string `gorm:"size:64;uniqueIndex;not null" json:"provider"`
+ APIKey string `gorm:"size:512" json:"-"`
+ BaseURL string `gorm:"size:512" json:"base_url,omitempty"`
+ Extra string `gorm:"type:text" json:"extra,omitempty"`
+ Enabled bool `gorm:"default:true" json:"enabled"`
+ Description string `gorm:"size:255" json:"description,omitempty"`
+ LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
+ TestResult string `gorm:"size:32" json:"test_result,omitempty"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// BeforeCreate 生成 UUID。
+func (c *ApiConfig) BeforeCreate(_ *gorm.DB) error {
+ if c.ID == "" {
+ c.ID = uuid.NewString()
+ }
+ return nil
+}
+
+// BeforeUpdate 更新时间戳。
+func (c *ApiConfig) BeforeUpdate(_ *gorm.DB) error {
+ c.UpdatedAt = time.Now()
+ return nil
+}
+
+// ApiProvider 定义支持的 API 提供者列表。
+type ApiProvider struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ HasAPIKey bool `json:"has_api_key"`
+ HasBaseURL bool `json:"has_base_url"`
+}
+
+// PredefinedProviders 返回预定义的 API 提供者列表。
+func PredefinedProviders() []ApiProvider {
+ return []ApiProvider{
+ {ID: "tmdb", Name: "TMDb", Description: "The Movie Database - 电影/剧集元数据", HasAPIKey: true, HasBaseURL: true},
+ {ID: "douban", Name: "豆瓣", Description: "豆瓣电影/音乐/书籍数据", HasAPIKey: true, HasBaseURL: false},
+ {ID: "bangumi", Name: "Bangumi", Description: "番剧/动漫数据库", HasAPIKey: true, HasBaseURL: false},
+ {ID: "thetvdb", Name: "TheTVDB", Description: "TV Series Database", HasAPIKey: true, HasBaseURL: false},
+ {ID: "fanart", Name: "Fanart.tv", Description: "影视海报/背景图", HasAPIKey: true, HasBaseURL: false},
+ {ID: "openai", Name: "OpenAI", Description: "GPT 系列模型", HasAPIKey: true, HasBaseURL: true},
+ {ID: "deepseek", Name: "DeepSeek", Description: "DeepSeek 大模型", HasAPIKey: true, HasBaseURL: true},
+ {ID: "siliconflow", Name: "SiliconFlow", Description: "AI 模型聚合 API", HasAPIKey: true, HasBaseURL: true},
+ {ID: "adult", Name: "Adult API", Description: "Adult 内容元数据(需额外权限)", HasAPIKey: true, HasBaseURL: false},
+ }
+}
diff --git a/internal/model/download_client.go b/internal/model/download_client.go
new file mode 100644
index 0000000..45fca74
--- /dev/null
+++ b/internal/model/download_client.go
@@ -0,0 +1,16 @@
+// 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/emby_types.go b/internal/model/emby_types.go
new file mode 100644
index 0000000..23e2ea2
--- /dev/null
+++ b/internal/model/emby_types.go
@@ -0,0 +1,530 @@
+// Package model — Emby API 兼容层请求/响应类型。
+package model
+
+import "time"
+
+// ─── Emby 认证 ────────────────────────────────────────────────────────────────
+
+// EmbyAuthRequest Emby 认证请求(用户名+密码)。
+type EmbyAuthRequest struct {
+ Username string `json:"Username"`
+ Password string `json:"Password"`
+}
+
+// EmbyAuthResponse Emby 认证响应。
+type EmbyAuthResponse struct {
+ User EmbyUser `json:"User"`
+ AccessToken string `json:"AccessToken"`
+ ServerID string `json:"ServerId"`
+}
+
+// EmbyApiKeyAuthRequest API Key 认证请求。
+type EmbyApiKeyAuthRequest struct {
+ ApiKey string `json:"ApiKey"`
+}
+
+// ─── Emby 用户 ────────────────────────────────────────────────────────────────
+
+// EmbyUser Emby 用户信息。
+type EmbyUser struct {
+ Id string `json:"Id"`
+ Name string `json:"Name"`
+ ServerId string `json:"ServerId"`
+ HasPassword bool `json:"HasPassword"`
+ PrimaryImageTag string `json:"PrimaryImageTag,omitempty"`
+ Configuration *EmbyUserConfiguration `json:"Configuration,omitempty"`
+ LastActivityDate *time.Time `json:"LastActivityDate,omitempty"`
+ LastLoginDate *time.Time `json:"LastLoginDate,omitempty"`
+}
+
+// EmbyUserConfiguration 用户配置。
+type EmbyUserConfiguration struct {
+ AudioLanguagePreference string `json:"AudioLanguagePreference"`
+ SubtitleLanguagePreference string `json:"SubtitleLanguagePreference"`
+ EnableAutoPlay bool `json:"EnableAutoPlay"`
+ EnableNextEpisodeAutoPlay bool `json:"EnableNextEpisodeAutoPlay"`
+}
+
+// ─── Emby 系统信息 ────────────────────────────────────────────────────────────
+
+// EmbySystemInfo 系统信息。
+type EmbySystemInfo struct {
+ Id string `json:"Id"`
+ ServerName string `json:"ServerName"`
+ Version string `json:"Version"`
+ ProductName string `json:"ProductName"`
+ OperatingSystem string `json:"OperatingSystem"`
+ Architecture string `json:"Architecture"`
+ LocalAddress string `json:"LocalAddress"`
+ WanAddress string `json:"WanAddress,omitempty"`
+ HasPendingRestart bool `json:"HasPendingRestart"`
+ IsShuttingDown bool `json:"IsShuttingDown"`
+ SupportsLibraryScan bool `json:"SupportsLibraryScan"`
+ SupportsHttps bool `json:"SupportsHttps"`
+ SupportsAutoDiscovery bool `json:"SupportsAutoDiscovery"`
+ WebSocketPortNumber int `json:"WebSocketPortNumber"`
+ TranscodingTempPath string `json:"TranscodingTempPath,omitempty"`
+ CanSelfUpdate bool `json:"CanSelfUpdate"`
+ CanLaunchWebBrowser bool `json:"CanLaunchWebBrowser"`
+ CanRestart bool `json:"CanRestart"`
+ CodecCount int `json:"CodecCount"`
+}
+
+// EmbyLogEntry 日志条目。
+type EmbyLogEntry struct {
+ Id string `json:"Id"`
+ DateCreated time.Time `json:"DateCreated"`
+ Level string `json:"Level"`
+ Message string `json:"Message"`
+}
+
+// EmbyServerConfiguration 服务器配置。
+type EmbyServerConfiguration struct {
+ Name string `json:"Name"`
+ ServerName string `json:"ServerName"`
+ EnableUPnP bool `json:"EnableUPnP"`
+ PublicPort int `json:"PublicPort"`
+ EnableHttps bool `json:"EnableHttps"`
+ HttpServerPortNumber int `json:"HttpServerPortNumber"`
+ HttpsPortNumber int `json:"HttpsPortNumber"`
+ EnableRemoteAccess bool `json:"EnableRemoteAccess"`
+}
+
+// EmbySession 会话信息。
+type EmbySession struct {
+ Id string `json:"Id"`
+ Client string `json:"Client"`
+ ClientVersion string `json:"ClientVersion"`
+ DeviceId string `json:"DeviceId"`
+ DeviceName string `json:"DeviceName"`
+ UserName string `json:"UserName,omitempty"`
+ UserId string `json:"UserId,omitempty"`
+ LastActivityDate *time.Time `json:"LastActivityDate,omitempty"`
+ RemoteEndPoint string `json:"RemoteEndPoint,omitempty"`
+ NowPlayingItem *EmbyItem `json:"NowPlayingItem,omitempty"`
+ PlayState *EmbyPlaybackState `json:"PlayState,omitempty"`
+ Capabilities EmbyClientCapabilities `json:"Capabilities,omitempty"`
+ SupportsRemoteControl bool `json:"SupportsRemoteControl"`
+ AdditionalUsers []EmbySessionUserInfo `json:"AdditionalUsers,omitempty"`
+}
+
+// EmbySessionUserInfo 会话中的附加用户。
+type EmbySessionUserInfo struct {
+ UserId string `json:"UserId"`
+ UserName string `json:"UserName"`
+}
+
+// EmbyPlaybackState 播放状态。
+type EmbyPlaybackState struct {
+ PositionTicks int64 `json:"PositionTicks"`
+ VolumeLevel int `json:"VolumeLevel"`
+ IsMuted bool `json:"IsMuted"`
+ IsPaused bool `json:"IsPaused"`
+ PlayMethod string `json:"PlayMethod,omitempty"`
+ CanSeek bool `json:"CanSeek"`
+}
+
+// EmbyClientCapabilities 客户端能力描述。
+type EmbyClientCapabilities struct {
+ PlayableMediaTypes []string `json:"PlayableMediaTypes"`
+ SupportedCommands []string `json:"SupportedCommands"`
+ SupportsMediaControl bool `json:"SupportsMediaControl"`
+ SupportsSync bool `json:"SupportsSync"`
+}
+
+// ─── Emby 虚拟文件夹 / 媒体库 ────────────────────────────────────────────────
+
+// EmbyVirtualFolder 虚拟文件夹(媒体库)。
+type EmbyVirtualFolder struct {
+ Name string `json:"Name"`
+ Locations []string `json:"Locations"`
+ CollectionType string `json:"CollectionType"`
+ LibraryOptions EmbyLibraryOptions `json:"LibraryOptions,omitempty"`
+ RefreshStatus *EmbyRefreshStatus `json:"RefreshStatus,omitempty"`
+ ItemId string `json:"ItemId"`
+}
+
+// EmbyLibraryOptions 媒体库选项。
+type EmbyLibraryOptions struct {
+ PreferredMetadataLanguage string `json:"PreferredMetadataLanguage"`
+ MetadataCountryCode string `json:"MetadataCountryCode"`
+ EnableRealtimeMonitor bool `json:"EnableRealtimeMonitor"`
+ EnableAutomaticSeriesGrouping bool `json:"EnableAutomaticSeriesGrouping"`
+}
+
+// EmbyRefreshStatus 刷新状态。
+type EmbyRefreshStatus struct {
+ LastRefreshResult string `json:"LastRefreshResult"`
+ LastRefreshedAt time.Time `json:"LastRefreshedAt"`
+ IsActive bool `json:"IsActive"`
+}
+
+// EmbyItemsCounts 项目计数。
+type EmbyItemsCounts struct {
+ MovieCount int `json:"MovieCount"`
+ SeriesCount int `json:"SeriesCount"`
+ EpisodeCount int `json:"EpisodeCount"`
+ ArtistCount int `json:"ArtistCount"`
+ AlbumCount int `json:"AlbumCount"`
+ SongCount int `json:"SongCount"`
+ MusicVideoCount int `json:"MusicVideoCount"`
+ BookCount int `json:"BookCount"`
+ BoxSetCount int `json:"BoxSetCount"`
+}
+
+// ─── Emby Items ───────────────────────────────────────────────────────────────
+
+// EmbyItemsResponse Emby 标准分页响应包装。
+type EmbyItemsResponse struct {
+ Items []EmbyItem `json:"Items"`
+ TotalRecordCount int `json:"TotalRecordCount"`
+ StartIndex int `json:"StartIndex"`
+}
+
+// EmbyItem Emby 媒体项。
+type EmbyItem struct {
+ Id string `json:"Id"`
+ Name string `json:"Name"`
+ Type string `json:"Type"` // Movie / Series / Episode / Season / BoxSet / Folder
+ Overview string `json:"Overview,omitempty"`
+ ProductionYear int `json:"ProductionYear,omitempty"`
+ PremiereDate *time.Time `json:"PremiereDate,omitempty"`
+ CommunityRating float64 `json:"CommunityRating,omitempty"`
+ OfficialRating string `json:"OfficialRating,omitempty"`
+ RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
+ ParentId string `json:"ParentId,omitempty"`
+ SeriesId string `json:"SeriesId,omitempty"`
+ SeasonId string `json:"SeasonId,omitempty"`
+ IndexNumber int `json:"IndexNumber,omitempty"`
+ ParentIndexNumber int `json:"ParentIndexNumber,omitempty"`
+ UserData *EmbyUserData `json:"UserData,omitempty"`
+ ImageTags map[string]string `json:"ImageTags,omitempty"`
+ BackdropImageTags []string `json:"BackdropImageTags,omitempty"`
+ Genres []string `json:"Genres,omitempty"`
+ Studios []EmbyNameId `json:"Studios,omitempty"`
+ People []EmbyPerson `json:"People,omitempty"`
+ MediaSources []EmbyMediaSource `json:"MediaSources,omitempty"`
+ RecursiveItemCount int `json:"RecursiveItemCount,omitempty"`
+ ChildCount int `json:"ChildCount,omitempty"`
+ Status string `json:"Status,omitempty"`
+ AirDays []string `json:"AirDays,omitempty"`
+ EndDate *time.Time `json:"EndDate,omitempty"`
+ ProviderIds map[string]string `json:"ProviderIds,omitempty"`
+ Taglines []string `json:"Taglines,omitempty"`
+ GenreItems []EmbyNameId `json:"GenreItems,omitempty"`
+ DateCreated *time.Time `json:"DateCreated,omitempty"`
+ Path string `json:"Path,omitempty"`
+ SortName string `json:"SortName,omitempty"`
+ ForcedSortName string `json:"ForcedSortName,omitempty"`
+ Width int `json:"Width,omitempty"`
+ Height int `json:"Height,omitempty"`
+ Container string `json:"Container,omitempty"`
+}
+
+// EmbyUserData 用户播放数据。
+type EmbyUserData struct {
+ PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
+ PlayCount int `json:"PlayCount"`
+ IsFavorite bool `json:"IsFavorite"`
+ Played bool `json:"Played"`
+ UnplayedItemCount int `json:"UnplayedItemCount"`
+ PercentagePlayed float64 `json:"PercentagePlayed"`
+ Rating float64 `json:"Rating,omitempty"`
+ PlayedPercentage float64 `json:"PlayedPercentage,omitempty"`
+}
+
+// EmbyMediaSource 媒体源。
+type EmbyMediaSource struct {
+ Id string `json:"Id"`
+ Name string `json:"Name"`
+ Path string `json:"Path"`
+ Size int64 `json:"Size"`
+ Container string `json:"Container,omitempty"`
+ Bitrate int64 `json:"Bitrate,omitempty"`
+ MediaStreams []EmbyMediaStream `json:"MediaStreams"`
+ SupportsTranscoding bool `json:"SupportsTranscoding"`
+ SupportsDirectStream bool `json:"SupportsDirectStream"`
+ SupportsDirectPlay bool `json:"SupportsDirectPlay"`
+ TranscodingUrl string `json:"TranscodingUrl,omitempty"`
+ Protocol string `json:"Protocol,omitempty"`
+ Type string `json:"Type,omitempty"`
+ IsRemote bool `json:"IsRemote,omitempty"`
+ RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
+ ETag string `json:"ETag,omitempty"`
+ SupportsProbing bool `json:"SupportsProbing,omitempty"`
+}
+
+// EmbyMediaStream 媒体流(视频/音频/字幕)。
+type EmbyMediaStream struct {
+ Codec string `json:"Codec"`
+ Type string `json:"Type"` // Video / Audio / Subtitle
+ Language string `json:"Language,omitempty"`
+ DisplayTitle string `json:"DisplayTitle,omitempty"`
+ Index int `json:"Index"`
+ IsDefault bool `json:"IsDefault"`
+ IsForced bool `json:"IsForced"`
+ IsExternal bool `json:"IsExternal,omitempty"`
+ Height int `json:"Height,omitempty"`
+ Width int `json:"Width,omitempty"`
+ BitRate int64 `json:"BitRate,omitempty"`
+ Channels int `json:"Channels,omitempty"`
+ SampleRate int `json:"SampleRate,omitempty"`
+ AspectRatio string `json:"AspectRatio,omitempty"`
+ VideoRange string `json:"VideoRange,omitempty"`
+ DeliveryUrl string `json:"DeliveryUrl,omitempty"`
+ DeliveryMethod string `json:"DeliveryMethod,omitempty"`
+ ExternalUrl string `json:"ExternalUrl,omitempty"`
+ ExternalSubtitleId string `json:"ExternalSubtitleId,omitempty"`
+ SubtitleFileName string `json:"SubtitleFileName,omitempty"`
+ Title string `json:"Title,omitempty"`
+ Comment string `json:"Comment,omitempty"`
+ Path string `json:"Path,omitempty"`
+}
+
+// EmbyPerson 人员信息。
+type EmbyPerson struct {
+ Id string `json:"Id"`
+ Name string `json:"Name"`
+ Role string `json:"Role,omitempty"`
+ Type string `json:"Type,omitempty"`
+ PrimaryImageTag string `json:"PrimaryImageTag,omitempty"`
+}
+
+// EmbyNameId 名称+ID 对。
+type EmbyNameId struct {
+ Name string `json:"Name"`
+ Id string `json:"Id"`
+}
+
+// ─── Emby PlaybackInfo ────────────────────────────────────────────────────────
+
+// EmbyPlaybackInfoRequest 播放信息请求。
+type EmbyPlaybackInfoRequest struct {
+ UserId string `json:"UserId,omitempty"`
+ MaxStreamingBitrate int64 `json:"MaxStreamingBitrate,omitempty"`
+ StartTimeTicks int64 `json:"StartTimeTicks,omitempty"`
+ AudioStreamIndex int `json:"AudioStreamIndex,omitempty"`
+ SubtitleStreamIndex int `json:"SubtitleStreamIndex,omitempty"`
+ MaxAudioChannels int `json:"MaxAudioChannels,omitempty"`
+ ItemId string `json:"ItemId,omitempty"`
+ DeviceProfile *EmbyDeviceProfile `json:"DeviceProfile,omitempty"`
+ EnableDirectStream bool `json:"EnableDirectStream,omitempty"`
+ EnableDirectPlay bool `json:"EnableDirectPlay,omitempty"`
+ AutoOpenLiveStream bool `json:"AutoOpenLiveStream,omitempty"`
+}
+
+// EmbyPlaybackInfoResponse 播放信息响应。
+type EmbyPlaybackInfoResponse struct {
+ MediaSources []EmbyMediaSource `json:"MediaSources"`
+ PlaySessionId string `json:"PlaySessionId"`
+}
+
+// EmbyDeviceProfile 设备配置文件。
+type EmbyDeviceProfile struct {
+ Name string `json:"Name,omitempty"`
+ MaxStaticBitrate int `json:"MaxStaticBitrate,omitempty"`
+ MaxStreamingBitrate int `json:"MaxStreamingBitrate,omitempty"`
+ MusicStreamingTranscodingBitrate int `json:"MusicStreamingTranscodingBitrate,omitempty"`
+ DirectPlayProfiles []EmbyDirectPlayProfile `json:"DirectPlayProfiles,omitempty"`
+ TranscodingProfiles []EmbyTranscodingProfile `json:"TranscodingProfiles,omitempty"`
+ ContainerProfiles []EmbyContainerProfile `json:"ContainerProfiles,omitempty"`
+ CodecProfiles []EmbyCodecProfile `json:"CodecProfiles,omitempty"`
+ SubtitleProfiles []EmbySubtitleProfile `json:"SubtitleProfiles,omitempty"`
+}
+
+// EmbyDirectPlayProfile 直接播放配置。
+type EmbyDirectPlayProfile struct {
+ Container string `json:"Container,omitempty"`
+ AudioCodec string `json:"AudioCodec,omitempty"`
+ VideoCodec string `json:"VideoCodec,omitempty"`
+ Type string `json:"Type,omitempty"`
+}
+
+// EmbyTranscodingProfile 转码配置。
+type EmbyTranscodingProfile struct {
+ Container string `json:"Container,omitempty"`
+ Type string `json:"Type,omitempty"`
+ VideoCodec string `json:"VideoCodec,omitempty"`
+ AudioCodec string `json:"AudioCodec,omitempty"`
+ Protocol string `json:"Protocol,omitempty"`
+ EstimateContentLength bool `json:"EstimateContentLength,omitempty"`
+ EnableMpegtsM2TsMode bool `json:"EnableMpegtsM2TsMode,omitempty"`
+ TranscodeSeekInfo string `json:"TranscodeSeekInfo,omitempty"`
+ Context string `json:"Context,omitempty"`
+ EnableSubtitlesInManifest bool `json:"EnableSubtitlesInManifest,omitempty"`
+ MaxAudioChannels int `json:"MaxAudioChannels,omitempty"`
+ MinSegments int `json:"MinSegments,omitempty"`
+ SegmentLength int `json:"SegmentLength,omitempty"`
+ BreakOnNonKeyFrames bool `json:"BreakOnNonKeyFrames,omitempty"`
+}
+
+// EmbyContainerProfile 容器配置。
+type EmbyContainerProfile struct {
+ Type string `json:"Type,omitempty"`
+ Conditions []string `json:"Conditions,omitempty"`
+ Container string `json:"Container,omitempty"`
+}
+
+// EmbyCodecProfile 编解码器配置。
+type EmbyCodecProfile struct {
+ Type string `json:"Type,omitempty"`
+ Conditions []EmbyProfileCondition `json:"Conditions,omitempty"`
+ Codec string `json:"Codec,omitempty"`
+ Container string `json:"Container,omitempty"`
+}
+
+// EmbyProfileCondition 配置条件。
+type EmbyProfileCondition struct {
+ Condition string `json:"Condition,omitempty"`
+ Property string `json:"Property,omitempty"`
+ Value string `json:"Value,omitempty"`
+ IsRequired bool `json:"IsRequired,omitempty"`
+}
+
+// EmbySubtitleProfile 字幕配置。
+type EmbySubtitleProfile struct {
+ Format string `json:"Format,omitempty"`
+ Method string `json:"Method,omitempty"`
+ DidlMode string `json:"DidlMode,omitempty"`
+ Language string `json:"Language,omitempty"`
+ Container string `json:"Container,omitempty"`
+}
+
+// ─── Emby 播放进度上报 ────────────────────────────────────────────────────────
+
+// EmbyPlaybackProgressRequest 播放进度上报。
+type EmbyPlaybackProgressRequest struct {
+ CanSeek bool `json:"CanSeek"`
+ ItemId string `json:"ItemId"`
+ MediaSourceId string `json:"MediaSourceId,omitempty"`
+ PositionTicks int64 `json:"PositionTicks"`
+ RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
+ IsPaused bool `json:"IsPaused"`
+ IsMuted bool `json:"IsMuted"`
+ VolumeLevel int `json:"VolumeLevel,omitempty"`
+ PlayMethod string `json:"PlayMethod,omitempty"`
+ PlaySessionId string `json:"PlaySessionId,omitempty"`
+ LiveStreamId string `json:"LiveStreamId,omitempty"`
+ QueueableMediaTypes []string `json:"QueueableMediaTypes,omitempty"`
+}
+
+// EmbyStopPlaybackRequest 停止播放上报。
+type EmbyStopPlaybackRequest struct {
+ ItemId string `json:"ItemId"`
+ MediaSourceId string `json:"MediaSourceId,omitempty"`
+ PositionTicks int64 `json:"PositionTicks"`
+ RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
+ PlaySessionId string `json:"PlaySessionId,omitempty"`
+ LiveStreamId string `json:"LiveStreamId,omitempty"`
+}
+
+// EmbyUserDataRequest 用户数据更新。
+type EmbyUserDataRequest struct {
+ PlaybackPositionTicks int64 `json:"PlaybackPositionTicks,omitempty"`
+ PlayCount int `json:"PlayCount,omitempty"`
+ IsFavorite bool `json:"IsFavorite,omitempty"`
+ Played bool `json:"Played,omitempty"`
+ PlayedPercentage float64 `json:"PlayedPercentage,omitempty"`
+}
+
+// ─── Emby Hubs ────────────────────────────────────────────────────────────────
+
+// EmbyHubResponse Hub 响应。
+type EmbyHubResponse struct {
+ Items []EmbyHubItem `json:"Items"`
+}
+
+// EmbyHubItem Hub 条目。
+type EmbyHubItem struct {
+ Id string `json:"Id"`
+ Name string `json:"Name"`
+ Type string `json:"Type"`
+ Items []EmbyItem `json:"Items"`
+ TotalCount int `json:"TotalCount,omitempty"`
+ ImageUrl string `json:"ImageUrl,omitempty"`
+}
+
+// ─── Emby 字幕 ────────────────────────────────────────────────────────────────
+
+// EmbyRemoteSubtitleInfo 远程字幕信息。
+type EmbyRemoteSubtitleInfo struct {
+ ThreeLetterISOLanguageName string `json:"ThreeLetterISOLanguageName"`
+ Id string `json:"Id"`
+ ProviderName string `json:"ProviderName"`
+ Name string `json:"Name"`
+ Format string `json:"Format"`
+ Author string `json:"Author"`
+ Comment string `json:"Comment"`
+ DateCreated *time.Time `json:"DateCreated,omitempty"`
+ CommunityRating float64 `json:"CommunityRating,omitempty"`
+ DownloadCount int `json:"DownloadCount"`
+ IsHashMatch bool `json:"IsHashMatch,omitempty"`
+ IsForced bool `json:"IsForced,omitempty"`
+ IsHearingImpaired bool `json:"IsHearingImpaired,omitempty"`
+}
+
+// EmbySubtitleSearchRequest 字幕搜索请求。
+type EmbySubtitleSearchRequest struct {
+ ItemId string `json:"ItemId"`
+ Language string `json:"Language"`
+ IsPerfectMatch bool `json:"IsPerfectMatch,omitempty"`
+}
+
+// EmbyImageRemoteInfo 远程图片信息。
+type EmbyImageRemoteInfo struct {
+ Providers []EmbyImageProviderInfo `json:"Providers"`
+ TotalRecordCount int `json:"TotalRecordCount"`
+}
+
+// EmbyImageProviderInfo 图片提供者信息。
+type EmbyImageProviderInfo struct {
+ Name string `json:"Name"`
+ RemoteImages []EmbyRemoteImageInfo `json:"RemoteImages,omitempty"`
+ SupportedImages []string `json:"SupportedImages"`
+}
+
+// EmbyRemoteImageInfo 远程图片信息。
+type EmbyRemoteImageInfo struct {
+ Url string `json:"Url"`
+ ThumbnailUrl string `json:"ThumbnailUrl,omitempty"`
+ Height int `json:"Height"`
+ Width int `json:"Width"`
+ CommunityRating float64 `json:"CommunityRating,omitempty"`
+ VoteCount int `json:"VoteCount,omitempty"`
+ Language string `json:"Language,omitempty"`
+ Type string `json:"Type"`
+ RatingType string `json:"RatingType,omitempty"`
+ ProviderName string `json:"ProviderName"`
+}
+
+// ─── Emby Genre / Person ─────────────────────────────────────────────────────
+
+// EmbyGenre 类型。
+type EmbyGenre struct {
+ Name string `json:"Name"`
+ Id string `json:"Id,omitempty"`
+}
+
+// EmbyPersonInfo 人物信息。
+type EmbyPersonInfo struct {
+ Id string `json:"Id"`
+ Name string `json:"Name"`
+ Type string `json:"Type,omitempty"`
+ PrimaryImageTag string `json:"PrimaryImageTag,omitempty"`
+ Overview string `json:"Overview,omitempty"`
+ BirthDate string `json:"BirthDate,omitempty"`
+ ProductionYear int `json:"ProductionYear,omitempty"`
+ EndDate string `json:"EndDate,omitempty"`
+ PremiereDate *time.Time `json:"PremiereDate,omitempty"`
+}
+
+// ─── Emby Active Encoding ────────────────────────────────────────────────────
+
+// EmbyActiveEncodingRequest 活跃编码请求(客户端报告转码进度)。
+type EmbyActiveEncodingRequest struct {
+ PlaySessionId string `json:"PlaySessionId"`
+ When string `json:"When"`
+ PositionTicks int64 `json:"PositionTicks,omitempty"`
+ IsPaused bool `json:"IsPaused,omitempty"`
+ IsUserPaused bool `json:"IsUserPaused,omitempty"`
+}
diff --git a/internal/model/model.go b/internal/model/model.go
index c82588a..d1d65a8 100644
--- a/internal/model/model.go
+++ b/internal/model/model.go
@@ -1,6 +1,5 @@
-// Package model defines GORM data models and the registry used by
-// auto-migration. Each subsystem in MediaStationGo owns a slice of tables
-// here; AllModels returns the union for db.AutoMigrate.
+// Package model 定义 GORM 数据模型和自动迁移使用的注册表。
+// 每个子系统在 MediaStationGo 中拥有一个表切片;AllModels 返回联合以供 db.AutoMigrate 使用。
package model
import (
@@ -10,11 +9,11 @@ import (
"gorm.io/gorm"
)
-// Base captures the fields embedded in every domain entity:
+// Base 嵌入每个域实体共享的字段:
//
-// - ID: UUID v4 string primary key.
-// - CreatedAt / UpdatedAt: managed by GORM.
-// - DeletedAt: soft-delete (queries auto-filter on it).
+// - ID: UUID v4 字符串主键
+// - CreatedAt / UpdatedAt: 由 GORM 管理
+// - DeletedAt: 软删除(查询自动过滤)
type Base struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
CreatedAt time.Time `json:"created_at"`
@@ -22,7 +21,7 @@ type Base struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
-// BeforeCreate generates a UUID if the caller did not supply one.
+// BeforeCreate 如果调用者未提供则生成 UUID。
func (b *Base) BeforeCreate(_ *gorm.DB) error {
if b.ID == "" {
b.ID = uuid.NewString()
@@ -30,20 +29,23 @@ func (b *Base) BeforeCreate(_ *gorm.DB) error {
return nil
}
-// User is a local account. The first registered admin (or seeded admin)
-// gains the "admin" role; everyone else defaults to "user".
+// User 是本地账户。第一个注册的管理员(或种子管理员)获得 "admin" 角色;
+// 其他所有用户默认为 "user"。
type User struct {
Base
Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
PasswordHash string `gorm:"size:128;not null" json:"-"`
Role string `gorm:"size:16;not null;default:user" json:"role"`
+ Tier string `gorm:"size:16;default:free" json:"tier"` // free / plus
+ Nickname string `gorm:"size:128" json:"nickname,omitempty"`
Email string `gorm:"size:128" json:"email,omitempty"`
AvatarURL string `gorm:"size:255" json:"avatar_url,omitempty"`
ForcePasswordReset bool `gorm:"default:false" json:"force_password_reset"`
+ IsActive bool `gorm:"default:true" json:"is_active"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
}
-// Library represents a user-defined media root directory.
+// Library 表示用户定义的媒体根目录。
type Library struct {
Base
Name string `gorm:"size:128;not null" json:"name"`
@@ -52,8 +54,7 @@ type Library struct {
Enabled bool `gorm:"default:true" json:"enabled"`
}
-// Media is a single playable item. Series episodes link to a SeriesID; movies
-// have SeriesID == "".
+// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。
type Media struct {
Base
LibraryID string `gorm:"index;size:36" json:"library_id"`
@@ -116,7 +117,7 @@ type APIConfig struct {
Description string `gorm:"size:255" json:"description,omitempty"`
}
-// Series groups episodes that belong to the same show.
+// Series 将属于同一节目的剧集分组。
type Series struct {
Base
LibraryID string `gorm:"index;size:36" json:"library_id"`
@@ -130,7 +131,7 @@ type Series struct {
BangumiID int `json:"bangumi_id"`
}
-// PlaybackHistory records the current playback position for resume support.
+// PlaybackHistory 记录当前播放位置以支持续播。
type PlaybackHistory struct {
Base
UserID string `gorm:"index;size:36;not null" json:"user_id"`
@@ -141,14 +142,14 @@ type PlaybackHistory struct {
Completed bool `json:"completed"`
}
-// Favorite marks a media item as favourite for a given user.
+// Favorite 将媒体项标记为给定用户的收藏。
type Favorite struct {
Base
UserID string `gorm:"index;size:36;not null;uniqueIndex:uniq_user_media" json:"user_id"`
MediaID string `gorm:"index;size:36;not null;uniqueIndex:uniq_user_media" json:"media_id"`
}
-// Playlist is a user-curated, ordered list of media items.
+// Playlist 是用户策划的、有序的媒体列表。
type Playlist struct {
Base
UserID string `gorm:"index;size:36;not null" json:"user_id"`
@@ -156,7 +157,7 @@ type Playlist struct {
IsPublic bool `gorm:"default:false" json:"is_public"`
}
-// PlaylistItem is the join table between Playlists and Media with ordering.
+// PlaylistItem 是 Playlist 和 Media 的连接表,带有排序。
type PlaylistItem struct {
Base
PlaylistID string `gorm:"index;size:36;not null" json:"playlist_id"`
@@ -164,7 +165,7 @@ type PlaylistItem struct {
Position int `json:"position"`
}
-// DownloadTask is an outstanding (or completed) torrent / HTTP download.
+// DownloadTask 是待处理(或已完成)的 torrent / HTTP 下载。
type DownloadTask struct {
Base
UserID string `gorm:"index;size:36" json:"user_id"`
@@ -175,27 +176,25 @@ type DownloadTask struct {
Progress float32 `json:"progress"`
}
-// Subscription is an automation rule that polls an RSS feed and queues
-// matching torrents into the configured download client.
+// Subscription 是自动化规则,轮询 RSS 源并将匹配种子排队到配置的下载客户端。
type Subscription struct {
Base
- UserID string `gorm:"index;size:36" json:"user_id"`
- 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"`
- Enabled bool `gorm:"default:true" json:"enabled"`
+ UserID string `gorm:"index;size:36" json:"user_id"`
+ 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"`
+ Enabled bool `gorm:"default:true" json:"enabled"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
}
-// Setting is a single key/value system-wide preference (used by the admin UI).
+// Setting 是单个键/值系统级偏好(供管理 UI 使用)。
type Setting struct {
Key string `gorm:"primaryKey;size:128" json:"key"`
Value string `gorm:"type:text" json:"value"`
UpdatedAt time.Time `json:"updated_at"`
}
-// AccessLog is a structured audit-trail entry. Stored in SQLite for the
-// admin Activity panel.
+// AccessLog 是结构化审计跟踪条目。存储在 SQLite 中供管理活动面板使用。
type AccessLog struct {
Base
UserID string `gorm:"index;size:36" json:"user_id"`
@@ -205,7 +204,7 @@ type AccessLog struct {
Detail string `gorm:"type:text" json:"detail"`
}
-// AllModels returns the slice consumed by gorm.AutoMigrate.
+// AllModels 返回 gorm.AutoMigrate 使用的切片。
func AllModels() []interface{} {
return []interface{}{
&User{},
@@ -221,5 +220,12 @@ func AllModels() []interface{} {
&Setting{},
&AccessLog{},
&APIConfig{},
+ &UserPermission{},
+ &RefreshToken{},
+ &ApiConfig{},
+ &DownloadClient{},
+ &NotifyChannel{},
+ &Site{},
+ &STRMRecord{},
}
}
diff --git a/internal/model/notify_channel.go b/internal/model/notify_channel.go
new file mode 100644
index 0000000..a31f27c
--- /dev/null
+++ b/internal/model/notify_channel.go
@@ -0,0 +1,14 @@
+// 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
new file mode 100644
index 0000000..45ebc6b
--- /dev/null
+++ b/internal/model/permission.go
@@ -0,0 +1,103 @@
+// Package model 定义权限相关的数据模型。
+package model
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// UserPermission 定义用户细粒度权限(19项)。
+// 默认开启(6项):CanViewDashboard, CanPlayMedia, CanCast, CanExternalPlayer, CanFavorite, CanViewHistory
+// 默认关闭(13项):其他权限需要管理员分配
+type UserPermission struct {
+ ID string `gorm:"primaryKey;size:36" json:"id"`
+ UserID string `gorm:"uniqueIndex;size:36;not null" json:"user_id"`
+
+ // 默认开启(6项)- Basic
+ CanViewDashboard bool `gorm:"default:true" json:"can_view_dashboard"`
+ CanPlayMedia bool `gorm:"default:true" json:"can_play_media"`
+ CanCast bool `gorm:"default:true" json:"can_cast"`
+ CanExternalPlayer bool `gorm:"default:true" json:"can_external_player"`
+ CanFavorite bool `gorm:"default:true" json:"can_favorite"`
+ CanViewHistory bool `gorm:"default:true" json:"can_view_history"`
+
+ // 默认关闭(13项)- Advanced
+ CanEditMedia bool `gorm:"default:false" json:"can_edit_media"`
+ 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"`
+ 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"`
+ CanManageFiles bool `gorm:"default:false" json:"can_manage_files"`
+ CanManageStrm bool `gorm:"default:false" json:"can_manage_strm"`
+ CanAccessSettings bool `gorm:"default:false" json:"can_access_settings"`
+
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// BeforeCreate 生成 UUID。
+func (p *UserPermission) BeforeCreate(_ *gorm.DB) error {
+ if p.ID == "" {
+ p.ID = uuid.NewString()
+ }
+ return nil
+}
+
+// NewDefaultPermission 创建带有默认权限的 UserPermission。
+func NewDefaultPermission(userID string) *UserPermission {
+ return &UserPermission{
+ ID: uuid.NewString(),
+ UserID: userID,
+ CanViewDashboard: true,
+ CanPlayMedia: true,
+ CanCast: true,
+ CanExternalPlayer: true,
+ CanFavorite: true,
+ CanViewHistory: true,
+ CanEditMedia: false,
+ CanRescrape: false,
+ CanUseAI: false,
+ CanCaptureFrames: false,
+ CanManageDownloads: false,
+ CanViewDiscover: false,
+ CanManageSubscriptions: false,
+ CanManageSites: false,
+ CanUseAIAssistant: false,
+ CanManageUsers: false,
+ CanManageFiles: false,
+ CanManageStrm: false,
+ CanAccessSettings: false,
+ }
+}
+
+// PermissionMap 将权限结构转换为 map[string]bool 便于检查。
+func (p *UserPermission) PermissionMap() map[string]bool {
+ return map[string]bool{
+ "can_view_dashboard": p.CanViewDashboard,
+ "can_play_media": p.CanPlayMedia,
+ "can_cast": p.CanCast,
+ "can_external_player": p.CanExternalPlayer,
+ "can_favorite": p.CanFavorite,
+ "can_view_history": p.CanViewHistory,
+ "can_edit_media": p.CanEditMedia,
+ "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_sites": p.CanManageSites,
+ "can_use_ai_assistant": p.CanUseAIAssistant,
+ "can_manage_users": p.CanManageUsers,
+ "can_manage_files": p.CanManageFiles,
+ "can_manage_strm": p.CanManageStrm,
+ "can_access_settings": p.CanAccessSettings,
+ }
+}
diff --git a/internal/model/refresh_token.go b/internal/model/refresh_token.go
new file mode 100644
index 0000000..98d82b3
--- /dev/null
+++ b/internal/model/refresh_token.go
@@ -0,0 +1,38 @@
+// Package model 定义刷新令牌数据模型。
+package model
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// RefreshToken 用于双令牌认证机制中的刷新令牌。
+// 存储时使用 SHA256 哈希,原始令牌不存储。
+type RefreshToken struct {
+ ID string `gorm:"primaryKey;size:36" json:"id"`
+ UserID string `gorm:"index;size:36;not null" json:"user_id"`
+ TokenHash string `gorm:"uniqueIndex;size:128;not null" json:"-"`
+ ExpiresAt time.Time `gorm:"index" json:"expires_at"`
+ CreatedAt time.Time `json:"created_at"`
+ Revoked bool `gorm:"default:false" json:"revoked"`
+}
+
+// BeforeCreate 生成 UUID。
+func (t *RefreshToken) BeforeCreate(_ *gorm.DB) error {
+ if t.ID == "" {
+ t.ID = uuid.NewString()
+ }
+ return nil
+}
+
+// IsExpired 检查刷新令牌是否已过期。
+func (t *RefreshToken) IsExpired() bool {
+ return time.Now().After(t.ExpiresAt)
+}
+
+// IsValid 检查刷新令牌是否有效(未撤销且未过期)。
+func (t *RefreshToken) IsValid() bool {
+ return !t.Revoked && !t.IsExpired()
+}
diff --git a/internal/model/site.go b/internal/model/site.go
new file mode 100644
index 0000000..c95aa15
--- /dev/null
+++ b/internal/model/site.go
@@ -0,0 +1,33 @@
+// 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 / 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 加密
+ 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", "discuz", "custom_rss"}
+}
+
+// AuthTypes 返回支持的认证方式列表。
+func AuthTypes() []string {
+ return []string{"cookie", "api_key", "auth_header"}
+}
diff --git a/internal/model/strm.go b/internal/model/strm.go
new file mode 100644
index 0000000..c32dbaa
--- /dev/null
+++ b/internal/model/strm.go
@@ -0,0 +1,35 @@
+// 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:36;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",
+ "s3",
+ "http", "https",
+}
+
+// IsAllowedProtocol 检查协议是否在白名单中。
+func IsAllowedProtocol(protocol string) bool {
+ for _, p := range AllowedSTRMProtocols {
+ if p == protocol {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/repository/download_client_repo.go b/internal/repository/download_client_repo.go
new file mode 100644
index 0000000..c16251f
--- /dev/null
+++ b/internal/repository/download_client_repo.go
@@ -0,0 +1,92 @@
+// 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).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
+}
+
+// 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/notify_channel_repo.go b/internal/repository/notify_channel_repo.go
new file mode 100644
index 0000000..8d03155
--- /dev/null
+++ b/internal/repository/notify_channel_repo.go
@@ -0,0 +1,67 @@
+// 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
+}
+
+// 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 5542fd9..303f427 100644
--- a/internal/repository/repository.go
+++ b/internal/repository/repository.go
@@ -1,13 +1,14 @@
-// Package repository implements a thin GORM-based data-access layer over the
-// types declared in internal/model. Each method takes a context.Context so we
-// can plug in cancellation / tracing later.
+// Package repository 实现基于 GORM 的数据访问层。
+// 每个方法接受 context.Context 以便后续插入取消/追踪。
//
-// Repositories are intentionally narrow: they only know how to persist data,
-// not how to interpret it. Domain logic lives in internal/service.
+// Repository 故意保持精简:它们只负责持久化数据,不处理业务逻辑。
+// 业务逻辑位于 internal/service。
package repository
import (
"context"
+ "crypto/sha256"
+ "encoding/hex"
"errors"
"time"
@@ -16,7 +17,7 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/model"
)
-// Container is the registry of all repositories injected into services.
+// Container 是所有 repositories 的注册表,注入到 services 中。
type Container struct {
DB *gorm.DB
User *UserRepository
@@ -30,9 +31,16 @@ type Container struct {
Subscription *SubscriptionRepository
Setting *SettingRepository
Log *AccessLogRepository
+ Permission *PermissionRepository
+ RefreshToken *RefreshTokenRepository
+ ApiConfig *ApiConfigRepository
+ DownloadClient *DownloadClientRepository
+ NotifyChannel *NotifyChannelRepository
+ Site *SiteRepository
+ STRM *STRMRepository
}
-// New wires every repository to a single *gorm.DB.
+// New 将每个 repository 连接到单个 *gorm.DB。
func New(db *gorm.DB) *Container {
return &Container{
DB: db,
@@ -47,6 +55,13 @@ func New(db *gorm.DB) *Container {
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},
}
}
@@ -267,7 +282,7 @@ func (r *HistoryRepository) ListByUser(ctx context.Context, userID string, limit
return rows, err
}
-// ─── Favorite ────────────────────────────────────────────────────────────────
+// ─── Favorite ───────────────────────────────────────────────────────────────
// FavoriteRepository persists model.Favorite records.
type FavoriteRepository struct{ db *gorm.DB }
@@ -311,7 +326,7 @@ func (r *PlaylistRepository) ListByUser(ctx context.Context, userID string) ([]m
return rows, err
}
-// ─── Download ────────────────────────────────────────────────────────────────
+// ─── Download ───────────────────────────────────────────────────────────────
// DownloadRepository persists model.DownloadTask records.
type DownloadRepository struct{ db *gorm.DB }
@@ -328,7 +343,7 @@ func (r *DownloadRepository) List(ctx context.Context) ([]model.DownloadTask, er
return rows, err
}
-// ─── Subscription ────────────────────────────────────────────────────────────
+// ─── Subscription ───────────────────────────────────────────────────────────
// SubscriptionRepository persists model.Subscription records.
type SubscriptionRepository struct{ db *gorm.DB }
@@ -389,3 +404,158 @@ func (r *AccessLogRepository) Recent(ctx context.Context, limit int) ([]model.Ac
err := r.db.WithContext(ctx).Order("created_at desc").Limit(limit).Find(&rows).Error
return rows, err
}
+
+// ─── Permission ──────────────────────────────────────────────────────────────
+
+// PermissionRepository persists model.UserPermission records.
+type PermissionRepository struct{ db *gorm.DB }
+
+// Create inserts a new permission record.
+func (r *PermissionRepository) Create(ctx context.Context, p *model.UserPermission) error {
+ return r.db.WithContext(ctx).Create(p).Error
+}
+
+// FindByUserID returns the permission record for a user, or (nil, nil) when absent.
+func (r *PermissionRepository) FindByUserID(ctx context.Context, userID string) (*model.UserPermission, error) {
+ var p model.UserPermission
+ err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&p).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &p, nil
+}
+
+// Update updates permission fields for a user.
+func (r *PermissionRepository) Update(ctx context.Context, userID string, updates map[string]bool) error {
+ return r.db.WithContext(ctx).Model(&model.UserPermission{}).
+ Where("user_id = ?", userID).Updates(updates).Error
+}
+
+// Upsert creates or updates a permission record.
+func (r *PermissionRepository) Upsert(ctx context.Context, p *model.UserPermission) error {
+ return r.db.WithContext(ctx).Where("user_id = ?", p.UserID).
+ Assign(*p).FirstOrCreate(p).Error
+}
+
+// Delete removes a permission record.
+func (r *PermissionRepository) Delete(ctx context.Context, userID string) error {
+ return r.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&model.UserPermission{}).Error
+}
+
+// ─── Refresh Token ───────────────────────────────────────────────────────────
+
+// RefreshTokenRepository persists model.RefreshToken records.
+type RefreshTokenRepository struct{ db *gorm.DB }
+
+// Create inserts a new refresh token record.
+func (r *RefreshTokenRepository) Create(ctx context.Context, t *model.RefreshToken) error {
+ return r.db.WithContext(ctx).Create(t).Error
+}
+
+// FindByHash returns the refresh token matching the hash, or (nil, nil).
+func (r *RefreshTokenRepository) FindByHash(ctx context.Context, hash string) (*model.RefreshToken, error) {
+ var t model.RefreshToken
+ err := r.db.WithContext(ctx).Where("token_hash = ?", hash).First(&t).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &t, nil
+}
+
+// RevokeByUserID revokes all refresh tokens for a user.
+func (r *RefreshTokenRepository) RevokeByUserID(ctx context.Context, userID string) error {
+ return r.db.WithContext(ctx).Model(&model.RefreshToken{}).
+ Where("user_id = ?", userID).Update("revoked", true).Error
+}
+
+// DeleteExpired removes all expired refresh tokens.
+func (r *RefreshTokenRepository) DeleteExpired(ctx context.Context) error {
+ return r.db.WithContext(ctx).Where("expires_at < ?", time.Now()).Delete(&model.RefreshToken{}).Error
+}
+
+// Revoke revokes a specific refresh token.
+func (r *RefreshTokenRepository) Revoke(ctx context.Context, hash string) error {
+ return r.db.WithContext(ctx).Model(&model.RefreshToken{}).
+ Where("token_hash = ?", hash).Update("revoked", true).Error
+}
+
+// HashToken returns the SHA256 hash of a token.
+func HashToken(token string) string {
+ h := sha256.Sum256([]byte(token))
+ return hex.EncodeToString(h[:])
+}
+
+// ─── API Config ──────────────────────────────────────────────────────────────
+
+// ApiConfigRepository persists model.ApiConfig records.
+type ApiConfigRepository struct{ db *gorm.DB }
+
+// Create inserts a new API config record.
+func (r *ApiConfigRepository) Create(ctx context.Context, c *model.ApiConfig) error {
+ return r.db.WithContext(ctx).Create(c).Error
+}
+
+// FindByProvider returns the API config for a provider, or (nil, nil).
+func (r *ApiConfigRepository) FindByProvider(ctx context.Context, provider string) (*model.ApiConfig, error) {
+ var c model.ApiConfig
+ err := r.db.WithContext(ctx).Where("provider = ?", provider).First(&c).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &c, nil
+}
+
+// List returns all API configs.
+func (r *ApiConfigRepository) List(ctx context.Context) ([]model.ApiConfig, error) {
+ var rows []model.ApiConfig
+ err := r.db.WithContext(ctx).Order("provider asc").Find(&rows).Error
+ return rows, err
+}
+
+// Upsert creates or updates an API config.
+func (r *ApiConfigRepository) Upsert(ctx context.Context, c *model.ApiConfig) error {
+ return r.db.WithContext(ctx).Where("provider = ?", c.Provider).
+ Assign(model.ApiConfig{
+ APIKey: c.APIKey,
+ BaseURL: c.BaseURL,
+ Extra: c.Extra,
+ Enabled: c.Enabled,
+ UpdatedAt: time.Now(),
+ }).FirstOrCreate(c).Error
+}
+
+// Update updates an API config.
+func (r *ApiConfigRepository) Update(ctx context.Context, c *model.ApiConfig) error {
+ return r.db.WithContext(ctx).Model(&model.ApiConfig{}).
+ Where("provider = ?", c.Provider).Updates(map[string]any{
+ "api_key": c.APIKey,
+ "base_url": c.BaseURL,
+ "extra": c.Extra,
+ "enabled": c.Enabled,
+ "updated_at": time.Now(),
+ }).Error
+}
+
+// Delete removes an API config.
+func (r *ApiConfigRepository) Delete(ctx context.Context, provider string) error {
+ return r.db.WithContext(ctx).Where("provider = ?", provider).Delete(&model.ApiConfig{}).Error
+}
+
+// UpdateTestResult 更新测试结果。
+func (r *ApiConfigRepository) UpdateTestResult(ctx context.Context, provider, result string) error {
+ now := time.Now()
+ return r.db.WithContext(ctx).Model(&model.ApiConfig{}).
+ Where("provider = ?", provider).Updates(map[string]any{
+ "test_result": result,
+ "last_tested_at": &now,
+ }).Error
+}
diff --git a/internal/repository/site_repo.go b/internal/repository/site_repo.go
new file mode 100644
index 0000000..a3726bf
--- /dev/null
+++ b/internal/repository/site_repo.go
@@ -0,0 +1,56 @@
+// 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
new file mode 100644
index 0000000..c9dafd3
--- /dev/null
+++ b/internal/repository/strm_repo.go
@@ -0,0 +1,82 @@
+// 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/service/api_config_svc.go b/internal/service/api_config_svc.go
new file mode 100644
index 0000000..94a96f2
--- /dev/null
+++ b/internal/service/api_config_svc.go
@@ -0,0 +1,383 @@
+// Package service — API 配置管理服务。
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "go.uber.org/zap"
+
+ "github.com/ShukeBta/MediaStationGo/internal/config"
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/repository"
+)
+
+// ApiConfigService 负责第三方 API 配置的 CRUD 和加密管理。
+type ApiConfigService struct {
+ cfg *config.Config
+ log *zap.Logger
+ repo *repository.Container
+ crypto *CryptoService
+}
+
+// NewApiConfigService 创建 API 配置服务实例。
+func NewApiConfigService(cfg *config.Config, log *zap.Logger, repo *repository.Container, crypto *CryptoService) *ApiConfigService {
+ return &ApiConfigService{cfg: cfg, log: log, repo: repo, crypto: crypto}
+}
+
+// ApiConfigService 错误定义。
+var (
+ ErrApiConfigNotFound = errors.New("API configuration not found")
+ ErrInvalidProvider = errors.New("invalid provider")
+ ErrTestFailed = errors.New("connection test failed")
+)
+
+// GetByProvider 获取指定提供者的 API 配置。
+func (s *ApiConfigService) GetByProvider(ctx context.Context, provider string) (*model.ApiConfig, error) {
+ cfg, err := s.repo.ApiConfig.FindByProvider(ctx, provider)
+ if err != nil {
+ return nil, err
+ }
+ if cfg == nil {
+ return nil, ErrApiConfigNotFound
+ }
+ // 解密敏感字段
+ if cfg.APIKey != "" && s.crypto.IsEncrypted(cfg.APIKey) {
+ cfg.APIKey = s.crypto.Decrypt(cfg.APIKey)
+ }
+ return cfg, nil
+}
+
+// List 返回所有 API 配置。
+func (s *ApiConfigService) List(ctx context.Context) ([]model.ApiConfig, error) {
+ configs, err := s.repo.ApiConfig.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+ // 解密敏感字段
+ for i := range configs {
+ if configs[i].APIKey != "" && s.crypto.IsEncrypted(configs[i].APIKey) {
+ configs[i].APIKey = s.crypto.Decrypt(configs[i].APIKey)
+ }
+ }
+ return configs, nil
+}
+
+// GetProviders 返回预定义的提供者列表。
+func (s *ApiConfigService) GetProviders() []model.ApiProvider {
+ return model.PredefinedProviders()
+}
+
+// Upsert 创建或更新 API 配置,自动加密敏感字段。
+func (s *ApiConfigService) Upsert(ctx context.Context, provider string, apiKey, baseURL, extra string, enabled bool) (*model.ApiConfig, error) {
+ // 验证提供者是否有效
+ if !s.isValidProvider(provider) {
+ return nil, ErrInvalidProvider
+ }
+
+ // 加密 API Key
+ encryptedKey := apiKey
+ if apiKey != "" && !s.crypto.IsEncrypted(apiKey) {
+ encryptedKey = s.crypto.Encrypt(apiKey)
+ }
+
+ cfg := &model.ApiConfig{
+ Provider: provider,
+ APIKey: encryptedKey,
+ BaseURL: baseURL,
+ Extra: extra,
+ Enabled: enabled,
+ Description: s.getProviderDescription(provider),
+ }
+
+ if err := s.repo.ApiConfig.Upsert(ctx, cfg); err != nil {
+ return nil, err
+ }
+
+ // 返回解密后的配置
+ cfg.APIKey = apiKey
+ return cfg, nil
+}
+
+// Delete 删除 API 配置。
+func (s *ApiConfigService) Delete(ctx context.Context, provider string) error {
+ return s.repo.ApiConfig.Delete(ctx, provider)
+}
+
+// Update 更新 API 配置。
+func (s *ApiConfigService) Update(ctx context.Context, provider string, apiKey, baseURL, extra string, enabled bool) error {
+ // 加密 API Key
+ encryptedKey := apiKey
+ if apiKey != "" && !s.crypto.IsEncrypted(apiKey) {
+ encryptedKey = s.crypto.Encrypt(apiKey)
+ }
+
+ cfg := &model.ApiConfig{
+ Provider: provider,
+ APIKey: encryptedKey,
+ BaseURL: baseURL,
+ Extra: extra,
+ Enabled: enabled,
+ }
+
+ return s.repo.ApiConfig.Update(ctx, cfg)
+}
+
+// TestConnection 测试 API 连接。
+func (s *ApiConfigService) TestConnection(ctx context.Context, provider string) (string, error) {
+ cfg, err := s.GetByProvider(ctx, provider)
+ if err != nil {
+ return "error", err
+ }
+
+ // 根据不同提供者执行不同的测试逻辑
+ switch provider {
+ case "tmdb":
+ return s.testTMDb(cfg)
+ case "openai":
+ return s.testOpenAI(cfg)
+ case "deepseek":
+ return s.testDeepSeek(cfg)
+ case "siliconflow":
+ return s.testSiliconFlow(cfg)
+ default:
+ return "unknown", fmt.Errorf("no test implemented for provider: %s", provider)
+ }
+}
+
+// testTMDb 测试 TMDb API 连接。
+func (s *ApiConfigService) testTMDb(cfg *model.ApiConfig) (string, error) {
+ if cfg.APIKey == "" {
+ return "error", errors.New("API key is required")
+ }
+
+ testURL := "https://api.themoviedb.org/3/configuration?api_key=" + cfg.APIKey
+ resp, err := http.Get(testURL)
+ if err != nil {
+ // 如果配置了代理,使用代理
+ if s.cfg.Secrets.TMDbAPIProxy != "" {
+ proxyURL := s.cfg.Secrets.TMDbAPIProxy + "?api_key=" + cfg.APIKey
+ resp, err = http.Get(proxyURL)
+ if err != nil {
+ return "error", fmt.Errorf("TMDb connection failed: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode == 200 {
+ return "success", nil
+ }
+ return "error", fmt.Errorf("TMDb API returned status %d", resp.StatusCode)
+ }
+ return "error", fmt.Errorf("TMDb connection failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == 200 {
+ return "success", nil
+ }
+ if resp.StatusCode == 401 {
+ return "invalid", errors.New("invalid API key")
+ }
+ return "error", fmt.Errorf("TMDb API returned status %d", resp.StatusCode)
+}
+
+// testOpenAI 测试 OpenAI API 连接。
+func (s *ApiConfigService) testOpenAI(cfg *model.ApiConfig) (string, error) {
+ if cfg.APIKey == "" {
+ return "error", errors.New("API key is required")
+ }
+
+ baseURL := cfg.BaseURL
+ if baseURL == "" {
+ baseURL = "https://api.openai.com/v1"
+ }
+
+ testURL := baseURL + "/models"
+ req, err := http.NewRequest("GET", testURL, nil)
+ if err != nil {
+ return "error", err
+ }
+ req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req.WithContext(context.Background()))
+ if err != nil {
+ return "error", fmt.Errorf("OpenAI connection failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == 200 {
+ return "success", nil
+ }
+ if resp.StatusCode == 401 {
+ return "invalid", errors.New("invalid API key")
+ }
+ return "error", fmt.Errorf("OpenAI API returned status %d", resp.StatusCode)
+}
+
+// testDeepSeek 测试 DeepSeek API 连接。
+func (s *ApiConfigService) testDeepSeek(cfg *model.ApiConfig) (string, error) {
+ if cfg.APIKey == "" {
+ return "error", errors.New("API key is required")
+ }
+
+ baseURL := cfg.BaseURL
+ if baseURL == "" {
+ baseURL = "https://api.deepseek.com"
+ }
+
+ testURL := baseURL + "/models"
+ req, err := http.NewRequest("GET", testURL, nil)
+ if err != nil {
+ return "error", err
+ }
+ req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req.WithContext(context.Background()))
+ if err != nil {
+ return "error", fmt.Errorf("DeepSeek connection failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == 200 {
+ return "success", nil
+ }
+ if resp.StatusCode == 401 {
+ return "invalid", errors.New("invalid API key")
+ }
+ return "error", fmt.Errorf("DeepSeek API returned status %d", resp.StatusCode)
+}
+
+// testSiliconFlow 测试 SiliconFlow API 连接。
+func (s *ApiConfigService) testSiliconFlow(cfg *model.ApiConfig) (string, error) {
+ if cfg.APIKey == "" {
+ return "error", errors.New("API key is required")
+ }
+
+ baseURL := cfg.BaseURL
+ if baseURL == "" {
+ baseURL = "https://api.siliconflow.cn/v1"
+ }
+
+ testURL := baseURL + "/models"
+ req, err := http.NewRequest("GET", testURL, nil)
+ if err != nil {
+ return "error", err
+ }
+ req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req.WithContext(context.Background()))
+ if err != nil {
+ return "error", fmt.Errorf("SiliconFlow connection failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == 200 {
+ return "success", nil
+ }
+ if resp.StatusCode == 401 {
+ return "invalid", errors.New("invalid API key")
+ }
+ return "error", fmt.Errorf("SiliconFlow API returned status %d", resp.StatusCode)
+}
+
+// GetEffectiveConfig 获取生效的 API 配置(数据库配置优先于配置文件)。
+func (s *ApiConfigService) GetEffectiveConfig(ctx context.Context, provider string) (*model.ApiConfig, error) {
+ // 首先尝试从数据库获取
+ cfg, err := s.GetByProvider(ctx, provider)
+ if err == nil && cfg != nil {
+ return cfg, nil
+ }
+
+ // 如果数据库没有,尝试从配置文件获取
+ return s.getConfigFromFile(provider)
+}
+
+// getConfigFromFile 从配置文件获取 API 配置。
+func (s *ApiConfigService) getConfigFromFile(provider string) (*model.ApiConfig, error) {
+ var apiKey string
+ var hasKey bool
+
+ switch provider {
+ case "tmdb":
+ apiKey = s.cfg.Secrets.TMDbAPIKey
+ hasKey = apiKey != ""
+ case "bangumi":
+ apiKey = s.cfg.Secrets.BangumiToken
+ hasKey = apiKey != ""
+ case "thetvdb":
+ apiKey = s.cfg.Secrets.TheTVDBAPIKey
+ hasKey = apiKey != ""
+ case "fanart":
+ apiKey = s.cfg.Secrets.FanartAPIKey
+ hasKey = apiKey != ""
+ }
+
+ if !hasKey {
+ return nil, ErrApiConfigNotFound
+ }
+
+ return &model.ApiConfig{
+ Provider: provider,
+ APIKey: apiKey,
+ Enabled: true,
+ }, nil
+}
+
+// isValidProvider 检查提供者是否有效。
+func (s *ApiConfigService) isValidProvider(provider string) bool {
+ providers := model.PredefinedProviders()
+ for _, p := range providers {
+ if p.ID == provider {
+ return true
+ }
+ }
+ return false
+}
+
+// getProviderDescription 获取提供者描述。
+func (s *ApiConfigService) getProviderDescription(provider string) string {
+ providers := model.PredefinedProviders()
+ for _, p := range providers {
+ if p.ID == provider {
+ return p.Description
+ }
+ }
+ return ""
+}
+
+// UpdateTestResult 更新测试结果。
+func (s *ApiConfigService) UpdateTestResult(ctx context.Context, provider, result string) error {
+ return s.repo.ApiConfig.UpdateTestResult(ctx, provider, result)
+}
+
+// MaskAPIKey 遮蔽 API Key 的中间部分。
+func (s *ApiConfigService) MaskAPIKey(apiKey string) string {
+ if len(apiKey) <= 8 {
+ return "***"
+ }
+ return apiKey[:4] + "..." + apiKey[len(apiKey)-4:]
+}
+
+// ExtractBaseURL 从 URL 中提取域名。
+func ExtractBaseURL(rawURL string) string {
+ if rawURL == "" {
+ return ""
+ }
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ return rawURL
+ }
+ return u.Scheme + "://" + u.Host
+}
+
+// ProviderMatches 检查请求的提供者是否与配置的提供者匹配。
+func ProviderMatches(requested, configured string) bool {
+ return strings.EqualFold(requested, configured)
+}
diff --git a/internal/service/aria2_adp.go b/internal/service/aria2_adp.go
new file mode 100644
index 0000000..40ca3b1
--- /dev/null
+++ b/internal/service/aria2_adp.go
@@ -0,0 +1,424 @@
+// Package service — Aria2 下载适配器。
+//
+// Aria2Adapter 实现了 DownloadAdapter 接口,通过 Aria2 JSON-RPC API
+// 管理下载任务。
+package service
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+)
+
+// 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"`
+}
+
+// Aria2Adapter 是 Aria2 的 DownloadAdapter 实现。
+type Aria2Adapter struct {
+ mu sync.Mutex
+ cfg DownloadClientConfig
+ client *http.Client
+ idSeq int
+}
+
+// NewAria2Adapter 创建新的 Aria2 适配器。
+func NewAria2Adapter() *Aria2Adapter {
+ return &Aria2Adapter{
+ client: &http.Client{Timeout: 20 * time.Second},
+ }
+}
+
+// Initialize 配置并初始化 Aria2 RPC 连接。
+func (a *Aria2Adapter) Initialize(ctx context.Context, cfg DownloadClientConfig) error {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ 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 := a.cfg.Host
+ if !strings.HasSuffix(rpcURL, "/jsonrpc") {
+ rpcURL = strings.TrimRight(rpcURL, "/") + "/jsonrpc"
+ }
+
+ 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 := http.NewRequestWithContext(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 := a.cfg.Host
+ if !strings.HasSuffix(rpcURL, "/jsonrpc") {
+ rpcURL = strings.TrimRight(rpcURL, "/") + "/jsonrpc"
+ }
+
+ 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 := http.NewRequestWithContext(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
+}
+
+// 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)
+}
+
+// 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()
+ if deleteFiles {
+ _, err := a.rpcLocked(ctx, "aria2.removeDownloadResult", []interface{}{hash})
+ return err
+ }
+ _, err := a.rpcLocked(ctx, "aria2.remove", []interface{}{hash})
+ return err
+}
+
+// List 列出所有活动/等待/已停止的任务。
+func (a *Aria2Adapter) List(ctx context.Context, filter string) ([]TorrentInfo, error) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+
+ var allResults []TorrentInfo
+
+ // 获取活动任务
+ active, err := a.rpcLocked(ctx, "aria2.tellActive", []interface{}{
+ []string{"gid", "bittorrent", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "status", "dir", "numSeeders", "connections", "errorCode"},
+ })
+ if err == nil && active != nil {
+ items := a.parseAria2Items(active)
+ allResults = append(allResults, items...)
+ }
+
+ // 获取等待中的任务
+ waiting, err := a.rpcLocked(ctx, "aria2.tellWaiting", []interface{}{
+ 0, 100,
+ []string{"gid", "bittorrent", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "status", "dir", "numSeeders", "connections", "errorCode"},
+ })
+ if err == nil && waiting != nil {
+ items := a.parseAria2Items(waiting)
+ allResults = append(allResults, items...)
+ }
+
+ // 获取已停止的任务
+ stopped, err := a.rpcLocked(ctx, "aria2.tellStopped", []interface{}{
+ 0, 100,
+ []string{"gid", "bittorrent", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "status", "dir", "numSeeders", "connections", "errorCode"},
+ })
+ if err == nil && stopped != nil {
+ items := a.parseAria2Items(stopped)
+ allResults = append(allResults, items...)
+ }
+
+ if filter != "" {
+ filtered := make([]TorrentInfo, 0, len(allResults))
+ for _, item := range allResults {
+ if strings.EqualFold(item.State, filter) {
+ filtered = append(filtered, item)
+ }
+ }
+ return filtered, nil
+ }
+
+ return allResults, nil
+}
+
+// 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", "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
+}
+
+// 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 hash 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"])
+ }
+ hash = strVal(bt["infoHash"])
+ }
+
+ // 如果没有 bittorrent 信息,使用 GID 作为 hash
+ if hash == "" {
+ hash = gid
+ }
+ if name == "" {
+ // 尝试从 files 获取文件名
+ if files, ok := item["files"].([]interface{}); ok && len(files) > 0 {
+ if f, ok := files[0].(map[string]interface{}); ok {
+ paths, ok := f["path"].([]interface{})
+ if ok && len(paths) > 0 {
+ name = strVal(paths[len(paths)-1])
+ }
+ if name == "" {
+ name = strVal(f["uris"])
+ }
+ }
+ }
+ }
+ if name == "" {
+ name = gid
+ }
+
+ var progress float64
+ if totalLength > 0 {
+ progress = float64(completedLength) / float64(totalLength) * 100
+ }
+
+ // Aria2 状态映射
+ state := aria2StatusStr(status)
+
+ return &TorrentInfo{
+ Hash: hash,
+ Name: name,
+ Size: totalLength,
+ Progress: progress,
+ DLSpeed: dlSpeed,
+ UPSpeed: upSpeed,
+ State: state,
+ SavePath: dir,
+ NumSeeds: numSeeders,
+ NumLeechs: max(connections-numSeeders, 0),
+ AddedOn: time.Now(),
+ }
+}
+
+// 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
+ }
+}
+
+// nextID 生成递增的请求 ID。
+func (a *Aria2Adapter) nextID() string {
+ a.idSeq++
+ return fmt.Sprintf("msg-%d", a.idSeq)
+}
+
+func max(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
diff --git a/internal/service/auth.go b/internal/service/auth.go
index 93af306..73b2163 100644
--- a/internal/service/auth.go
+++ b/internal/service/auth.go
@@ -14,27 +14,29 @@ import (
"golang.org/x/crypto/bcrypt"
"github.com/ShukeBta/MediaStationGo/internal/config"
- "github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// AuthService handles registration, login, and JWT issuance.
type AuthService struct {
- cfg *config.Config
- log *zap.Logger
- repo *repository.Container
+ cfg *config.Config
+ log *zap.Logger
+ repo *repository.Container
+ tokenSvc *TokenService
+ permissionSvc *PermissionService
}
// NewAuthService is the constructor.
-func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *AuthService {
- return &AuthService{cfg: cfg, log: log, repo: repo}
+func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Container, tokenSvc *TokenService, permissionSvc *PermissionService) *AuthService {
+ return &AuthService{cfg: cfg, log: log, repo: repo, tokenSvc: tokenSvc, permissionSvc: permissionSvc}
}
// Common service-level errors.
var (
ErrInvalidCredentials = errors.New("invalid username or password")
ErrUsernameTaken = errors.New("username already taken")
+ ErrUserInactive = errors.New("user account is inactive")
)
// SeedAdmin makes sure at least one admin user exists. It mirrors the
@@ -60,11 +62,14 @@ func (s *AuthService) SeedAdmin(ctx context.Context) error {
Username: "admin",
PasswordHash: hash,
Role: "admin",
+ Tier: "plus",
ForcePasswordReset: pwd == "admin123",
}
if err := s.repo.User.Create(ctx, user); err != nil {
return err
}
+ // 确保管理员有权限记录
+ _, _ = s.permissionSvc.EnsureForUser(ctx, user.ID)
s.log.Warn("default admin created — change the password after first login",
zap.String("username", "admin"),
zap.String("password_source", "ADMIN_INITIAL_PASSWORD or admin123"),
@@ -74,49 +79,72 @@ func (s *AuthService) SeedAdmin(ctx context.Context) error {
// Register creates a new user. The first registered user is auto-promoted to
// admin to support fresh installs that did not run SeedAdmin.
-func (s *AuthService) Register(ctx context.Context, username, password string) (*model.User, error) {
+func (s *AuthService) Register(ctx context.Context, username, password string) (*model.User, *TokenPair, error) {
username = strings.TrimSpace(username)
if username == "" || password == "" {
- return nil, fmt.Errorf("username and password required")
+ return nil, nil, fmt.Errorf("username and password required")
}
if existing, err := s.repo.User.FindByUsername(ctx, username); err != nil {
- return nil, err
+ return nil, nil, err
} else if existing != nil {
- return nil, ErrUsernameTaken
+ return nil, nil, ErrUsernameTaken
}
hash, err := hashPassword(password)
if err != nil {
- return nil, err
+ return nil, nil, err
}
role := "user"
if n, err := s.repo.User.CountAdmins(ctx); err == nil && n == 0 {
role = "admin"
}
- u := &model.User{Username: username, PasswordHash: hash, Role: role}
- if err := s.repo.User.Create(ctx, u); err != nil {
- return nil, err
+ u := &model.User{
+ Username: username,
+ PasswordHash: hash,
+ Role: role,
+ Tier: "free",
}
- return u, nil
+ if err := s.repo.User.Create(ctx, u); err != nil {
+ return nil, nil, err
+ }
+ // 自动为新用户创建默认权限
+ _, _ = s.permissionSvc.EnsureForUser(ctx, u.ID)
+ // 签发令牌对
+ tokens, err := s.tokenSvc.IssuePair(ctx, u.ID, u.Role, u.Tier)
+ if err != nil {
+ return u, nil, nil // 用户已创建,令牌签发失败不影响注册成功
+ }
+ return u, tokens, nil
}
-// Login validates credentials and returns the user + a fresh JWT.
-func (s *AuthService) Login(ctx context.Context, username, password string) (*model.User, string, error) {
+// LoginResponse 登录响应结构。
+type LoginResponse struct {
+ User *model.User `json:"user"`
+ Tokens *TokenPair `json:"tokens"`
+}
+
+// Login validates credentials and returns the user + a fresh JWT token pair.
+func (s *AuthService) Login(ctx context.Context, username, password string) (*LoginResponse, error) {
u, err := s.repo.User.FindByUsername(ctx, username)
if err != nil {
- return nil, "", err
+ return nil, err
}
if u == nil {
- return nil, "", ErrInvalidCredentials
+ return nil, ErrInvalidCredentials
+ }
+ // 检查用户是否激活
+ if !u.IsActive {
+ return nil, ErrUserInactive
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
- return nil, "", ErrInvalidCredentials
+ return nil, ErrInvalidCredentials
}
- token, err := s.IssueToken(u)
+ // 签发令牌对
+ tokens, err := s.tokenSvc.IssuePair(ctx, u.ID, u.Role, u.Tier)
if err != nil {
- return nil, "", err
+ return nil, err
}
_ = s.repo.User.TouchLogin(ctx, u.ID)
- return u, token, nil
+ return &LoginResponse{User: u, Tokens: tokens}, nil
}
// ChangePassword updates the user password if the old one matches.
@@ -138,14 +166,15 @@ func (s *AuthService) ChangePassword(ctx context.Context, userID, oldPwd, newPwd
return s.repo.User.UpdatePassword(ctx, userID, hash)
}
-// IssueToken signs a JWT for the given user (24h validity).
+// IssueToken signs a JWT for the given user (60min validity, includes tier).
func (s *AuthService) IssueToken(u *model.User) (string, error) {
- claims := middleware.Claims{
+ 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(24 * time.Hour)),
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(60 * time.Minute)),
Issuer: "mediastationgo",
Subject: u.ID,
},
@@ -154,6 +183,16 @@ func (s *AuthService) IssueToken(u *model.User) (string, error) {
return t.SignedString([]byte(s.cfg.Secrets.JWTSecret))
}
+// RefreshTokens 使用刷新令牌获取新的令牌对。
+func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (*TokenPair, error) {
+ return s.tokenSvc.Refresh(ctx, refreshToken)
+}
+
+// Logout 撤销用户的所有刷新令牌。
+func (s *AuthService) Logout(ctx context.Context, userID string) error {
+ return s.tokenSvc.RevokeAll(ctx, userID)
+}
+
func hashPassword(p string) (string, error) {
h, err := bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost)
if err != nil {
diff --git a/internal/service/crypto.go b/internal/service/crypto.go
index 513ea0e..eeffcee 100644
--- a/internal/service/crypto.go
+++ b/internal/service/crypto.go
@@ -99,6 +99,11 @@ func (c *CryptoService) Decrypt(value string) string {
return string(plain)
}
+// IsEncrypted returns true if value carries the encrypted prefix.
+func (c *CryptoService) IsEncrypted(value string) bool {
+ return strings.HasPrefix(value, encPrefix)
+}
+
// MaskAPIKey returns "abcd****wxyz" so the key can be displayed in the
// admin UI without leaking it. Inputs shorter than 8 chars become "****".
func MaskAPIKey(plain string) string {
diff --git a/internal/service/download_adapter.go b/internal/service/download_adapter.go
new file mode 100644
index 0000000..c68b010
--- /dev/null
+++ b/internal/service/download_adapter.go
@@ -0,0 +1,69 @@
+// 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)
+}
+
+// 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"`
+}
+
+// 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_manager_svc.go b/internal/service/download_manager_svc.go
new file mode 100644
index 0000000..4cb9511
--- /dev/null
+++ b/internal/service/download_manager_svc.go
@@ -0,0 +1,253 @@
+// Package service — 下载管理器,管理多个下载客户端适配器。
+//
+// DownloadManager 提供多客户端分发能力,支持运行时热插拔。
+// 调用方通过 GetDefault() 或 GetClient(id) 获取适配器来执行下载操作。
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "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
+}
+
+// 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),
+ }
+}
+
+// LoadAll 从数据库加载所有已启用的客户端并初始化适配器。
+func (m *DownloadManager) LoadAll(ctx context.Context) error {
+ dbClients, err := m.repo.DownloadClient.ListEnabled(ctx)
+ if err != nil {
+ return err
+ }
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ // 清空现有
+ m.clients = make(map[string]DownloadAdapter, len(dbClients))
+ m.configs = make(map[string]DownloadClientConfig, len(dbClients))
+
+ for _, dc := range dbClients {
+ 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),
+ )
+ continue
+ }
+
+ adapter := AdapterFactory(dc.Type)
+ if adapter == nil {
+ m.log.Warn("unknown download client type",
+ zap.String("type", dc.Type),
+ zap.String("id", dc.ID),
+ )
+ continue
+ }
+
+ if initErr := adapter.Initialize(ctx, cfg); initErr != nil {
+ m.log.Warn("failed to initialize download client",
+ zap.String("id", dc.ID),
+ zap.String("name", dc.Name),
+ zap.Error(initErr),
+ )
+ continue
+ }
+
+ m.clients[dc.ID] = adapter
+ m.configs[dc.ID] = cfg
+ m.log.Info("download client initialized",
+ zap.String("id", dc.ID),
+ zap.String("name", dc.Name),
+ zap.String("type", dc.Type),
+ )
+ }
+ return nil
+}
+
+// GetDefault 返回默认下载客户端适配器。
+// 如果没有设置默认客户端,返回第一个可用的客户端。
+func (m *DownloadManager) GetDefault() (string, DownloadAdapter, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ // 首先找默认的
+ defaultClient, err := m.repo.DownloadClient.FindDefault(context.Background())
+ if err != nil {
+ return "", nil, err
+ }
+ if defaultClient != nil {
+ if adapter, ok := m.clients[defaultClient.ID]; ok {
+ return defaultClient.ID, adapter, nil
+ }
+ }
+
+ // 返回第一个可用的
+ for id, adapter := range m.clients {
+ return id, adapter, nil
+ }
+
+ return "", 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
+}
+
+// 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()
+ m.clients[dc.ID] = adapter
+ m.configs[dc.ID] = cfg
+ return nil
+}
+
+// RemoveClient 移除一个下载客户端(停止适配器,不删除数据库记录)。
+func (m *DownloadManager) RemoveClient(id string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ delete(m.clients, id)
+ delete(m.configs, id)
+}
+
+// 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) {
+ m.mu.RLock()
+ ids := make([]string, 0, len(m.clients))
+ for id := range m.clients {
+ ids = append(ids, id)
+ }
+ adapters := make([]DownloadAdapter, 0, len(m.clients))
+ for _, id := range ids {
+ adapters = append(adapters, m.clients[id])
+ }
+ m.mu.RUnlock()
+
+ result := make(map[string][]TorrentInfo)
+ for i, id := range ids {
+ list, err := adapters[i].List(ctx, filter)
+ if err != nil {
+ m.log.Warn("failed to list torrents from client",
+ zap.String("id", id),
+ zap.Error(err),
+ )
+ continue
+ }
+ result[id] = list
+ }
+ return result, nil
+}
+
+// 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)
+ }
+
+ cfg := DownloadClientConfig{
+ Host: dc.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/image_proxy.go b/internal/service/image_proxy.go
index 85249c1..f01ce66 100644
--- a/internal/service/image_proxy.go
+++ b/internal/service/image_proxy.go
@@ -141,3 +141,79 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, raw strin
http.ServeContent(w, &http.Request{}, key, stat.ModTime(), f)
return nil
}
+
+// Fetch 拉取远程图片并返回字节和 Content-Type(带缓存)。
+func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, error) {
+ if raw == "" {
+ return nil, "", errors.New("missing url")
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.Scheme == "" || u.Host == "" {
+ return nil, "", errors.New("invalid url")
+ }
+ if _, ok := p.allowHost[strings.ToLower(u.Host)]; !ok {
+ return nil, "", errors.New("host not allowed")
+ }
+
+ // Cache lookup
+ sum := sha1.Sum([]byte(raw))
+ key := hex.EncodeToString(sum[:])
+ cachePath := filepath.Join(p.cacheDir, key)
+
+ if data, err := os.ReadFile(cachePath); err == nil {
+ // Content-Type from file extension or upstream headers — use a simple detect
+ ctype := detectContentType(data)
+ return data, ctype, nil
+ }
+
+ // Fetch upstream
+ if err := os.MkdirAll(p.cacheDir, 0o755); err != nil {
+ return nil, "", err
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ req.Header.Set("User-Agent", "MediaStationGo/0.1")
+
+ resp, err := p.client.Do(req)
+ if err != nil {
+ return nil, "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ return nil, "", errors.New("upstream returned " + resp.Status)
+ }
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, "", err
+ }
+
+ // Write to cache
+ tmp, err := os.CreateTemp(p.cacheDir, "img-*.tmp")
+ if err == nil {
+ if _, err := tmp.Write(data); err == nil {
+ tmp.Close()
+ os.Rename(tmp.Name(), cachePath)
+ } else {
+ tmp.Close()
+ os.Remove(tmp.Name())
+ }
+ }
+
+ ctype := resp.Header.Get("Content-Type")
+ if ctype == "" {
+ ctype = detectContentType(data)
+ }
+ return data, ctype, nil
+}
+
+// detectContentType 通过前 512 字节检测 MIME 类型。
+func detectContentType(data []byte) string {
+ if len(data) > 512 {
+ return http.DetectContentType(data[:512])
+ }
+ return http.DetectContentType(data)
+}
diff --git a/internal/service/notify_bark.go b/internal/service/notify_bark.go
new file mode 100644
index 0000000..b96c413
--- /dev/null
+++ b/internal/service/notify_bark.go
@@ -0,0 +1,77 @@
+// 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_email.go b/internal/service/notify_email.go
new file mode 100644
index 0000000..429f66f
--- /dev/null
+++ b/internal/service/notify_email.go
@@ -0,0 +1,147 @@
+// 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
new file mode 100644
index 0000000..949ebb6
--- /dev/null
+++ b/internal/service/notify_svc.go
@@ -0,0 +1,185 @@
+// 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"
+)
+
+// 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
new file mode 100644
index 0000000..f1f75ee
--- /dev/null
+++ b/internal/service/notify_telegram.go
@@ -0,0 +1,100 @@
+// Package service — Telegram 通知 Provider。
+package service
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// 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"]
+ chatID := cfg["chat_id"]
+ parseMode := cfg["parse_mode"]
+ if parseMode == "" {
+ parseMode = "HTML"
+ }
+
+ if botToken == "" || chatID == "" {
+ return fmt.Errorf("telegram: bot_token and chat_id are required")
+ }
+
+ text := formatTelegramMessage(event, parseMode)
+
+ payload := map[string]string{
+ "chat_id": chatID,
+ "text": text,
+ "parse_mode": parseMode,
+ }
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+
+ apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
+ 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("telegram api error %d: %s", resp.StatusCode, string(respBody))
+ }
+ return nil
+}
+
+// ValidateConfig 验证 Telegram 配置。
+func (p *TelegramProvider) ValidateConfig(cfg map[string]string) error {
+ if cfg["bot_token"] == "" {
+ return fmt.Errorf("telegram: bot_token is required")
+ }
+ if cfg["chat_id"] == "" {
+ return fmt.Errorf("telegram: chat_id is required")
+ }
+ return nil
+}
+
+// formatTelegramMessage 格式化消息内容。
+func formatTelegramMessage(event NotifyEvent, parseMode string) string {
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("%s\n\n", escapeHTML(event.Title)))
+ sb.WriteString(escapeHTML(event.Message))
+
+ if len(event.Data) > 0 {
+ sb.WriteString("\n\n")
+ for k, v := range event.Data {
+ sb.WriteString(fmt.Sprintf("• %s: %v\n", escapeHTML(k), v))
+ }
+ }
+
+ if parseMode != "HTML" {
+ // Markdown 模式
+ result := sb.String()
+ result = strings.ReplaceAll(result, "", "**")
+ result = strings.ReplaceAll(result, "", "**")
+ result = strings.ReplaceAll(result, "<", "<")
+ result = strings.ReplaceAll(result, ">", ">")
+ result = strings.ReplaceAll(result, "&", "&")
+ return result
+ }
+
+ return sb.String()
+}
diff --git a/internal/service/notify_webhook.go b/internal/service/notify_webhook.go
new file mode 100644
index 0000000..f6ff894
--- /dev/null
+++ b/internal/service/notify_webhook.go
@@ -0,0 +1,119 @@
+// 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
new file mode 100644
index 0000000..6794d87
--- /dev/null
+++ b/internal/service/notify_wechat.go
@@ -0,0 +1,77 @@
+// 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/permission_svc.go b/internal/service/permission_svc.go
new file mode 100644
index 0000000..1d1edab
--- /dev/null
+++ b/internal/service/permission_svc.go
@@ -0,0 +1,117 @@
+// Package service — 权限管理服务。
+package service
+
+import (
+ "context"
+ "errors"
+
+ "go.uber.org/zap"
+
+ "github.com/ShukeBta/MediaStationGo/internal/config"
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/repository"
+)
+
+// PermissionService 负责用户细粒度权限管理。
+type PermissionService struct {
+ cfg *config.Config
+ log *zap.Logger
+ repo *repository.Container
+}
+
+// NewPermissionService 创建权限服务实例。
+func NewPermissionService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *PermissionService {
+ return &PermissionService{cfg: cfg, log: log, repo: repo}
+}
+
+// 权限服务错误定义。
+var (
+ ErrPermissionDenied = errors.New("permission denied")
+ ErrPermissionNotFound = errors.New("permission not found")
+)
+
+// GetByUserID 获取用户的权限记录,不存在则返回默认权限。
+func (s *PermissionService) GetByUserID(ctx context.Context, userID string) (*model.UserPermission, error) {
+ perm, err := s.repo.Permission.FindByUserID(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ if perm == nil {
+ // 返回默认权限但不持久化
+ return model.NewDefaultPermission(userID), nil
+ }
+ return perm, nil
+}
+
+// EnsureForUser 确保用户拥有权限记录,不存在则创建默认权限。
+func (s *PermissionService) EnsureForUser(ctx context.Context, userID string) (*model.UserPermission, error) {
+ perm, err := s.repo.Permission.FindByUserID(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ if perm != nil {
+ return perm, nil
+ }
+ // 创建默认权限
+ defaultPerm := model.NewDefaultPermission(userID)
+ if err := s.repo.Permission.Upsert(ctx, defaultPerm); err != nil {
+ return nil, err
+ }
+ return defaultPerm, nil
+}
+
+// Check 检查用户是否拥有特定权限。
+// 权限检查优先级:admin → 全权限 > plus → 全权限 > user → 查表
+func (s *PermissionService) Check(ctx context.Context, userID, role, tier, permissionKey string) bool {
+ // admin 拥有所有权限
+ if role == "admin" {
+ return true
+ }
+
+ // plus 用户拥有所有权限
+ if tier == "plus" {
+ return true
+ }
+
+ // free 用户查表
+ perm, err := s.GetByUserID(ctx, userID)
+ if err != nil || perm == nil {
+ return false
+ }
+
+ permMap := perm.PermissionMap()
+ hasPermission, ok := permMap[permissionKey]
+ if !ok {
+ return false
+ }
+ return hasPermission
+}
+
+// Update 更新用户的权限。
+func (s *PermissionService) Update(ctx context.Context, userID string, updates map[string]bool) error {
+ // 确保权限记录存在
+ if _, err := s.EnsureForUser(ctx, userID); err != nil {
+ return err
+ }
+ return s.repo.Permission.Update(ctx, userID, updates)
+}
+
+// ResetToDefault 将用户权限重置为默认值。
+func (s *PermissionService) ResetToDefault(ctx context.Context, userID string) error {
+ defaultPerm := model.NewDefaultPermission(userID)
+ return s.repo.Permission.Upsert(ctx, defaultPerm)
+}
+
+// GetPermissionMap 获取用户权限的 map 表示。
+func (s *PermissionService) GetPermissionMap(ctx context.Context, userID string) (map[string]bool, error) {
+ perm, err := s.GetByUserID(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ return perm.PermissionMap(), nil
+}
+
+// IsSuperUser 检查用户是否为超级用户(admin 或 plus)。
+func (s *PermissionService) IsSuperUser(role, tier string) bool {
+ return role == "admin" || tier == "plus"
+}
diff --git a/internal/service/qbittorrent_adp.go b/internal/service/qbittorrent_adp.go
new file mode 100644
index 0000000..b7331b0
--- /dev/null
+++ b/internal/service/qbittorrent_adp.go
@@ -0,0 +1,344 @@
+// Package service — qBittorrent 下载适配器。
+//
+// QBitAdapter 实现了 DownloadAdapter 接口,通过 qBittorrent WebUI API
+// 管理下载任务。底层使用与 QBitClient 相同的 HTTP API 调用逻辑。
+package service
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/cookiejar"
+ "net/url"
+ "strconv"
+ "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)
+ return &QBitAdapter{
+ client: &http.Client{Jar: jar, Timeout: 20 * time.Second},
+ }
+}
+
+// Initialize 配置并初始化 qBittorrent 连接。
+func (a *QBitAdapter) Initialize(ctx context.Context, cfg DownloadClientConfig) error {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ 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) {
+ 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)
+ if savePath != "" {
+ _ = w.WriteField("savepath", savePath)
+ }
+ _ = w.Close()
+
+ baseURL := strings.TrimRight(a.cfg.Host, "/")
+ req, err := http.NewRequestWithContext(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)
+
+ 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("qbittorrent add torrent: %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
+ }
+ return "", nil
+}
+
+// AddMagnet 通过磁力链接添加种子。
+func (a *QBitAdapter) AddMagnet(ctx context.Context, magnet, savePath string) (string, error) {
+ return a.AddTorrent(ctx, magnet, savePath)
+}
+
+// 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
+ }
+ baseURL := strings.TrimRight(a.cfg.Host, "/")
+ form := url.Values{}
+ form.Set("hashes", hash)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost,
+ baseURL+"/api/v2/torrents/pause", strings.NewReader(form.Encode()))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Referer", baseURL)
+ resp, err := a.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ return fmt.Errorf("qbittorrent pause: %d", resp.StatusCode)
+ }
+ return nil
+}
+
+// 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
+ }
+ baseURL := strings.TrimRight(a.cfg.Host, "/")
+ form := url.Values{}
+ form.Set("hashes", hash)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost,
+ baseURL+"/api/v2/torrents/resume", strings.NewReader(form.Encode()))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Referer", baseURL)
+ resp, err := a.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ return fmt.Errorf("qbittorrent resume: %d", resp.StatusCode)
+ }
+ return nil
+}
+
+// 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 := http.NewRequestWithContext(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)
+ 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
+}
+
+// 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"
+ if filter != "" {
+ u += "?filter=" + url.QueryEscape(filter)
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
+ if err != nil {
+ return nil, err
+ }
+ 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)
+ }
+
+ // qBittorrent 返回的字段名与 TorrentInfo 不同,需要转换
+ type qbTorrent 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"`
+ }
+
+ var qbList []qbTorrent
+ if err := json.NewDecoder(resp.Body).Decode(&qbList); err != nil {
+ return nil, err
+ }
+
+ result := make([]TorrentInfo, 0, len(qbList))
+ for _, t := range qbList {
+ result = append(result, TorrentInfo{
+ Hash: t.Hash,
+ Name: t.Name,
+ Size: t.Size,
+ Progress: float64(t.Progress),
+ DLSpeed: t.DLSpeed,
+ UPSpeed: t.UPSpeed,
+ State: t.State,
+ SavePath: t.SavePath,
+ NumSeeds: t.NumSeeds,
+ NumLeechs: t.NumLeechs,
+ AddedOn: time.Unix(t.AddedOn, 0),
+ Category: t.Category,
+ Tags: t.Tags,
+ })
+ }
+ return result, 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)
+}
+
+// loginLocked 执行登录(调用者必须持有锁)。
+func (a *QBitAdapter) loginLocked(ctx context.Context) error {
+ if a.cfg.Host == "" {
+ return fmt.Errorf("qbittorrent host not configured")
+ }
+ form := url.Values{}
+ form.Set("username", a.cfg.Username)
+ form.Set("password", a.cfg.Password)
+ baseURL := strings.TrimRight(a.cfg.Host, "/")
+ req, err := http.NewRequestWithContext(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")
+ req.Header.Set("Referer", 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 || strings.TrimSpace(string(body)) != "Ok." {
+ return fmt.Errorf("qbittorrent login failed: %s", strings.TrimSpace(string(body)))
+ }
+ a.LoggedIn = true
+ return nil
+}
+
+// ensureAuthLocked 确保已认证(调用者必须持有锁)。
+func (a *QBitAdapter) ensureAuthLocked(ctx context.Context) error {
+ if a.LoggedIn {
+ return nil
+ }
+ return a.loginLocked(ctx)
+}
+
+// --- 为了与现有的 QBitClient 兼容,添加转换辅助函数 ---
+
+// 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,
+ }
+}
+
+// TorrentInfoToQBit 将 TorrentInfo 转换回旧的 QBitTorrent 格式(兼容性)。
+func TorrentInfoToQBit(t TorrentInfo) QBitTorrent {
+ return QBitTorrent{
+ Hash: t.Hash,
+ Name: t.Name,
+ State: t.State,
+ Progress: float32(t.Progress),
+ DLSpeed: t.DLSpeed,
+ UpSpeed: t.UPSpeed,
+ NumSeeds: t.NumSeeds,
+ NumLeech: t.NumLeechs,
+ Size: t.Size,
+ SavePath: t.SavePath,
+ }
+}
+
+// unused import guard
+var _ = strconv.Itoa
diff --git a/internal/service/service.go b/internal/service/service.go
index 7528896..a32c34f 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -1,11 +1,11 @@
-// Package service contains the business logic of MediaStationGo. Handlers
-// deserialize the HTTP request, call into a Service method, then serialize
-// the response. Services own all cross-cutting policy (auth, scanning,
-// transcoding, etc.) and never deal with HTTP types directly.
+// Package service 包含 MediaStationGo 的业务逻辑。
+// Handler 反序列化 HTTP 请求,调用 Service 方法,然后序列化响应。
+// Services 拥有所有横切策略(认证、扫描、转码等)且不直接处理 HTTP 类型。
package service
import (
"context"
+ "time"
"go.uber.org/zap"
@@ -13,13 +13,13 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
-// Container holds every service initialized at startup. Handlers receive a
-// pointer to it and pick the relevant fields.
+// Container 持有在启动时初始化的每个服务。Handler 接收指向它的指针并选择相关字段。
type Container struct {
Cfg *config.Config
Log *zap.Logger
Repo *repository.Container
WSHub *Hub
+ SSEHub *SSEHub
Auth *AuthService
Media *MediaService
Scan *ScannerService
@@ -55,16 +55,26 @@ type Container struct {
Notifier *NotifierService
Organizer *OrganizerService
Douban *DoubanProvider
+ Permission *PermissionService
+ Token *TokenService
+ ApiConfig *ApiConfigService
+ DownloadMgr *DownloadManager
+ Notify *NotifyService
+ Site *SiteService
stopCtx context.Context
stopCancel context.CancelFunc
}
-// New builds the service container.
+// New 构建服务容器。
func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Container {
hub := NewHub(log)
go hub.Run()
+ // 初始化 SSE Hub
+ sseHub := NewSSEHub(log)
+ go sseHub.Run()
+
probe := NewFFprobeService(cfg, log)
tmdb := NewTMDbProvider(cfg, log)
bangumi := NewBangumiProvider(cfg, log)
@@ -92,6 +102,14 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
douban := NewDoubanProvider(cfg, log)
scheduler := NewSchedulerService(log, repos, scanner, transcoder, hub, cfg.Cache.CacheDir)
+ // 初始化认证相关服务
+ tokenSvc := NewTokenService(cfg, log, repos)
+ permissionSvc := NewPermissionService(cfg, log, repos)
+ apiConfigSvc := NewApiConfigService(cfg, log, repos, crypto)
+ downloadMgr := NewDownloadManager(log, repos, crypto)
+ notifySvc := NewNotifyService(log, repos, crypto)
+ siteSvc := NewSiteService(log, repos, crypto)
+
ctx, cancel := context.WithCancel(context.Background())
return &Container{
@@ -99,7 +117,8 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
Log: log,
Repo: repos,
WSHub: hub,
- Auth: NewAuthService(cfg, log, repos),
+ SSEHub: sseHub,
+ Auth: NewAuthService(cfg, log, repos, tokenSvc, permissionSvc),
Media: NewMediaService(cfg, log, repos),
Scan: scanner,
Stream: NewStreamService(cfg, log, repos, transcoder),
@@ -134,13 +153,19 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
Notifier: notifier,
Organizer: organizer,
Douban: douban,
+ Permission: permissionSvc,
+ Token: tokenSvc,
+ ApiConfig: apiConfigSvc,
+ DownloadMgr: downloadMgr,
+ Notify: notifySvc,
+ Site: siteSvc,
stopCtx: ctx,
stopCancel: cancel,
}
}
-// Boot kicks off background workers (watcher, downloads poller,
-// subscription scheduler). Called once after AutoMigrate.
+// Boot 启动后台工作进程(watcher, downloads poller, subscription scheduler)。
+// 在 AutoMigrate 后调用一次。
func (c *Container) Boot() {
if err := c.Watcher.Start(c.stopCtx); err != nil {
c.Log.Warn("watcher start failed", zap.Error(err))
@@ -150,11 +175,17 @@ func (c *Container) Boot() {
if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil {
c.Log.Warn("api config seed failed", zap.Error(err))
}
+
+ // 加载所有已配置的下载客户端
+ if err := c.DownloadMgr.LoadAll(c.stopCtx); err != nil {
+ c.Log.Warn("failed to load download clients", zap.Error(err))
+ }
+
+ // 启动调度器定时任务
c.Scheduler.Start(c.stopCtx)
}
-// Close releases any resources held by services (websocket hub, ffmpeg
-// transcodes, fsnotify, background pollers).
+// Close 释放 services 持有的任何资源(websocket hub, ffmpeg 转码, fsnotify, 后台轮询器)。
func (c *Container) Close() {
if c.stopCancel != nil {
c.stopCancel()
@@ -177,4 +208,13 @@ func (c *Container) Close() {
if c.WSHub != nil {
c.WSHub.Stop()
}
+ if c.SSEHub != nil {
+ c.SSEHub.Stop()
+ }
+ if c.Scheduler != nil {
+ c.Scheduler.Stop()
+ }
}
+
+// unused guard
+var _ = time.Now
diff --git a/internal/service/site_adapter.go b/internal/service/site_adapter.go
new file mode 100644
index 0000000..cce5548
--- /dev/null
+++ b/internal/service/site_adapter.go
@@ -0,0 +1,1387 @@
+// Package service — PT 站点适配器接口及 6 种适配器实现。
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// SiteConfig 站点配置(从 model.Site 解密后的纯文本)。
+type SiteConfig struct {
+ Name string
+ Type string
+ URL string
+ AuthType string
+ Cookie string
+ APIKey string
+ AuthHeader string
+ Extra map[string]string // JSON 扩展配置
+}
+
+// SearchResult 站点搜索结果。
+type SearchResult 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"`
+ 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) (*SearchResult, error)
+
+ // Browse 浏览种子列表。
+ Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SearchResult, 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 客户端。
+func newHTTPClient(cfg SiteConfig, timeout time.Duration) *http.Client {
+ return &http.Client{Timeout: timeout}
+}
+
+// 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 != "" {
+ 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)
+ }
+ }
+ }
+
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
+ return req, nil
+}
+
+// doRequest 执行 HTTP 请求并返回响应体。
+func doRequest(ctx context.Context, client *http.Client, method, rawURL string, cfg SiteConfig, body io.Reader) ([]byte, int, error) {
+ 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")
+ }
+
+ resp, err := client.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
+}
+
+// ─── 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 {
+ resp, err := buildRequest(ctx, "GET", cfg.URL+"/index.php", cfg, nil)
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+ httpResp, err := a.client.Do(resp)
+ if err != nil {
+ return fmt.Errorf("request failed: %w", err)
+ }
+ defer httpResp.Body.Close()
+
+ if httpResp.StatusCode == http.StatusFound || httpResp.StatusCode == http.StatusFound {
+ return fmt.Errorf("authentication failed: redirected to login page")
+ }
+ if httpResp.StatusCode != http.StatusOK {
+ return fmt.Errorf("authentication failed: status %d", httpResp.StatusCode)
+ }
+
+ body, _ := io.ReadAll(httpResp.Body)
+ bodyStr := string(body)
+ // NexusPHP 登录页面通常包含 logout 或 userdetails
+ if strings.Contains(bodyStr, "userdetails") || strings.Contains(bodyStr, "logout") {
+ return nil
+ }
+ // Check for common login indicators
+ if strings.Contains(bodyStr, "login") && !strings.Contains(bodyStr, "userdetails") {
+ return fmt.Errorf("authentication failed: not logged in")
+ }
+ return nil
+}
+
+func (a *NexusPHPAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SearchResult, error) {
+ params := url.Values{}
+ params.Set("search", keyword)
+ 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)
+ }
+
+ return parseNexusPHPHTML(string(data), cfg.Name, cfg.URL)
+}
+
+func (a *NexusPHPAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SearchResult, 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)
+ }
+
+ return parseNexusPHPHTML(string(data), 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
+}
+
+// parseNexusPHPHTML 解析 NexusPHP 种子列表 HTML。
+func parseNexusPHPHTML(html, siteName, baseURL string) (*SearchResult, error) {
+ result := &SearchResult{
+ SiteName: siteName,
+ Items: []TorrentItem{},
+ Page: 1,
+ }
+
+ // Extract table rows from torrent table
+ rowRegex := regexp.MustCompile(`]*>\s*| ]*class="rowfollow"[^>]*>.*? |
`)
+ matches := rowRegex.FindAllString(html, -1)
+
+ for _, row := range matches {
+ item := parseNexusPHPRow(row, baseURL)
+ if item.ID != "" {
+ result.Items = append(result.Items, item)
+ }
+ }
+
+ result.Total = len(result.Items)
+ return result, nil
+}
+
+// parseNexusPHPRow 解析单行种子条目。
+func parseNexusPHPRow(row, baseURL string) TorrentItem {
+ item := TorrentItem{}
+
+ // Extract torrent ID and title
+ idRegex := regexp.MustCompile(`details\.php\?id=(\d+)[^"]*"[^>]*>([^<]+)`)
+ idMatches := idRegex.FindStringSubmatch(row)
+ if len(idMatches) >= 3 {
+ item.ID = idMatches[1]
+ item.Title = strings.TrimSpace(idMatches[2])
+ item.DetailURL = baseURL + "/details.php?id=" + item.ID
+ }
+
+ // Extract download link
+ dlRegex := regexp.MustCompile(`download\.php\?id=(\d+)`)
+ if dlMatches := dlRegex.FindStringSubmatch(row); len(dlMatches) >= 2 {
+ item.DownloadURL = baseURL + "/download.php?id=" + dlMatches[1]
+ }
+
+ // Extract size
+ sizeRegex := regexp.MustCompile(`(?i)(\d+\.?\d*)\s*(GB|MB|TB|KB)`)
+ if sizeMatches := sizeRegex.FindStringSubmatch(row); len(sizeMatches) >= 3 {
+ item.Size = parseSizeString(sizeMatches[1], sizeMatches[2])
+ }
+
+ // Extract seeders and leechers
+ seedersRegex := regexp.MustCompile(`seeders[^"]*"[^>]*>(\d+)<`)
+ if m := seedersRegex.FindStringSubmatch(row); len(m) >= 2 {
+ item.Seeders, _ = strconv.Atoi(m[1])
+ }
+ leechersRegex := regexp.MustCompile(`leechers[^"]*"[^>]*>(\d+)<`)
+ if m := leechersRegex.FindStringSubmatch(row); len(m) >= 2 {
+ item.Leechers, _ = strconv.Atoi(m[1])
+ }
+
+ // Extract snatched
+ snatchedRegex := regexp.MustCompile(`snatched[^"]*"[^>]*>(\d+)`)
+ if m := snatchedRegex.FindStringSubmatch(row); len(m) >= 2 {
+ item.Snatched, _ = strconv.Atoi(m[1])
+ }
+
+ // Check for free flag
+ freeRegex := regexp.MustCompile(`(?i)(class="free|free2|twoupfree|free_download|促销|免费)`)
+ item.Free = freeRegex.MatchString(row)
+
+ // Extract upload time
+ timeRegex := regexp.MustCompile(`(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})`)
+ if m := timeRegex.FindStringSubmatch(row); len(m) >= 2 {
+ if t, err := time.Parse("2006-01-02 15:04", m[1]); err == nil {
+ item.UploadTime = t
+ }
+ }
+
+ // Extract category
+ catRegex := regexp.MustCompile(`cat=(\d+)[^"]*"[^>]*title="([^"]+)"`)
+ if m := catRegex.FindStringSubmatch(row); len(m) >= 3 {
+ item.Category = strings.TrimSpace(m[2])
+ }
+
+ return item
+}
+
+// parseNexusPHPDetailHTML 解析种子详情页。
+func parseNexusPHPDetailHTML(html, id, baseURL string) (*TorrentDetail, error) {
+ detail := &TorrentDetail{
+ ID: id,
+ DetailURL: baseURL + "/details.php?id=" + id,
+ }
+
+ // Title
+ titleRegex := regexp.MustCompile(`]*>([^<]+)
`)
+ if m := titleRegex.FindStringSubmatch(html); len(m) >= 2 {
+ detail.Title = strings.TrimSpace(m[1])
+ }
+
+ // Subtitle
+ subRegex := regexp.MustCompile(`]*class="[^"]*sub[^"]*"[^>]*>([^<]+)`)
+ if m := subRegex.FindStringSubmatch(html); len(m) >= 2 {
+ detail.Subtitle = strings.TrimSpace(m[1])
+ }
+
+ // Info hash
+ hashRegex := regexp.MustCompile(`(?i)info_hash[^<]*\s*]*>([^<]+) | `)
+ if m := hashRegex.FindStringSubmatch(html); len(m) >= 2 {
+ detail.InfoHash = strings.TrimSpace(m[1])
+ }
+
+ // IMDB ID
+ imdbRegex := regexp.MustCompile(`(?i)imdb[^<]*\s*]*>[^<]*(tt\d+)`)
+ if m := imdbRegex.FindStringSubmatch(html); len(m) >= 2 {
+ detail.ImdbID = m[1]
+ }
+
+ // Size
+ sizeRegex := regexp.MustCompile(`(?i)size[^<]* | \s*]*>(\d+\.?\d*)\s*(GB|MB|TB|KB)`)
+ if m := sizeRegex.FindStringSubmatch(html); len(m) >= 3 {
+ detail.Size = parseSizeString(m[1], m[2])
+ }
+
+ // Seeders / Leechers / Snatched
+ slRegex := regexp.MustCompile(`seeders[^<]* | \s*]*>(\d+) | \s*]*>\s* | \s*]*>\s* | \s*]*>leechers[^<]* | \s*]*>(\d+)`)
+ if m := slRegex.FindStringSubmatch(html); len(m) >= 3 {
+ detail.Seeders, _ = strconv.Atoi(m[1])
+ detail.Leechers, _ = strconv.Atoi(m[2])
+ }
+
+ snRegex := regexp.MustCompile(`(?i)times completed[^<]* | \s*]*>(\d+)`)
+ if m := snRegex.FindStringSubmatch(html); len(m) >= 2 {
+ detail.Snatched, _ = strconv.Atoi(m[1])
+ }
+
+ // Description
+ descRegex := regexp.MustCompile(`(?i) ]*id="kdescr"[^>]*>(.*?) `)
+ if m := descRegex.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
+}
+
+// ─── 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) (*SearchResult, 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) (*SearchResult, 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) (*SearchResult, error) {
+ var resp map[string]interface{}
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, fmt.Errorf("parse JSON: %w", err)
+ }
+
+ result := &SearchResult{
+ 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
+}
+
+// ─── 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) (*SearchResult, 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) (*SearchResult, 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) (*SearchResult, 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 := &SearchResult{
+ 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
+}
+
+// ─── 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 {
+ u := cfg.URL + "/api/torrent/search"
+ payload := `{"mode":"search","keyword":"","page":1,"pageSize":1}`
+ data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte(payload))
+ if err != nil {
+ return fmt.Errorf("authenticate: %w", err)
+ }
+ if status == http.StatusUnauthorized {
+ return fmt.Errorf("authentication failed: unauthorized")
+ }
+ 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 code, ok := resp["code"].(float64); ok && code != 0 {
+ return fmt.Errorf("authentication failed: code %v", code)
+ }
+ }
+ return nil
+}
+
+func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SearchResult, error) {
+ payload := map[string]interface{}{
+ "mode": "search",
+ "keyword": keyword,
+ "page": page,
+ "pageSize": 50,
+ }
+ body, _ := json.Marshal(payload)
+
+ u := cfg.URL + "/api/torrent/search"
+ data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, body)
+ if err != nil {
+ return nil, fmt.Errorf("search: %w", 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) (*SearchResult, error) {
+ payload := map[string]interface{}{
+ "mode": "browse",
+ "category": category,
+ "page": page,
+ "pageSize": 50,
+ }
+ body, _ := json.Marshal(payload)
+
+ u := cfg.URL + "/api/torrent/search"
+ data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, body)
+ if err != nil {
+ return nil, fmt.Errorf("browse: %w", 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) {
+ u := cfg.URL + "/api/torrent/detail?id=" + 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 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
+}
+
+func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
+ return cfg.URL + "/api/torrent/detail?id=" + id, nil
+}
+
+// parseMTeamJSON 解析 MTeam JSON 响应。
+func parseMTeamJSON(data []byte, siteName, baseURL string) (*SearchResult, error) {
+ var resp struct {
+ Code int `json:"code"`
+ Data struct {
+ Total int `json:"total"`
+ Lists []map[string]interface{} `json:"lists"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, fmt.Errorf("parse JSON: %w", err)
+ }
+
+ result := &SearchResult{
+ SiteName: siteName,
+ Items: []TorrentItem{},
+ Total: resp.Data.Total,
+ }
+
+ for _, t := range resp.Data.Lists {
+ 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)
+ }
+ 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
+ result.Items = append(result.Items, item)
+ }
+
+ return result, nil
+}
+
+// ─── 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 || 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) (*SearchResult, 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) (*SearchResult, 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) (*SearchResult, error) {
+ result := &SearchResult{
+ 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
+}
+
+// ─── 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) (*SearchResult, 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) (*SearchResult, 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) (*SearchResult, error) {
+ result := &SearchResult{
+ SiteName: siteName,
+ Items: []TorrentItem{},
+ }
+
+ html := string(data)
+ // Simple regex-based XML parsing for RSS items
+ itemRegex := regexp.MustCompile(`- (.*?)
`)
+ items := itemRegex.FindAllStringSubmatch(html, -1)
+
+ for i, item := range items {
+ ri := TorrentItem{}
+
+ // Title
+ titleRegex := regexp.MustCompile(`(?:)?`)
+ if m := titleRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
+ ri.Title = strings.TrimSpace(m[1])
+ }
+
+ // Filter by keyword
+ if keyword != "" && !strings.Contains(strings.ToLower(ri.Title), strings.ToLower(keyword)) {
+ continue
+ }
+
+ ri.ID = strconv.Itoa(i)
+
+ // Link
+ linkRegex := regexp.MustCompile(`(?:)?`)
+ if m := linkRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
+ ri.DetailURL = strings.TrimSpace(m[1])
+ ri.DownloadURL = strings.TrimSpace(m[1])
+ }
+
+ // Description
+ descRegex := regexp.MustCompile(`(?:)?`)
+ if m := descRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
+ desc := stripHTML(m[1])
+ ri.Subtitle = desc
+ }
+
+ // Size from description
+ sizeRegex := regexp.MustCompile(`(\d+\.?\d*)\s*(GB|MB|TB|KB)`)
+ if m := sizeRegex.FindStringSubmatch(item[1]); len(m) >= 3 {
+ ri.Size = parseSizeString(m[1], m[2])
+ }
+
+ // Category
+ catRegex := regexp.MustCompile(`(?:)?`)
+ if m := catRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
+ ri.Category = strings.TrimSpace(m[1])
+ }
+
+ // Date
+ dateRegex := regexp.MustCompile(`(?:)?`)
+ if m := dateRegex.FindStringSubmatch(item[1]); len(m) >= 2 {
+ for _, layout := range []string{
+ time.RFC1123, time.RFC1123Z, time.RFC3339,
+ "2006-01-02 15:04:05", "2006-01-02T15:04:05-07:00",
+ } {
+ if t, err := time.Parse(layout, strings.TrimSpace(m[1])); err == nil {
+ ri.UploadTime = t
+ break
+ }
+ }
+ }
+
+ result.Items = append(result.Items, ri)
+ }
+
+ result.Total = len(result.Items)
+ return result, nil
+}
+
+// TorrentDetail has a Description field used by RSS adapter.
+// (Already defined above)
+
+// ─── 辅助函数 ────────────────────────────────────────────────────────────────
+
+// 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))
+ }
+
+ resp, err := client.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
+}
+
+// parseSizeString 将带单位的字符串转换为字节数。
+func parseSizeString(value string, unit string) int64 {
+ v, err := strconv.ParseFloat(value, 64)
+ if err != nil {
+ return 0
+ }
+ switch strings.ToLower(unit) {
+ 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, "")
+}
+
+// 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 "discuz":
+ return NewDiscuzAdapter()
+ case "custom_rss":
+ return NewCustomRSSAdapter()
+ default:
+ return NewNexusPHPAdapter()
+ }
+}
diff --git a/internal/service/site_search_svc.go b/internal/service/site_search_svc.go
new file mode 100644
index 0000000..2651c26
--- /dev/null
+++ b/internal/service/site_search_svc.go
@@ -0,0 +1,214 @@
+// Package service — 跨站聚合搜索服务.
+package service
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+ "sync"
+
+ "go.uber.org/zap"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/repository"
+)
+
+// SiteSearchService 跨站聚合搜索服务.
+type SiteSearchService struct {
+ log *zap.Logger
+ repo *repository.Container
+ site *SiteService
+}
+
+// NewSiteSearchService 创建跨站搜索服务.
+func NewSiteSearchService(log *zap.Logger, repo *repository.Container, siteSvc *SiteService) *SiteSearchService {
+ return &SiteSearchService{log: log, repo: repo, site: siteSvc}
+}
+
+// SearchAll 在所有启用的站点中搜索关键字.
+func (s *SiteSearchService) SearchAll(ctx context.Context, keyword string, page, pageSize int) (*AggregatedResult, error) {
+ sites, err := s.repo.Site.ListEnabled(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("list enabled sites: %w", err)
+ }
+
+ if len(sites) == 0 {
+ return &AggregatedResult{
+ Keyword: keyword,
+ Items: []TorrentItem{},
+ Total: 0,
+ Page: page,
+ PageSize: pageSize,
+ }, nil
+ }
+
+ return s.SearchSites(ctx, keyword, sites, page, pageSize)
+}
+
+// SearchSites 在指定站点中搜索关键字.
+func (s *SiteSearchService) SearchSites(ctx context.Context, keyword string, sites []model.Site, page, pageSize int) (*AggregatedResult, error) {
+ var mu sync.Mutex
+ var wg sync.WaitGroup
+ var allItems []TorrentItem
+
+ for _, site := range sites {
+ wg.Add(1)
+ go func(siteModel model.Site) {
+ defer wg.Done()
+
+ cfg, err := s.site.GetSiteConfig(ctx, siteModel.ID)
+ if err != nil {
+ s.log.Warn("get site config failed", zap.String("site_id", siteModel.ID), zap.Error(err))
+ return
+ }
+
+ adapter := GetAdapterForType(siteModel.Type)
+ result, err := adapter.Search(ctx, *cfg, keyword, page)
+ if err != nil {
+ s.log.Warn("site search failed",
+ zap.String("site_id", siteModel.ID),
+ zap.String("site_name", siteModel.Name),
+ zap.Error(err),
+ )
+ return
+ }
+
+ mu.Lock()
+ allItems = append(allItems, result.Items...)
+ mu.Unlock()
+ }(site)
+ }
+
+ wg.Wait()
+
+ sort.Slice(allItems, func(i, j int) bool {
+ if allItems[i].Seeders != allItems[j].Seeders {
+ return allItems[i].Seeders > allItems[j].Seeders
+ }
+ return allItems[i].UploadTime.After(allItems[j].UploadTime)
+ })
+
+ allItems = deduplicateItems(allItems)
+
+ total := len(allItems)
+ start := (page - 1) * pageSize
+ end := start + pageSize
+ if start > total {
+ start = total
+ }
+ if end > total {
+ end = total
+ }
+
+ return &AggregatedResult{
+ Keyword: keyword,
+ Items: allItems[start:end],
+ Total: total,
+ Page: page,
+ PageSize: pageSize,
+ }, nil
+}
+
+// SearchSite 在单个站点中搜索.
+func (s *SiteSearchService) SearchSite(ctx context.Context, siteID, keyword string, page int) (*SearchResult, error) {
+ cfg, err := s.site.GetSiteConfig(ctx, siteID)
+ if err != nil {
+ return nil, fmt.Errorf("get site config: %w", err)
+ }
+
+ siteModel, err := s.repo.Site.FindByID(ctx, siteID)
+ if err != nil || siteModel == nil {
+ return nil, fmt.Errorf("find site: %w", err)
+ }
+
+ adapter := GetAdapterForType(siteModel.Type)
+ result, err := adapter.Search(ctx, *cfg, keyword, page)
+ if err != nil {
+ return nil, fmt.Errorf("search site %s: %w", siteModel.Name, err)
+ }
+
+ return result, nil
+}
+
+// BrowseSite 浏览站点资源.
+func (s *SiteSearchService) BrowseSite(ctx context.Context, siteID, category string, page int) (*SearchResult, error) {
+ cfg, err := s.site.GetSiteConfig(ctx, siteID)
+ if err != nil {
+ return nil, fmt.Errorf("get site config: %w", err)
+ }
+
+ siteModel, err := s.repo.Site.FindByID(ctx, siteID)
+ if err != nil || siteModel == nil {
+ return nil, fmt.Errorf("find site: %w", err)
+ }
+
+ adapter := GetAdapterForType(siteModel.Type)
+ result, err := adapter.Browse(ctx, *cfg, category, page)
+ if err != nil {
+ return nil, fmt.Errorf("browse site %s: %w", siteModel.Name, err)
+ }
+
+ return result, nil
+}
+
+// GetTorrentDetail 获取种子详情.
+func (s *SiteSearchService) GetTorrentDetail(ctx context.Context, siteID, torrentID string) (*TorrentDetail, error) {
+ cfg, err := s.site.GetSiteConfig(ctx, siteID)
+ if err != nil {
+ return nil, fmt.Errorf("get site config: %w", err)
+ }
+
+ siteModel, err := s.repo.Site.FindByID(ctx, siteID)
+ if err != nil || siteModel == nil {
+ return nil, fmt.Errorf("find site: %w", err)
+ }
+
+ adapter := GetAdapterForType(siteModel.Type)
+ detail, err := adapter.GetDetail(ctx, *cfg, torrentID)
+ if err != nil {
+ return nil, fmt.Errorf("get detail from %s: %w", siteModel.Name, err)
+ }
+
+ return detail, nil
+}
+
+// AggregatedResult 聚合搜索结果.
+type AggregatedResult struct {
+ Keyword string `json:"keyword"`
+ Items []TorrentItem `json:"items"`
+ Total int `json:"total"`
+ Page int `json:"page"`
+ PageSize int `json:"page_size"`
+}
+
+// deduplicateItems 通过标题相似性去重.
+func deduplicateItems(items []TorrentItem) []TorrentItem {
+ seen := make(map[string]bool)
+ result := make([]TorrentItem, 0, len(items))
+
+ for _, item := range items {
+ key := normalizeTitle(item.Title)
+ if key == "" {
+ continue
+ }
+ if !seen[key] {
+ seen[key] = true
+ result = append(result, item)
+ }
+ }
+
+ return result
+}
+
+// normalizeTitle 标题标准化.
+func normalizeTitle(title string) string {
+ title = strings.ToLower(strings.TrimSpace(title))
+ title = strings.ReplaceAll(title, ".", " ")
+ title = strings.ReplaceAll(title, "_", " ")
+ title = strings.ReplaceAll(title, "-", " ")
+ for strings.Contains(title, " ") {
+ title = strings.ReplaceAll(title, " ", " ")
+ }
+ return strings.TrimSpace(title)
+}
diff --git a/internal/service/site_svc.go b/internal/service/site_svc.go
new file mode 100644
index 0000000..d674832
--- /dev/null
+++ b/internal/service/site_svc.go
@@ -0,0 +1,246 @@
+// Package service — PT 站点管理服务。
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "time"
+
+ "go.uber.org/zap"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/repository"
+)
+
+// 站点管理错误码。
+var (
+ ErrSiteNotFound = errors.New("site not found")
+ ErrSiteAuthFailed = errors.New("site authentication failed")
+ ErrSiteTypeInvalid = errors.New("invalid site type")
+ ErrSiteAuthInvalid = errors.New("invalid auth type")
+)
+
+// SiteService 站点管理服务。
+type SiteService struct {
+ log *zap.Logger
+ repo *repository.Container
+ crypto *CryptoService
+}
+
+// NewSiteService 创建站点管理服务。
+func NewSiteService(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *SiteService {
+ return &SiteService{log: log, repo: repo, crypto: crypto}
+}
+
+// Create 创建站点,加密敏感字段。
+func (s *SiteService) Create(ctx context.Context, site *model.Site) (*model.Site, error) {
+ if !isValidSiteType(site.Type) {
+ return nil, ErrSiteTypeInvalid
+ }
+ if !isValidAuthType(site.AuthType) {
+ return nil, ErrSiteAuthInvalid
+ }
+
+ // 加密敏感字段
+ s.encryptSite(site)
+
+ if err := s.repo.Site.Create(ctx, site); err != nil {
+ s.log.Error("create site failed", zap.Error(err))
+ return nil, err
+ }
+
+ return site, nil
+}
+
+// GetByID 获取站点(敏感字段解密)。
+func (s *SiteService) GetByID(ctx context.Context, id string) (*model.Site, error) {
+ site, err := s.repo.Site.FindByID(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ if site == nil {
+ return nil, ErrSiteNotFound
+ }
+
+ s.decryptSite(site)
+ return site, nil
+}
+
+// List 获取所有站点(不含敏感字段)。
+func (s *SiteService) List(ctx context.Context) ([]model.Site, error) {
+ sites, err := s.repo.Site.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return sites, nil
+}
+
+// Update 更新站点。
+func (s *SiteService) Update(ctx context.Context, site *model.Site) (*model.Site, error) {
+ existing, err := s.repo.Site.FindByID(ctx, site.ID)
+ if err != nil {
+ return nil, err
+ }
+ if existing == nil {
+ return nil, ErrSiteNotFound
+ }
+
+ if !isValidSiteType(site.Type) {
+ return nil, ErrSiteTypeInvalid
+ }
+ if !isValidAuthType(site.AuthType) {
+ return nil, ErrSiteAuthInvalid
+ }
+
+ s.encryptSite(site)
+
+ if err := s.repo.Site.Update(ctx, site); err != nil {
+ s.log.Error("update site failed", zap.Error(err))
+ return nil, err
+ }
+
+ return site, nil
+}
+
+// Delete 删除站点。
+func (s *SiteService) Delete(ctx context.Context, id string) error {
+ existing, err := s.repo.Site.FindByID(ctx, id)
+ if err != nil {
+ return err
+ }
+ if existing == nil {
+ return ErrSiteNotFound
+ }
+ return s.repo.Site.Delete(ctx, id)
+}
+
+// Authenticate 测试站点认证。
+func (s *SiteService) Authenticate(ctx context.Context, id string) error {
+ site, err := s.repo.Site.FindByID(ctx, id)
+ if err != nil {
+ return err
+ }
+ if site == nil {
+ return ErrSiteNotFound
+ }
+
+ cfg, err := s.toSiteConfig(site)
+ if err != nil {
+ return err
+ }
+
+ adapter := GetAdapterForType(site.Type)
+ if err := adapter.Authenticate(ctx, *cfg); err != nil {
+ // 更新错误状态
+ now := time.Now()
+ site.LastError = err.Error()
+ site.LastCheckAt = &now
+ _ = s.repo.Site.Update(ctx, site)
+ return ErrSiteAuthFailed
+ }
+
+ // 清除错误状态
+ now := time.Now()
+ site.LastError = ""
+ site.LastCheckAt = &now
+ _ = s.repo.Site.Update(ctx, site)
+ return nil
+}
+
+// GetSiteConfig 获取解密后的站点配置(供内部使用)。
+func (s *SiteService) GetSiteConfig(ctx context.Context, id string) (*SiteConfig, error) {
+ site, err := s.repo.Site.FindByID(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ if site == nil {
+ return nil, ErrSiteNotFound
+ }
+ return s.toSiteConfig(site)
+}
+
+// encryptSite 加密站点敏感字段。
+func (s *SiteService) encryptSite(site *model.Site) {
+ if site.Cookie != "" {
+ site.Cookie = s.crypto.Encrypt(site.Cookie)
+ }
+ if site.APIKey != "" {
+ site.APIKey = s.crypto.Encrypt(site.APIKey)
+ }
+ if site.AuthHeader != "" {
+ site.AuthHeader = s.crypto.Encrypt(site.AuthHeader)
+ }
+ if site.Extra != "" {
+ site.Extra = s.crypto.Encrypt(site.Extra)
+ }
+}
+
+// decryptSite 解密站点敏感字段。
+func (s *SiteService) decryptSite(site *model.Site) {
+ if site.Cookie != "" {
+ site.Cookie = s.crypto.Decrypt(site.Cookie)
+ }
+ if site.APIKey != "" {
+ site.APIKey = s.crypto.Decrypt(site.APIKey)
+ }
+ if site.AuthHeader != "" {
+ site.AuthHeader = s.crypto.Decrypt(site.AuthHeader)
+ }
+ if site.Extra != "" {
+ site.Extra = s.crypto.Decrypt(site.Extra)
+ }
+}
+
+// toSiteConfig 将 model.Site 转换为 SiteConfig(解密后)。
+func (s *SiteService) toSiteConfig(site *model.Site) (*SiteConfig, error) {
+ cfg := &SiteConfig{
+ Name: site.Name,
+ Type: site.Type,
+ URL: strings.TrimRight(site.URL, "/"),
+ AuthType: site.AuthType,
+ Extra: map[string]string{},
+ }
+
+ // 解密
+ if site.Cookie != "" {
+ cfg.Cookie = s.crypto.Decrypt(site.Cookie)
+ }
+ if site.APIKey != "" {
+ cfg.APIKey = s.crypto.Decrypt(site.APIKey)
+ }
+ if site.AuthHeader != "" {
+ cfg.AuthHeader = s.crypto.Decrypt(site.AuthHeader)
+ }
+ if site.Extra != "" {
+ dec := s.crypto.Decrypt(site.Extra)
+ if dec != "" {
+ if err := json.Unmarshal([]byte(dec), &cfg.Extra); err != nil {
+ s.log.Warn("parse site extra config failed", zap.Error(err))
+ }
+ }
+ }
+
+ return cfg, nil
+}
+
+// isValidSiteType 检查站点类型是否有效。
+func isValidSiteType(siteType string) bool {
+ for _, t := range model.SiteTypes() {
+ if t == siteType {
+ return true
+ }
+ }
+ return false
+}
+
+// isValidAuthType 检查认证方式是否有效。
+func isValidAuthType(authType string) bool {
+ for _, t := range model.AuthTypes() {
+ if t == authType {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/service/sse_hub.go b/internal/service/sse_hub.go
new file mode 100644
index 0000000..d3949be
--- /dev/null
+++ b/internal/service/sse_hub.go
@@ -0,0 +1,228 @@
+// Package service — SSE (Server-Sent Events) 事件流服务。
+package service
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "sync"
+ "time"
+
+ "go.uber.org/zap"
+)
+
+// SSEHub 管理 SSE 客户端连接和事件广播。
+type SSEHub struct {
+ clients map[chan SSEEvent]bool
+ broadcast chan SSEEvent
+ register chan chan SSEEvent
+ unregister chan chan SSEEvent
+ log *zap.Logger
+ tickets map[string]*sseTicket
+ ticketMu sync.RWMutex
+ stopCh chan struct{}
+}
+
+type SSEEvent struct {
+ Type string `json:"type"`
+ Payload interface{} `json:"payload"`
+}
+
+// SSEEvent 事件类型常量。
+const (
+ EventTypeScan = "scan"
+ EventTypeDownload = "download"
+ EventTypeSubscribe = "subscribe"
+ EventTypeTask = "task"
+ EventTypeSystem = "system"
+ EventTypeAuth = "auth"
+)
+
+// sseTicket 是一次性 OTP 票据。
+type sseTicket struct {
+ UserID string
+ ExpiresAt time.Time
+}
+
+// NewSSEHub 创建 SSE Hub 实例。
+func NewSSEHub(log *zap.Logger) *SSEHub {
+ return &SSEHub{
+ clients: make(map[chan SSEEvent]bool),
+ broadcast: make(chan SSEEvent, 256),
+ register: make(chan chan SSEEvent),
+ unregister: make(chan chan SSEEvent),
+ tickets: make(map[string]*sseTicket),
+ log: log,
+ stopCh: make(chan struct{}),
+ }
+}
+
+// Run 启动 SSE Hub 的事件循环。
+func (h *SSEHub) Run() {
+ for {
+ select {
+ case client := <-h.register:
+ h.clients[client] = true
+ h.log.Debug("SSE client connected", zap.Int("total", len(h.clients)))
+
+ case client := <-h.unregister:
+ if _, ok := h.clients[client]; ok {
+ delete(h.clients, client)
+ close(client)
+ h.log.Debug("SSE client disconnected", zap.Int("total", len(h.clients)))
+ }
+
+ case event := <-h.broadcast:
+ h.distribute(event)
+
+ case <-h.stopCh:
+ h.closeAll()
+ return
+ }
+ }
+}
+
+// Stop 停止 SSE Hub。
+func (h *SSEHub) Stop() {
+ close(h.stopCh)
+}
+
+// ClientChannel SSE 客户端通道包装器。
+type ClientChannel struct {
+ Ch chan SSEEvent
+}
+
+// Subscribe 注册一个新的 SSE 客户端,返回 SSE 客户端包装器。
+func (h *SSEHub) Subscribe() *ClientChannel {
+ ch := make(chan SSEEvent, 100)
+ h.register <- ch
+ return &ClientChannel{Ch: ch}
+}
+
+// Unsubscribe 取消注册 SSE 客户端。
+func (h *SSEHub) Unsubscribe(client *ClientChannel) {
+ if client != nil && client.Ch != nil {
+ h.unregister <- client.Ch
+ }
+}
+
+// Broadcast 向所有连接的客户端广播事件。
+func (h *SSEHub) Broadcast(eventType string, payload interface{}) {
+ event := SSEEvent{
+ Type: eventType,
+ Payload: payload,
+ }
+ select {
+ case h.broadcast <- event:
+ default:
+ h.log.Warn("SSE broadcast queue full, dropping event", zap.String("type", eventType))
+ }
+}
+
+// SendToUser 向指定用户发送事件(通过 UserID 匹配)。
+// 注意:此方法需要在客户端连接时关联 UserID。
+func (h *SSEHub) SendToUser(userID string, eventType string, payload interface{}) {
+ // 目前通过广播实现,未来可扩展为按用户分组
+ h.Broadcast(eventType, payload)
+}
+
+// distribute 将事件分发给所有客户端。
+func (h *SSEHub) distribute(event SSEEvent) {
+ data, err := json.Marshal(event)
+ if err != nil {
+ h.log.Error("failed to marshal SSE event", zap.Error(err))
+ return
+ }
+
+ for client := range h.clients {
+ select {
+ case client <- event:
+ default:
+ // 客户端通道已满,跳过
+ h.log.Warn("SSE client buffer full", zap.String("event", string(data)))
+ }
+ }
+}
+
+// closeAll 关闭所有客户端连接。
+func (h *SSEHub) closeAll() {
+ for client := range h.clients {
+ close(client)
+ }
+ h.clients = make(map[chan SSEEvent]bool)
+}
+
+// GenerateTicket 生成一次性 SSE 连接票据(用于无 JWT 场景下的安全连接)。
+func (h *SSEHub) GenerateTicket(userID string) (string, error) {
+ buf := make([]byte, 16)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ ticket := hex.EncodeToString(buf)
+
+ h.ticketMu.Lock()
+ defer h.ticketMu.Unlock()
+
+ h.tickets[ticket] = &sseTicket{
+ UserID: userID,
+ ExpiresAt: time.Now().Add(10 * time.Second),
+ }
+
+ return ticket, nil
+}
+
+// ValidateTicket 验证 SSE 连接票据,返回关联的用户 ID。
+func (h *SSEHub) ValidateTicket(ticket string) (string, error) {
+ h.ticketMu.Lock()
+ defer h.ticketMu.Unlock()
+
+ t, ok := h.tickets[ticket]
+ if !ok {
+ return "", ErrInvalidTicket
+ }
+
+ if time.Now().After(t.ExpiresAt) {
+ delete(h.tickets, ticket)
+ return "", ErrTicketExpired
+ }
+
+ userID := t.UserID
+ delete(h.tickets, ticket)
+
+ return userID, nil
+}
+
+// CleanupTickets 清理过期的票据。
+func (h *SSEHub) CleanupTickets() {
+ h.ticketMu.Lock()
+ defer h.ticketMu.Unlock()
+
+ now := time.Now()
+ for ticket, t := range h.tickets {
+ if now.After(t.ExpiresAt) {
+ delete(h.tickets, ticket)
+ }
+ }
+}
+
+// SSE Hub 错误定义。
+var (
+ ErrInvalidTicket = &SSEError{Message: "invalid ticket"}
+ ErrTicketExpired = &SSEError{Message: "ticket expired"}
+)
+
+// SSEError SSE 相关错误。
+type SSEError struct {
+ Message string
+}
+
+func (e *SSEError) Error() string {
+ return e.Message
+}
+
+// ClientCount 返回当前连接的客户端数量。
+func (h *SSEHub) ClientCount() int {
+ h.ticketMu.RLock()
+ defer h.ticketMu.RUnlock()
+ return len(h.clients)
+}
diff --git a/internal/service/strm_svc.go b/internal/service/strm_svc.go
new file mode 100644
index 0000000..483ddbd
--- /dev/null
+++ b/internal/service/strm_svc.go
@@ -0,0 +1,235 @@
+// Package service — STRM 文件管理服务。
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "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
+}
+
+// ProxySTRM 代理访问 STRM 资源。
+// 支持 Range 请求(206 Partial Content)。
+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
+ }
+
+ // 创建代理请求
+ proxyReq, err := http.NewRequestWithContext(ctx, req.Method, record.URL, nil)
+ if err != nil {
+ return fmt.Errorf("create proxy request: %w", err)
+ }
+
+ // 复制 Range 等关键请求头
+ 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 := req.Header.Get(header); v != "" {
+ proxyReq.Header.Set(header, v)
+ }
+ }
+
+ // 对 alist/webdav 协议可能需要特殊处理认证
+ if record.Protocol == "alist" || record.Protocol == "alists" {
+ // alist 协议可以直接访问,无需额外认证
+ }
+
+ client := &http.Client{Timeout: 60 * time.Second}
+ resp, err := client.Do(proxyReq)
+ if err != nil {
+ return fmt.Errorf("proxy request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ // 复制响应头
+ for _, header := range []string{
+ "Content-Type", "Content-Length", "Content-Range",
+ "Accept-Ranges", "Last-Modified", "ETag",
+ "Cache-Control", "Content-Disposition",
+ } {
+ if v := resp.Header.Get(header); v != "" {
+ w.Header().Set(header, v)
+ }
+ }
+
+ w.WriteHeader(resp.StatusCode)
+ _, err = io.Copy(w, resp.Body)
+ return err
+}
+
+// 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/token_svc.go b/internal/service/token_svc.go
new file mode 100644
index 0000000..dd33492
--- /dev/null
+++ b/internal/service/token_svc.go
@@ -0,0 +1,186 @@
+// Package service — 双令牌认证服务。
+package service
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "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"
+)
+
+const (
+ // AccessTokenDuration Access Token 有效期(60分钟)
+ AccessTokenDuration = 60 * time.Minute
+ // RefreshTokenDuration Refresh Token 有效期(30天)
+ RefreshTokenDuration = 30 * 24 * time.Hour
+ // RefreshTokenLength Refresh Token 随机字节长度
+ RefreshTokenLength = 32
+)
+
+// Claims 是 JWT 载荷(复制自 middleware 以避免循环导入)。
+type Claims struct {
+ UserID string `json:"uid"`
+ Role string `json:"role"`
+ Tier string `json:"tier,omitempty"`
+ jwt.RegisteredClaims
+}
+
+// TokenService 处理双令牌认证(Access Token + Refresh Token)。
+type TokenService struct {
+ cfg *config.Config
+ log *zap.Logger
+ repo *repository.Container
+}
+
+// NewTokenService 创建令牌服务实例。
+func NewTokenService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *TokenService {
+ return &TokenService{cfg: cfg, log: log, repo: repo}
+}
+
+// TokenPair 包含访问令牌和刷新令牌。
+type TokenPair struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn int64 `json:"expires_in"` // 秒
+ TokenType string `json:"token_type"`
+}
+
+// TokenService 错误定义。
+var (
+ ErrInvalidRefreshToken = errors.New("invalid refresh token")
+ ErrTokenExpired = errors.New("token expired")
+ ErrTokenRevoked = errors.New("token revoked")
+)
+
+// IssuePair 为用户签发新的令牌对。
+func (s *TokenService) IssuePair(ctx context.Context, userID, role, tier string) (*TokenPair, error) {
+ // 生成 Access Token
+ accessToken, err := s.issueAccessToken(userID, role, tier)
+ if err != nil {
+ return nil, err
+ }
+
+ // 生成 Refresh Token
+ refreshToken, err := s.generateRefreshToken()
+ if err != nil {
+ return nil, err
+ }
+
+ // 存储 Refresh Token 哈希
+ tokenHash := repository.HashToken(refreshToken)
+ rt := &model.RefreshToken{
+ UserID: userID,
+ TokenHash: tokenHash,
+ ExpiresAt: time.Now().Add(RefreshTokenDuration),
+ }
+ if err := s.repo.RefreshToken.Create(ctx, rt); err != nil {
+ return nil, err
+ }
+
+ return &TokenPair{
+ AccessToken: accessToken,
+ RefreshToken: refreshToken,
+ ExpiresIn: int64(AccessTokenDuration.Seconds()),
+ TokenType: "Bearer",
+ }, nil
+}
+
+// issueAccessToken 签发 JWT Access Token(HS256,60分钟有效期)。
+func (s *TokenService) issueAccessToken(userID, role, tier string) (string, error) {
+ claims := Claims{
+ UserID: userID,
+ Role: role,
+ Tier: tier,
+ RegisteredClaims: jwt.RegisteredClaims{
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
+ Issuer: "mediastationgo",
+ Subject: userID,
+ },
+ }
+ t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ return t.SignedString([]byte(s.cfg.Secrets.JWTSecret))
+}
+
+// generateRefreshToken 生成安全的随机 Refresh Token。
+func (s *TokenService) generateRefreshToken() (string, error) {
+ buf := make([]byte, RefreshTokenLength)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(buf), nil
+}
+
+// Refresh 使用 Refresh Token 轮换获取新的令牌对。
+func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) {
+ tokenHash := repository.HashToken(refreshToken)
+
+ // 查找 Refresh Token 记录
+ rt, err := s.repo.RefreshToken.FindByHash(ctx, tokenHash)
+ if err != nil {
+ return nil, err
+ }
+ if rt == nil {
+ return nil, ErrInvalidRefreshToken
+ }
+
+ // 检查是否已撤销
+ if rt.Revoked {
+ return nil, ErrTokenRevoked
+ }
+
+ // 检查是否过期
+ if rt.IsExpired() {
+ return nil, ErrTokenExpired
+ }
+
+ // 获取用户信息
+ user, err := s.repo.User.FindByID(ctx, rt.UserID)
+ if err != nil {
+ return nil, err
+ }
+ if user == nil {
+ return nil, ErrInvalidRefreshToken
+ }
+
+ // 撤销旧的 Refresh Token
+ if err := s.repo.RefreshToken.Revoke(ctx, tokenHash); err != nil {
+ s.log.Warn("failed to revoke old refresh token", zap.Error(err))
+ }
+
+ // 签发新的令牌对
+ return s.IssuePair(ctx, user.ID, user.Role, user.Tier)
+}
+
+// RevokeAll 撤销用户的所有 Refresh Token(用于登出)。
+func (s *TokenService) RevokeAll(ctx context.Context, userID string) error {
+ return s.repo.RefreshToken.RevokeByUserID(ctx, userID)
+}
+
+// ValidateAccessToken 验证 Access Token 并返回 Claims。
+func (s *TokenService) ValidateAccessToken(tokenString string) (*Claims, error) {
+ claims := &Claims{}
+ _, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
+ if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, errors.New("unexpected signing method")
+ }
+ return []byte(s.cfg.Secrets.JWTSecret), nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return claims, nil
+}
+
+// CleanupExpired 清理过期的 Refresh Token。
+func (s *TokenService) CleanupExpired(ctx context.Context) error {
+ return s.repo.RefreshToken.DeleteExpired(ctx)
+}
diff --git a/internal/service/transmission_adp.go b/internal/service/transmission_adp.go
new file mode 100644
index 0000000..22640c3
--- /dev/null
+++ b/internal/service/transmission_adp.go
@@ -0,0 +1,417 @@
+// Package service — Transmission 下载适配器。
+//
+// TransmissionAdapter 实现了 DownloadAdapter 接口,通过 Transmission RPC API
+// 管理下载任务。
+package service
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+// 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"`
+}
+
+// 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: &http.Client{Timeout: 20 * time.Second},
+ }
+}
+
+// Initialize 配置并初始化 Transmission RPC 连接。
+func (a *TransmissionAdapter) Initialize(ctx context.Context, cfg DownloadClientConfig) error {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ 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 := a.cfg.Host
+ if !strings.HasSuffix(rpcURL, "/rpc") && !strings.HasSuffix(rpcURL, "/transmission/rpc") {
+ if !strings.Contains(rpcURL, "/rpc") {
+ rpcURL = strings.TrimRight(rpcURL, "/") + "/transmission/rpc"
+ }
+ }
+ req, err := http.NewRequestWithContext(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 := a.cfg.Host
+ if !strings.Contains(rpcURL, "/rpc") {
+ rpcURL = strings.TrimRight(rpcURL, "/") + "/transmission/rpc"
+ }
+
+ 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++ {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(body))
+ if err != nil {
+ return nil, 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, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == 409 {
+ a.sessionID = resp.Header.Get("X-Transmission-Session-Id")
+ continue
+ }
+ if resp.StatusCode >= 400 {
+ raw, _ := io.ReadAll(resp.Body)
+ return nil, 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, err
+ }
+ if result.Result != "success" {
+ return nil, fmt.Errorf("transmission rpc result: %s", result.Result)
+ }
+ return &result, nil
+ }
+ return nil, fmt.Errorf("transmission: failed after CSRF retry")
+}
+
+// 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}
+ 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",
+ "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 := transmissionStateStr(status)
+
+ // 过滤
+ if filter != "" && !strings.EqualFold(state, filter) {
+ continue
+ }
+
+ result = append(result, TorrentInfo{
+ Hash: hash,
+ Name: name,
+ Size: size,
+ Progress: progress * 100,
+ DLSpeed: dlSpeed,
+ UPSpeed: upSpeed,
+ State: state,
+ SavePath: savePath,
+ NumSeeds: numSeeds,
+ NumLeechs: numLeechs,
+ AddedOn: time.Unix(addedOn, 0),
+ Tags: toJSONLabels(t["labels"]),
+ })
+ }
+ 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", "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: toFloat64(t["percentDone"]) * 100,
+ DLSpeed: toInt64(t["rateDownload"]),
+ UPSpeed: toInt64(t["rateUpload"]),
+ State: transmissionStateStr(status),
+ 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"]),
+ }
+ return info, nil
+}
+
+// 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/server.exe~ b/server.exe~
new file mode 100644
index 0000000..81d7b82
Binary files /dev/null and b/server.exe~ differ
diff --git a/web/src/App.tsx b/web/src/App.tsx
index c5cbd53..7aae41f 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -51,9 +51,6 @@ const DlnaPage = lazy(() => import('./pages/DlnaPage').then((m) => ({ default: m
const FileManagerPage = lazy(() =>
import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })),
)
-const APIConfigsPage = lazy(() =>
- import('./pages/APIConfigsPage').then((m) => ({ default: m.APIConfigsPage })),
-)
const StoragePage = lazy(() =>
import('./pages/StoragePage').then((m) => ({ default: m.StoragePage })),
)
@@ -69,6 +66,7 @@ const WatchHistoryPage = lazy(() =>
const PosterWallPage = lazy(() =>
import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })),
)
+const SitesPage = lazy(() => import('./pages/SitesPage').then((m) => ({ default: m.SitesPage })))
const Loading = () => 加载中…
@@ -100,6 +98,14 @@ export default function App() {
} />
} />
} />
+
+
+
+ }
+ />
-
-
- }
+ element={}
/>
{
+ const resp = await api.get('/api-config')
+ return resp.data as unknown as ApiConfig[]
+}
+
+// 获取提供者列表
+export async function getProviders(): Promise {
+ const resp = await api.get('/api-config/providers/list')
+ return resp.data as unknown as ApiProvider[]
+}
+
+// 获取指定提供者的配置
+export async function getApiConfig(provider: string): Promise {
+ const resp = await api.get(`/api-config/${provider}`)
+ return resp.data as unknown as ApiConfig
+}
+
+// 获取生效的配置
+export async function getEffectiveConfig(provider: string): Promise {
+ const resp = await api.get(`/api-config/${provider}/effective`)
+ return resp.data as unknown as ApiConfig
+}
+
+// 更新 API 配置
+export interface UpdateApiConfigRequest {
+ api_key?: string
+ base_url?: string
+ extra?: string
+ enabled?: boolean
+}
+
+export async function updateApiConfig(
+ provider: string,
+ data: UpdateApiConfigRequest
+): Promise {
+ const resp = await api.post(`/api-config/${provider}`, data)
+ return resp.data as unknown as ApiConfig
+}
+
+// 删除 API 配置
+export async function deleteApiConfig(provider: string): Promise {
+ await api.delete(`/api-config/${provider}`)
+}
+
+// 测试 API 连接
+export interface TestApiConfigResponse {
+ result: 'success' | 'error' | 'invalid' | 'unknown'
+}
+
+export async function testApiConfig(provider: string): Promise {
+ const resp = await api.post(`/api-config/${provider}/test`)
+ return resp.data as unknown as TestApiConfigResponse
+}
diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts
index a1af972..f73b1bc 100644
--- a/web/src/api/auth.ts
+++ b/web/src/api/auth.ts
@@ -1,9 +1,9 @@
import { api } from './client'
-import type { User } from '../types'
+import type { User, TokenPair } from '../types'
export interface LoginResponse {
- token: string
user: User
+ tokens: TokenPair
}
export const authAPI = {
@@ -11,7 +11,7 @@ export const authAPI = {
api.post('/auth/login', { username, password }).then((r) => r.data),
register: (username: string, password: string) =>
- api.post('/auth/register', { username, password }).then((r) => r.data),
+ api.post('/auth/register', { username, password }).then((r) => r.data),
me: () => api.get('/me').then((r) => r.data),
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 993187e..1d8e851 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -1,4 +1,4 @@
-import axios, { AxiosError } from 'axios'
+import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '../stores/auth'
@@ -9,6 +9,22 @@ export const api = axios.create({
timeout: 30000,
})
+// Flag to prevent multiple simultaneous refresh attempts
+let isRefreshing = false
+let refreshSubscribers: Array<(token: string) => void> = []
+
+// Subscribe to token refresh
+function subscribeTokenRefresh(callback: (token: string) => void) {
+ refreshSubscribers.push(callback)
+}
+
+// Notify all subscribers about new token
+function onTokenRefreshed(newToken: string) {
+ refreshSubscribers.forEach(callback => callback(newToken))
+ refreshSubscribers = []
+}
+
+// Add auth token to requests
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token
if (token) {
@@ -18,15 +34,54 @@ api.interceptors.request.use((config) => {
return config
})
+// Handle 401 errors with token refresh
api.interceptors.response.use(
(resp) => resp,
- (err: AxiosError) => {
- if (err.response?.status === 401) {
+ async (err: AxiosError) => {
+ const originalRequest = err.config as InternalAxiosRequestConfig & { _retry?: boolean }
+
+ // If 401 and not already retried
+ if (err.response?.status === 401 && originalRequest && !originalRequest._retry) {
+ if (isRefreshing) {
+ // Wait for token refresh to complete
+ return new Promise((resolve) => {
+ subscribeTokenRefresh((token: string) => {
+ if (originalRequest.headers) {
+ originalRequest.headers.Authorization = `Bearer ${token}`
+ }
+ resolve(api(originalRequest))
+ })
+ })
+ }
+
+ originalRequest._retry = true
+ isRefreshing = true
+
+ try {
+ const refreshed = await useAuthStore.getState().tokenRefresh()
+ if (refreshed) {
+ const newToken = useAuthStore.getState().token
+ if (newToken && originalRequest.headers) {
+ originalRequest.headers.Authorization = `Bearer ${newToken}`
+ }
+ onTokenRefreshed(newToken || '')
+ isRefreshing = false
+ return api(originalRequest)
+ }
+ } catch (refreshError) {
+ isRefreshing = false
+ refreshSubscribers = []
+ }
+
+ // Refresh failed, logout
useAuthStore.getState().logout()
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
}
+ return Promise.reject(err)
}
+
+ // For other errors, just reject
return Promise.reject(err)
},
)
@@ -55,3 +110,13 @@ export function imageURL(remote?: string): string {
if (remote.startsWith('/api/img')) return remote
return `/api/img?url=${encodeURIComponent(remote)}&${tokenQuery()}`
}
+
+// getToken returns the current auth token
+export function getToken(): string | null {
+ return useAuthStore.getState().token
+}
+
+// getRefreshToken returns the current refresh token
+export function getRefreshToken(): string | null {
+ return useAuthStore.getState().refreshToken
+}
diff --git a/web/src/api/downloadClient.ts b/web/src/api/downloadClient.ts
new file mode 100644
index 0000000..3eb6ebb
--- /dev/null
+++ b/web/src/api/downloadClient.ts
@@ -0,0 +1,51 @@
+import { api } from './client'
+import type { DownloadClient } from '../types'
+
+export interface DownloadClientCreateParams {
+ name: string
+ type: 'qbittorrent' | 'transmission' | 'aria2'
+ host: string
+ username?: string
+ password?: string
+ is_default?: boolean
+ extra?: Record
+}
+
+export interface DownloadClientUpdateParams {
+ name?: string
+ type?: 'qbittorrent' | 'transmission' | 'aria2'
+ host?: string
+ username?: string
+ password?: string
+ is_default?: boolean
+ enabled?: boolean
+ extra?: Record
+}
+
+export const downloadClientAPI = {
+ list: () =>
+ api
+ .get<{ code: number; data: DownloadClient[] }>('/download-clients')
+ .then((r) => r.data.data),
+
+ get: (id: string) =>
+ api
+ .get<{ code: number; data: DownloadClient }>(`/download-clients/${id}`)
+ .then((r) => r.data.data),
+
+ create: (params: DownloadClientCreateParams) =>
+ api
+ .post<{ code: number; data: DownloadClient }>('/download-clients', params)
+ .then((r) => r.data.data),
+
+ update: (id: string, params: DownloadClientUpdateParams) =>
+ api
+ .put<{ code: number; data: DownloadClient }>(`/download-clients/${id}`, params)
+ .then((r) => r.data.data),
+
+ delete: (id: string) =>
+ api.delete(`/download-clients/${id}`).then((r) => r.data),
+
+ test: (id: string) =>
+ api.post(`/download-clients/${id}/test`).then((r) => r.data),
+}
diff --git a/web/src/api/notify.ts b/web/src/api/notify.ts
new file mode 100644
index 0000000..17cc671
--- /dev/null
+++ b/web/src/api/notify.ts
@@ -0,0 +1,50 @@
+import { api } from './client'
+import type { NotifyChannel, NotifyProviderInfo } from '../types'
+
+export interface NotifyChannelCreateParams {
+ name: string
+ type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
+ enabled?: boolean
+ config: Record
+ events?: string[]
+}
+
+export interface NotifyChannelUpdateParams {
+ name?: string
+ enabled?: boolean
+ config?: Record
+ events?: string[]
+}
+
+export const notifyAPI = {
+ list: () =>
+ api
+ .get<{ code: number; data: NotifyChannel[] }>('/notify-channels')
+ .then((r) => r.data.data),
+
+ get: (id: string) =>
+ api
+ .get<{ code: number; data: NotifyChannel }>(`/notify-channels/${id}`)
+ .then((r) => r.data.data),
+
+ getTypes: () =>
+ api
+ .get<{ code: number; data: NotifyProviderInfo[] }>('/notify-channels/types')
+ .then((r) => r.data.data),
+
+ create: (params: NotifyChannelCreateParams) =>
+ api
+ .post<{ code: number; data: NotifyChannel }>('/notify-channels', params)
+ .then((r) => r.data.data),
+
+ update: (id: string, params: NotifyChannelUpdateParams) =>
+ api
+ .put<{ code: number; data: NotifyChannel }>(`/notify-channels/${id}`, params)
+ .then((r) => r.data.data),
+
+ delete: (id: string) =>
+ api.delete(`/notify-channels/${id}`).then((r) => r.data),
+
+ test: (id: string) =>
+ api.post(`/notify-channels/${id}/test`).then((r) => r.data),
+}
diff --git a/web/src/api/permission.ts b/web/src/api/permission.ts
new file mode 100644
index 0000000..e4bf457
--- /dev/null
+++ b/web/src/api/permission.ts
@@ -0,0 +1,38 @@
+// 权限 API 模块
+import { api } from './client'
+import type { UserPermission } from '../types'
+
+// 获取用户权限
+export async function getUserPermissions(userId: string): Promise {
+ const resp = await api.get(`/admin/users/${userId}/permissions`)
+ return resp.data as unknown as UserPermission
+}
+
+// 更新用户权限
+export async function updateUserPermissions(
+ userId: string,
+ permissions: Record
+): Promise {
+ await api.put(`/admin/users/${userId}/permissions`, { permissions })
+}
+
+// 重置用户权限为默认值
+export async function resetUserPermissions(userId: string): Promise {
+ await api.post(`/admin/users/${userId}/permissions/reset`)
+}
+
+// 获取当前用户权限
+export async function getMyPermissions(): Promise<{
+ permissions: Record
+ role: string
+ tier: string
+ is_super: boolean
+}> {
+ const resp = await api.get('/auth/permissions')
+ return resp.data as unknown as {
+ permissions: Record
+ role: string
+ tier: string
+ is_super: boolean
+ }
+}
diff --git a/web/src/api/refresh.ts b/web/src/api/refresh.ts
new file mode 100644
index 0000000..b32f2e7
--- /dev/null
+++ b/web/src/api/refresh.ts
@@ -0,0 +1,27 @@
+// 令牌刷新 API 模块
+import { api } from './client'
+
+// 刷新令牌请求/响应
+export interface RefreshTokenRequest {
+ refresh_token: string
+}
+
+export interface RefreshTokenResponse {
+ token: string
+ refresh_token: string
+ expires_in: number
+ token_type: string
+}
+
+// 刷新访问令牌
+export async function refreshToken(refreshToken: string): Promise {
+ const resp = await api.post('/auth/refresh', {
+ refresh_token: refreshToken,
+ })
+ return resp.data as unknown as RefreshTokenResponse
+}
+
+// 登出
+export async function logout(): Promise {
+ await api.post('/me/logout')
+}
diff --git a/web/src/api/sites.ts b/web/src/api/sites.ts
new file mode 100644
index 0000000..1bda125
--- /dev/null
+++ b/web/src/api/sites.ts
@@ -0,0 +1,32 @@
+import { api } from './client'
+
+// Site management API - matches Go backend /api/sites endpoints
+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),
+}
diff --git a/web/src/components/APIConfigsPanel.tsx b/web/src/components/APIConfigsPanel.tsx
new file mode 100644
index 0000000..265ce39
--- /dev/null
+++ b/web/src/components/APIConfigsPanel.tsx
@@ -0,0 +1,218 @@
+import { FormEvent, useEffect, useState } from 'react'
+import toast from 'react-hot-toast'
+import { Eye, KeyRound, Save, Trash2, X } from 'lucide-react'
+
+import { apiConfigsAPI, type APIConfig } from '../api/api_configs'
+
+// Compact inline-editable provider table for use inside AdminPage's "外部API" tab.
+export function APIConfigsPanel() {
+ const [items, setItems] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [editing, setEditing] = useState(null)
+
+ const refresh = () =>
+ apiConfigsAPI
+ .list()
+ .then(setItems)
+ .finally(() => setLoading(false))
+
+ useEffect(() => {
+ refresh().catch(() => undefined)
+ }, [])
+
+ return (
+
+
+
+
+ 外部 API 配置
+
+ TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 密钥管理
+ · AES-GCM 加密存储
+
+
+
+
+ {loading && (
+ 加载中…
+ )}
+
+ {!loading && (
+
+
+
+
+ | 服务 |
+ 密钥 |
+ 状态 |
+ 操作 |
+
+
+
+ {items.map((item) =>
+ editing === item.provider ? (
+ setEditing(null)}
+ onSaved={() => {
+ setEditing(null)
+ refresh()
+ }}
+ />
+ ) : (
+
+ |
+ {item.provider}
+ {item.description && (
+ {item.description}
+ )}
+ |
+
+ {item.has_key ? (
+ {item.masked_key}
+ ) : (
+ 未配置
+ )}
+ |
+
+ {item.has_key ? (
+
+ 已配置
+
+ ) : (
+
+ 未配置
+
+ )}
+ |
+
+
+
+
+ {item.has_key && (
+
+ )}
+
+ |
+
+ ),
+ )}
+
+
+
+ )}
+
+ )
+}
+
+function EditingRow({
+ item,
+ onCancel,
+ onSaved,
+}: {
+ item: APIConfig
+ onCancel: () => void
+ onSaved: () => void
+}) {
+ const [apiKey, setAPIKey] = useState('')
+ const [baseURL, setBaseURL] = useState(item.base_url ?? '')
+ const [enabled, setEnabled] = useState(item.enabled)
+ const [saving, setSaving] = useState(false)
+
+ const submit = async (e: FormEvent) => {
+ e.preventDefault()
+ setSaving(true)
+ try {
+ const patch: Record = { base_url: baseURL, enabled }
+ if (apiKey.trim()) patch.api_key = apiKey.trim()
+ await apiConfigsAPI.update(item.provider, patch)
+ toast.success(`${item.provider} 已保存`)
+ onSaved()
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '保存失败'
+ toast.error(msg)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ |
+
+ |
+
+ )
+}
diff --git a/web/src/components/DownloadClientCard.tsx b/web/src/components/DownloadClientCard.tsx
new file mode 100644
index 0000000..8cdcabc
--- /dev/null
+++ b/web/src/components/DownloadClientCard.tsx
@@ -0,0 +1,250 @@
+import { FormEvent, useEffect, useState } from 'react'
+import toast from 'react-hot-toast'
+import { Plug, Trash2, Zap, Settings, Loader2 } from 'lucide-react'
+
+import { downloadClientAPI, type DownloadClientCreateParams } from '../api/downloadClient'
+import type { DownloadClient } from '../types'
+
+const CLIENT_TYPES = [
+ { value: 'qbittorrent', label: 'qBittorrent' },
+ { value: 'transmission', label: 'Transmission' },
+ { value: 'aria2', label: 'Aria2' },
+] as const
+
+const TYPE_LABELS: Record = {
+ qbittorrent: 'qBittorrent',
+ transmission: 'Transmission',
+ aria2: 'Aria2',
+}
+
+export function DownloadClientCard() {
+ const [clients, setClients] = useState([])
+ const [showForm, setShowForm] = useState(false)
+ const [testing, setTesting] = useState(null)
+ const [form, setForm] = useState({
+ name: '',
+ type: 'qbittorrent',
+ host: '',
+ username: '',
+ password: '',
+ is_default: false,
+ })
+
+ const refresh = () => downloadClientAPI.list().then(setClients)
+ useEffect(() => {
+ refresh().catch(() => undefined)
+ }, [])
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault()
+ try {
+ await downloadClientAPI.create(form)
+ toast.success('下载客户端已创建')
+ setForm({ name: '', type: 'qbittorrent', host: '', username: '', password: '', is_default: false })
+ setShowForm(false)
+ await refresh()
+ } catch {
+ toast.error('创建失败')
+ }
+ }
+
+ const handleTest = async (id: string) => {
+ setTesting(id)
+ try {
+ await downloadClientAPI.test(id)
+ toast.success('连接测试成功')
+ } catch {
+ toast.error('连接测试失败')
+ } finally {
+ setTesting(null)
+ }
+ }
+
+ const handleDelete = async (id: string, name: string) => {
+ if (!confirm(`确定删除「${name}」?`)) return
+ await downloadClientAPI.delete(id)
+ toast.success('已删除')
+ await refresh()
+ }
+
+ const handleToggleDefault = async (client: DownloadClient) => {
+ await downloadClientAPI.update(client.id, { is_default: true })
+ toast.success(`已设「${client.name}」为默认客户端`)
+ await refresh()
+ }
+
+ const handleToggleEnabled = async (client: DownloadClient) => {
+ await downloadClientAPI.update(client.id, { enabled: !client.enabled })
+ toast.success(client.enabled ? '已禁用' : '已启用')
+ await refresh()
+ }
+
+ return (
+
+
+ 下载客户端
+
+
+
+ {showForm && (
+
+ )}
+
+
+ {clients.map((client) => (
+
+
+
+
+
+
+
+ {client.name}
+
+ {TYPE_LABELS[client.type]}
+
+ {client.is_default && (
+
+ 默认
+
+ )}
+ {!client.enabled && (
+
+ 已禁用
+
+ )}
+
+ {client.host}
+
+
+
+ {!client.is_default && (
+
+ )}
+
+
+
+
+
+ ))}
+ {clients.length === 0 && (
+
+ 暂无下载客户端,点击上方按钮添加
+
+ )}
+
+
+ )
+}
diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx
index bdf086f..ecce5b3 100644
--- a/web/src/components/Layout.tsx
+++ b/web/src/components/Layout.tsx
@@ -9,11 +9,11 @@ import {
Copy,
Film,
FolderTree,
+ Globe,
HardDrive,
Heart,
Home,
GalleryHorizontalEnd,
- KeyRound,
ListChecks,
ListMusic,
LogOut,
@@ -94,18 +94,18 @@ export function Layout() {
{user?.role === 'admin' && (
<>
-
- 管理
-
- } label="实时任务" />
- } label="运行状态" />
- } label="存储" />
- } label="文件浏览" />
- } label="重复文件" />
- } label="定时任务" />
- } label="API 配置" />
- } label="回收站" />
- } label="管理后台" />
+
+ 管理
+
+ } label="实时任务" />
+ } label="运行状态" />
+ } label="存储" />
+ } label="文件浏览" />
+ } label="重复文件" />
+ } label="定时任务" />
+ } label="站点管理" />
+ } label="回收站" />
+ } label="管理后台" />
>
)}
diff --git a/web/src/components/NotifyChannelCard.tsx b/web/src/components/NotifyChannelCard.tsx
new file mode 100644
index 0000000..6cb84a8
--- /dev/null
+++ b/web/src/components/NotifyChannelCard.tsx
@@ -0,0 +1,282 @@
+import { FormEvent, useEffect, useState } from 'react'
+import toast from 'react-hot-toast'
+import { Bell, Trash2, Loader2 } from 'lucide-react'
+
+import { notifyAPI, type NotifyChannelCreateParams } from '../api/notify'
+import type { NotifyChannel } from '../types'
+
+const CHANNEL_TYPES = [
+ { value: 'telegram', label: 'Telegram' },
+ { value: 'wechat', label: 'Server酱' },
+ { value: 'bark', label: 'Bark' },
+ { value: 'webhook', label: 'Webhook' },
+ { value: 'email', label: 'Email' },
+] as const
+
+const TYPE_LABELS: Record = {
+ telegram: 'Telegram',
+ wechat: 'Server酱',
+ bark: 'Bark',
+ webhook: 'Webhook',
+ email: 'Email',
+}
+
+const EVENT_OPTIONS = [
+ { value: 'subscription_hit', label: '订阅命中' },
+ { value: 'download_complete', label: '下载完成' },
+ { value: 'scrape_failed', label: '刮削失败' },
+ { value: 'system_alert', label: '系统告警' },
+]
+
+const CONFIG_FIELDS: Record = {
+ telegram: [
+ { key: 'bot_token', label: 'Bot Token', placeholder: '123456:ABC-DEF...' },
+ { key: 'chat_id', label: 'Chat ID', placeholder: '你的 Chat ID' },
+ { key: 'parse_mode', label: 'Parse Mode', placeholder: 'HTML (可选)' },
+ ],
+ wechat: [{ key: 'sendkey', label: 'SendKey', placeholder: 'SCT...' }],
+ bark: [
+ { key: 'server_url', label: '服务器地址', placeholder: 'https://api.day.app (可选)' },
+ { key: 'device_key', label: 'Device Key', placeholder: '你的 Bark Key' },
+ ],
+ webhook: [
+ { key: 'url', label: 'Webhook URL', placeholder: 'https://...' },
+ { key: 'method', label: 'HTTP 方法', placeholder: 'POST (可选)' },
+ { key: 'headers_json', label: 'Headers JSON', placeholder: '{"Authorization":"Bearer ..."} (可选)' },
+ { key: 'body_template', label: 'Body 模板', placeholder: '{{title}}: {{message}} (可选)' },
+ ],
+ email: [
+ { key: 'smtp_host', label: 'SMTP 地址', placeholder: 'smtp.gmail.com' },
+ { key: 'smtp_port', label: 'SMTP 端口', placeholder: '465' },
+ { key: 'username', label: '用户名', placeholder: '' },
+ { key: 'password', label: '密码', placeholder: '', type: 'password' },
+ { key: 'from', label: '发件人', placeholder: 'noreply@example.com' },
+ { key: 'to', label: '收件人', placeholder: 'user@example.com (多个用逗号分隔)' },
+ { key: 'tls', label: 'TLS', placeholder: 'true/false' },
+ ],
+}
+
+export function NotifyChannelCard() {
+ const [channels, setChannels] = useState([])
+ const [showForm, setShowForm] = useState(false)
+ const [testing, setTesting] = useState(null)
+ const [form, setForm] = useState({
+ name: '',
+ type: 'telegram',
+ enabled: true,
+ config: {},
+ events: [],
+ })
+
+ const refresh = () => notifyAPI.list().then(setChannels)
+ useEffect(() => {
+ refresh().catch(() => undefined)
+ }, [])
+
+ const handleTypeChange = (type: string) => {
+ setForm({
+ ...form,
+ type: type as NotifyChannelCreateParams['type'],
+ config: {},
+ })
+ }
+
+ const handleConfigChange = (key: string, value: string) => {
+ setForm({ ...form, config: { ...form.config, [key]: value } })
+ }
+
+ const handleEventToggle = (event: string) => {
+ const events = form.events || []
+ const idx = events.indexOf(event)
+ if (idx >= 0) {
+ events.splice(idx, 1)
+ } else {
+ events.push(event)
+ }
+ setForm({ ...form, events: [...events] })
+ }
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault()
+ try {
+ await notifyAPI.create(form)
+ toast.success('通知渠道已创建')
+ setForm({ name: '', type: 'telegram', enabled: true, config: {}, events: [] })
+ setShowForm(false)
+ await refresh()
+ } catch {
+ toast.error('创建失败')
+ }
+ }
+
+ const handleTest = async (id: string) => {
+ setTesting(id)
+ try {
+ await notifyAPI.test(id)
+ toast.success('测试通知已发送')
+ } catch {
+ toast.error('测试通知发送失败')
+ } finally {
+ setTesting(null)
+ }
+ }
+
+ const handleDelete = async (id: string, name: string) => {
+ if (!confirm(`确定删除「${name}」?`)) return
+ await notifyAPI.delete(id)
+ toast.success('已删除')
+ await refresh()
+ }
+
+ const handleToggleEnabled = async (channel: NotifyChannel) => {
+ await notifyAPI.update(channel.id, { enabled: !channel.enabled })
+ toast.success(channel.enabled ? '已禁用' : '已启用')
+ await refresh()
+ }
+
+ const configFields = CONFIG_FIELDS[form.type] || []
+
+ return (
+
+
+ 通知渠道
+
+
+
+ {showForm && (
+
+ )}
+
+
+ {channels.map((channel) => (
+
+
+
+
+
+
+
+ {channel.name}
+
+ {TYPE_LABELS[channel.type]}
+
+ {!channel.enabled && (
+
+ 已禁用
+
+ )}
+
+
+ 事件: {channel.events || '无'}
+
+
+
+
+
+
+
+
+
+ ))}
+ {channels.length === 0 && (
+
+ 暂无通知渠道,点击上方按钮添加
+
+ )}
+
+
+ )
+}
diff --git a/web/src/components/PermissionGuard.tsx b/web/src/components/PermissionGuard.tsx
new file mode 100644
index 0000000..ac7e495
--- /dev/null
+++ b/web/src/components/PermissionGuard.tsx
@@ -0,0 +1,67 @@
+import { type ReactNode } from 'react'
+
+import { usePermissionStore } from '../stores/permissions'
+import { useAuthStore } from '../stores/auth'
+
+interface PermissionGuardProps {
+ permission: string
+ children: ReactNode
+ fallback?: ReactNode
+ requireSuperUser?: boolean
+}
+
+/**
+ * PermissionGuard 组件用于根据用户权限控制内容显示。
+ *
+ * @param permission - 需要的权限键
+ * @param children - 有权限时显示的内容
+ * @param fallback - 无权限时显示的内容(可选,默认不显示)
+ * @param requireSuperUser - 是否要求超级用户(admin/plus)绕过权限检查
+ */
+export function PermissionGuard({
+ permission,
+ children,
+ fallback = null,
+ requireSuperUser = false,
+}: PermissionGuardProps) {
+ const { hasPermission, isSuper, permissions, isLoading } = usePermissionStore()
+ const tier = useAuthStore((state) => state.tier)
+ const role = useAuthStore((state) => state.user?.role)
+
+ // 超级用户(admin 或 plus)默认有所有权限
+ if (isSuper || tier === 'plus' || role === 'admin') {
+ return <>{children}>
+ }
+
+ // 如果 requireSuperUser 为 true 且用户不是超级用户,则不显示
+ if (requireSuperUser && !isSuper) {
+ return <>{fallback}>
+ }
+
+ // 加载中时显示 fallback
+ if (isLoading && Object.keys(permissions).length === 0) {
+ return <>{fallback}>
+ }
+
+ // 检查具体权限
+ if (hasPermission(permission)) {
+ return <>{children}>
+ }
+
+ return <>{fallback}>
+}
+
+// 权限检查工具函数
+export function checkPermission(
+ permission: string,
+ isSuper: boolean,
+ tier: string,
+ role: string,
+ permissions: Record
+): boolean {
+ // 超级用户有所有权限
+ if (isSuper || tier === 'plus' || role === 'admin') {
+ return true
+ }
+ return permissions[permission] === true
+}
diff --git a/web/src/hooks/usePermission.ts b/web/src/hooks/usePermission.ts
new file mode 100644
index 0000000..70e819b
--- /dev/null
+++ b/web/src/hooks/usePermission.ts
@@ -0,0 +1,133 @@
+import { useEffect } from 'react'
+
+import { usePermissionStore } from '../stores/permissions'
+import { useAuthStore } from '../stores/auth'
+
+/**
+ * usePermission hook - 检查用户是否拥有特定权限
+ *
+ * @param key - 权限键名
+ * @param options - 配置选项
+ * @param options.autoFetch - 是否在权限未加载时自动获取(默认 true)
+ * @returns boolean - 用户是否拥有该权限
+ *
+ * @example
+ * ```tsx
+ * function MyComponent() {
+ * const canEdit = usePermission('can_edit_media')
+ *
+ * if (canEdit) {
+ * return
+ * }
+ * return null
+ * }
+ * ```
+ */
+export function usePermission(
+ key: string,
+ options: { autoFetch?: boolean } = {}
+): boolean {
+ const { autoFetch = true } = options
+ const { hasPermission, isSuper, permissions, isLoading, fetchPermissions } = usePermissionStore()
+ const tier = useAuthStore((state) => state.tier)
+ const role = useAuthStore((state) => state.user?.role)
+ const isAuthenticated = useAuthStore((state) => state.token !== null)
+
+ // 超级用户有所有权限
+ if (isSuper || tier === 'plus' || role === 'admin') {
+ return true
+ }
+
+ // 权限未加载且未认证时,返回 false
+ if (!isAuthenticated) {
+ return false
+ }
+
+ // 权限未加载时自动获取
+ useEffect(() => {
+ if (autoFetch && Object.keys(permissions).length === 0 && !isLoading) {
+ fetchPermissions()
+ }
+ }, [autoFetch, permissions, isLoading, fetchPermissions])
+
+ return hasPermission(key)
+}
+
+/**
+ * usePermissions hook - 获取所有权限
+ *
+ * @returns 权限状态和检查函数
+ *
+ * @example
+ * ```tsx
+ * function MyComponent() {
+ * const { permissions, isSuper, check } = usePermissions()
+ *
+ * if (isSuper) {
+ * return
+ * }
+ *
+ * return (
+ *
+ * {check('can_view_dashboard') && }
+ * {check('can_play_media') && }
+ *
+ * )
+ * }
+ * ```
+ */
+export function usePermissions() {
+ const { permissions, isSuper, isLoading, fetchPermissions } = usePermissionStore()
+ const tier = useAuthStore((state) => state.tier)
+ const role = useAuthStore((state) => state.user?.role)
+ const isAuthenticated = useAuthStore((state) => state.token !== null)
+
+ const check = (key: string): boolean => {
+ if (isSuper || tier === 'plus' || role === 'admin') {
+ return true
+ }
+ return permissions[key] === true
+ }
+
+ return {
+ permissions,
+ isSuper: isSuper || tier === 'plus' || role === 'admin',
+ isLoading,
+ check,
+ refetch: fetchPermissions,
+ isAuthenticated,
+ }
+}
+
+/**
+ * usePermissionMany hook - 批量检查多个权限
+ *
+ * @param keys - 权限键数组
+ * @returns 每个权限的布尔值映射
+ *
+ * @example
+ * ```tsx
+ * function MyComponent() {
+ * const perms = usePermissionMany([
+ * 'can_edit_media',
+ * 'can_manage_users',
+ * 'can_access_settings',
+ * ])
+ *
+ * return (
+ *
+ * {perms['can_edit_media'] && }
+ * {perms['can_manage_users'] && }
+ *
+ * )
+ * }
+ * ```
+ */
+export function usePermissionMany(keys: string[]): Record {
+ const { check } = usePermissions()
+
+ return keys.reduce((acc, key) => {
+ acc[key] = check(key)
+ return acc
+ }, {} as Record)
+}
diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts
new file mode 100644
index 0000000..51a7161
--- /dev/null
+++ b/web/src/hooks/useSSE.ts
@@ -0,0 +1,127 @@
+import { useEffect, useRef, useCallback } from 'react'
+
+import { useAuthStore } from '../stores/auth'
+import type { SSEEvent } from '../types'
+
+type SSEEventHandler = (event: SSEEvent) => void
+
+/**
+ * useSSE hook - 管理 Server-Sent Events 连接
+ *
+ * @param onEvent - 事件处理函数
+ * @param options - 配置选项
+ *
+ * @example
+ * ```tsx
+ * function MyComponent() {
+ * const { connect, disconnect } = useSSE((event) => {
+ * if (event.type === 'scan') {
+ * console.log('Scan progress:', event.payload)
+ * }
+ * })
+ *
+ * useEffect(() => {
+ * connect()
+ * return () => disconnect()
+ * }, [])
+ *
+ * return SSE Demo
+ * }
+ * ```
+ */
+export function useSSE(
+ onEvent: SSEEventHandler,
+ options: { autoConnect?: boolean } = {}
+) {
+ const { autoConnect = true } = options
+ const eventSourceRef = useRef(null)
+ const reconnectTimeoutRef = useRef | null>(null)
+ const isConnectedRef = useRef(false)
+ const reconnectAttemptsRef = useRef(0)
+ const maxReconnectAttempts = 5
+
+ const connect = useCallback(() => {
+ // 如果已有连接,先断开
+ if (eventSourceRef.current) {
+ eventSourceRef.current.close()
+ }
+
+ const token = useAuthStore.getState().token
+ if (!token) {
+ console.warn('Cannot connect to SSE: No auth token')
+ return
+ }
+
+ const url = `/api/events?token=${encodeURIComponent(token)}`
+ const eventSource = new EventSource(url)
+ eventSourceRef.current = eventSource
+
+ eventSource.onopen = () => {
+ isConnectedRef.current = true
+ reconnectAttemptsRef.current = 0
+ console.log('SSE connected')
+ }
+
+ eventSource.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data) as SSEEvent
+ onEvent(data)
+ } catch (err) {
+ console.error('Failed to parse SSE event:', err)
+ }
+ }
+
+ eventSource.onerror = () => {
+ isConnectedRef.current = false
+ eventSource.close()
+
+ // 尝试重连
+ if (reconnectAttemptsRef.current < maxReconnectAttempts) {
+ const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000)
+ reconnectAttemptsRef.current++
+ console.log(`SSE reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current})`)
+ reconnectTimeoutRef.current = setTimeout(connect, delay)
+ } else {
+ console.error('SSE connection failed after max attempts')
+ }
+ }
+
+ // 自定义事件类型
+ eventSource.addEventListener('connected', (event) => {
+ console.log('SSE handshake received:', event)
+ })
+
+ }, [onEvent])
+
+ const disconnect = useCallback(() => {
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current)
+ reconnectTimeoutRef.current = null
+ }
+ if (eventSourceRef.current) {
+ eventSourceRef.current.close()
+ eventSourceRef.current = null
+ }
+ isConnectedRef.current = false
+ reconnectAttemptsRef.current = 0
+ }, [])
+
+ const isConnected = useCallback(() => {
+ return isConnectedRef.current
+ }, [])
+
+ useEffect(() => {
+ if (autoConnect) {
+ connect()
+ }
+ return () => {
+ disconnect()
+ }
+ }, [autoConnect, connect, disconnect])
+
+ return {
+ connect,
+ disconnect,
+ isConnected,
+ }
+}
diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx
index f3e663e..6955546 100644
--- a/web/src/pages/AdminPage.tsx
+++ b/web/src/pages/AdminPage.tsx
@@ -4,39 +4,55 @@ import { Trash2 } from 'lucide-react'
import { adminAPI } from '../api/admin'
import { libraryAPI } from '../api/library'
+import { schedulerAPI, type JobStatus } from '../api/scheduler'
import type { Library, User } from '../types'
+import { DownloadClientCard } from '../components/DownloadClientCard'
+import { NotifyChannelCard } from '../components/NotifyChannelCard'
+import { APIConfigsPanel } from '../components/APIConfigsPanel'
+import { SitesPage } from './SitesPage'
-// Single-page admin console. We split the surface into three panels:
-// • Libraries — create / delete / scan
-// • Users — list / delete (cannot delete self)
-// • Settings — generic key/value store viewer
-//
-// Each panel manages its own loading / refresh state.
export function AdminPage() {
- const [tab, setTab] = useState<'library' | 'users' | 'settings'>('library')
+ const [tab, setTab] = useState<
+ 'sites' | 'library' | 'users' | 'settings' | 'api' | 'downloads' | 'notify' | 'scheduler'
+ >('sites')
+ const tabs = [
+ { key: 'sites' as const, label: '站点管理' },
+ { key: 'library' as const, label: '媒体库' },
+ { key: 'users' as const, label: '用户' },
+ { key: 'api' as const, label: '外部API' },
+ { key: 'settings' as const, label: '系统设置' },
+ { key: 'downloads' as const, label: '下载客户端' },
+ { key: 'notify' as const, label: '通知渠道' },
+ { key: 'scheduler' as const, label: '定时任务' },
+ ]
return (
管理后台
-
- {(['library', 'users', 'settings'] as const).map((k) => (
+
+ {tabs.map((k) => (
))}
+ {tab === 'sites' && }
{tab === 'library' && }
{tab === 'users' && }
+ {tab === 'api' && }
{tab === 'settings' && }
+ {tab === 'downloads' && }
+ {tab === 'notify' && }
+ {tab === 'scheduler' && }
)
}
@@ -251,3 +267,64 @@ function SettingsPanel() {
)
}
+
+function SchedulerPanel() {
+ const [tasks, setTasks] = useState([])
+ const [loading, setLoading] = useState(false)
+
+ const refresh = () => {
+ setLoading(true)
+ schedulerAPI.status().then(setTasks).finally(() => setLoading(false))
+ }
+ useEffect(() => {
+ refresh()
+ }, [])
+
+ return (
+
+ {loading && 加载中... }
+ {!loading && tasks.length === 0 && (
+ 暂无定时任务
+ )}
+ {!loading && tasks.length > 0 && (
+
+
+
+ | 名称 |
+ 间隔 |
+ 上次运行 |
+ 操作 |
+
+
+
+ {tasks.map((t) => (
+
+ | {t.name} |
+ {t.interval} |
+
+ {t.last_run ? new Date(t.last_run).toLocaleString() : '-'}
+ |
+
+
+ |
+
+ ))}
+
+
+ )}
+
+ )
+}
diff --git a/web/src/pages/FavouritesPage.tsx b/web/src/pages/FavouritesPage.tsx
index ce3d570..372d58c 100644
--- a/web/src/pages/FavouritesPage.tsx
+++ b/web/src/pages/FavouritesPage.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
+import toast from 'react-hot-toast'
import { playbackAPI } from '../api/playback'
import { MediaCard } from '../components/MediaCard'
@@ -7,26 +8,76 @@ import type { Media } from '../types'
export function FavouritesPage() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
useEffect(() => {
playbackAPI
.listFavourites()
- .then(setItems)
+ .then((data) => setItems(data ?? []))
+ .catch((err) => {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '加载收藏失败'
+ setError(msg)
+ toast.error(msg)
+ })
.finally(() => setLoading(false))
}, [])
+ const isEmpty = !loading && !error && items.length === 0
+
return (
我的收藏
- {loading && 加载中… }
- {!loading && items.length === 0 && (
- 还没有任何收藏,点击媒体详情页的「收藏」按钮添加。
+
+ {loading && (
+
+
+ 加载中…
+
+ )}
+
+ {error && (
+
+ {error}
+
+
+ )}
+
+ {isEmpty && (
+
+ 还没有任何收藏
+
+ 点击媒体详情页的「收藏」按钮添加喜欢的内容
+
+
+ )}
+
+ {items.length > 0 && (
+
+ {items.map((m) => (
+
+ ))}
+
)}
-
- {items.map((m) => (
-
- ))}
-
)
}
diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx
index 3b4822e..e9bedd9 100644
--- a/web/src/pages/LoginPage.tsx
+++ b/web/src/pages/LoginPage.tsx
@@ -20,8 +20,8 @@ export function LoginPage() {
setLoading(true)
try {
const data = await authAPI.login(username, password)
- setSession(data.token, data.user)
- toast.success(`欢迎回来,${data.user.username}`)
+ setSession(data.tokens.access_token, data.tokens.refresh_token, data.user)
+ toast.success(`欢迎回来, ${data.user.username}`)
navigate('/')
} catch (err: unknown) {
const msg =
diff --git a/web/src/pages/SearchPage.tsx b/web/src/pages/SearchPage.tsx
index 9c97d5f..6ab09ce 100644
--- a/web/src/pages/SearchPage.tsx
+++ b/web/src/pages/SearchPage.tsx
@@ -1,4 +1,4 @@
-import { ChangeEvent, FormEvent, useEffect, useState } from 'react'
+import { ChangeEvent, FormEvent, useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Sparkles } from 'lucide-react'
@@ -7,16 +7,15 @@ import { mediaAPI } from '../api/library'
import { MediaCard } from '../components/MediaCard'
import type { Media } from '../types'
-// SearchPage runs a fast LIKE search by default, with an optional AI
-// "smart search" toggle that calls /api/ai/search to produce a structured
-// intent before executing the library lookup.
export function SearchPage() {
const [q, setQ] = useState('')
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
const [aiOn, setAiOn] = useState(false)
const [aiAvailable, setAiAvailable] = useState(false)
const [intent, setIntent] = useState(null)
+ const [hasSearched, setHasSearched] = useState(false)
useEffect(() => {
aiAPI
@@ -25,37 +24,63 @@ export function SearchPage() {
.catch(() => setAiAvailable(false))
}, [])
+ const doQuickSearch = useCallback((query: string) => {
+ if (!query.trim()) {
+ setItems([])
+ setHasSearched(false)
+ setLoading(false)
+ return
+ }
+ setHasSearched(true)
+ setError('')
+ mediaAPI
+ .search(query, 60)
+ .then((d) => {
+ setItems(d.items ?? [])
+ setIntent(null)
+ })
+ .catch((err) => {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '搜索失败'
+ setError(msg)
+ toast.error(msg)
+ })
+ .finally(() => setLoading(false))
+ }, [])
+
// Fast LIKE search-as-you-type when AI mode is OFF.
useEffect(() => {
if (aiOn) return
- const t = setTimeout(() => {
- setLoading(true)
- mediaAPI
- .search(q, 60)
- .then((d) => {
- setItems(d.items)
- setIntent(null)
- })
- .finally(() => setLoading(false))
- }, 300)
+ setLoading(true)
+ const t = setTimeout(() => doQuickSearch(q), 300)
return () => clearTimeout(t)
- }, [q, aiOn])
+ }, [q, aiOn, doQuickSearch])
const onAISubmit = async (e: FormEvent) => {
e.preventDefault()
if (!q.trim()) return
setLoading(true)
+ setError('')
+ setHasSearched(true)
try {
const data = await aiAPI.smartSearch(q)
- setItems(data.items)
+ setItems(data.items ?? [])
setIntent(data.intent)
- } catch {
- toast.error('AI 搜索失败')
+ } catch (err) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ 'AI 搜索失败'
+ setError(msg)
+ toast.error(msg)
} finally {
setLoading(false)
}
}
+ const showEmpty = !loading && !error && hasSearched && items.length === 0
+ const showIdle = !loading && !error && !hasSearched
+
return (
)}
- {loading && 搜索中… }
-
- {items.map((m) => (
-
- ))}
-
+ {loading && (
+
+
+ 搜索中…
+
+ )}
+
+ {error && (
+ {error}
+ )}
+
+ {showIdle && (
+
+ 输入关键词开始搜索
+
+ 支持电影、电视剧、动漫等媒体内容的快速搜索
+
+
+ )}
+
+ {showEmpty && (
+
+ 未找到匹配的媒体
+ 尝试其他关键词,或者添加媒体库后执行扫描
+
+ )}
+
+ {items.length > 0 && (
+
+ {items.map((m) => (
+
+ ))}
+
+ )}
)
}
diff --git a/web/src/pages/SitesPage.tsx b/web/src/pages/SitesPage.tsx
new file mode 100644
index 0000000..8e0bdef
--- /dev/null
+++ b/web/src/pages/SitesPage.tsx
@@ -0,0 +1,530 @@
+import { FormEvent, useEffect, useState } from 'react'
+import toast from 'react-hot-toast'
+import { Globe, Plus, Trash2, Wifi, RefreshCw, X, Edit3, CheckCircle, XCircle, HelpCircle } from 'lucide-react'
+
+import { sitesAPI } from '../api/sites'
+import type { Site } from '../types'
+
+// ── 站点类型映射 ──
+const SITE_TYPE_LABELS: Record = {
+ nexusphp: 'NexusPHP',
+ gazelle: 'Gazelle',
+ unit3d: 'UNIT3D',
+ mteam: 'M-Team',
+ discuz: 'Discuz',
+ custom_rss: '自定义 RSS',
+}
+
+const SITE_TYPE_ABBR: Record = {
+ nexusphp: 'NP',
+ gazelle: 'GZ',
+ unit3d: 'U3',
+ mteam: 'MT',
+ discuz: 'DZ',
+ custom_rss: 'RS',
+}
+
+const SITE_TYPE_COLORS: Record = {
+ nexusphp: 'bg-blue-500/15 text-blue-400',
+ gazelle: 'bg-purple-500/15 text-purple-400',
+ unit3d: 'bg-orange-500/15 text-orange-400',
+ mteam: 'bg-green-500/15 text-green-400',
+ discuz: 'bg-yellow-500/15 text-yellow-400',
+ custom_rss: 'bg-slate-500/15 text-slate-400',
+}
+
+const AUTH_TYPE_LABELS: Record = {
+ cookie: 'Cookie',
+ api_key: 'API Key',
+ auth_header: 'Auth Header',
+}
+
+// ── 默认表单 ──
+const defaultForm = () => ({
+ name: '',
+ url: '',
+ type: 'nexusphp',
+ auth_type: 'cookie',
+ cookie: '',
+ api_key: '',
+ auth_header: '',
+ enabled: true,
+ is_default: false,
+ extra: '',
+})
+
+export function SitesPage() {
+ const [sites, setSites] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [showModal, setShowModal] = useState(false)
+ const [editingId, setEditingId] = useState(null)
+ const [form, setForm] = useState(defaultForm())
+ const [saving, setSaving] = useState(false)
+ const [testingId, setTestingId] = useState(null)
+ const [advancedOpen, setAdvancedOpen] = useState(false)
+
+ const loadSites = async () => {
+ setLoading(true)
+ try {
+ const res = await sitesAPI.list()
+ setSites(Array.isArray(res.data) ? res.data : [])
+ } catch {
+ toast.error('加载站点列表失败')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ loadSites()
+ }, [])
+
+ // ── 弹窗操作 ──
+ const openCreate = () => {
+ setEditingId(null)
+ setForm(defaultForm())
+ setAdvancedOpen(false)
+ setShowModal(true)
+ }
+
+ const openEdit = async (id: string) => {
+ try {
+ const res = await sitesAPI.get(id)
+ const s = res.data as Site
+ setEditingId(id)
+ setForm({
+ name: s.name || '',
+ url: s.url || '',
+ type: s.type || 'nexusphp',
+ auth_type: s.auth_type || 'cookie',
+ cookie: s.cookie || '',
+ api_key: s.api_key || '',
+ auth_header: s.auth_header || '',
+ enabled: s.enabled !== false,
+ is_default: s.is_default || false,
+ extra: s.extra || '',
+ })
+ setAdvancedOpen(false)
+ setShowModal(true)
+ } catch {
+ toast.error('获取站点详情失败')
+ }
+ }
+
+ const closeModal = () => {
+ setShowModal(false)
+ setEditingId(null)
+ }
+
+ // ── 保存 ──
+ const handleSave = async (e: FormEvent) => {
+ e.preventDefault()
+ if (!form.name.trim() || !form.url.trim()) {
+ toast.error('站点名称和地址不能为空')
+ return
+ }
+ setSaving(true)
+ try {
+ const payload: Record = {
+ name: form.name.trim(),
+ url: form.url.trim(),
+ type: form.type,
+ auth_type: form.auth_type,
+ cookie: form.cookie || '',
+ api_key: form.api_key || '',
+ auth_header: form.auth_header || '',
+ enabled: form.enabled,
+ is_default: form.is_default,
+ extra: form.extra || '',
+ }
+
+ if (editingId) {
+ await sitesAPI.update(editingId, payload)
+ toast.success('站点已更新')
+ } else {
+ await sitesAPI.create(payload)
+ toast.success('站点已添加')
+ }
+ closeModal()
+ await loadSites()
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
+ '保存失败'
+ toast.error(msg)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ // ── 测试 ──
+ const handleTest = async (id: string) => {
+ setTestingId(id)
+ try {
+ await sitesAPI.test(id)
+ toast.success('连接测试成功')
+ } catch {
+ toast.error('连接测试失败')
+ } finally {
+ setTestingId(null)
+ }
+ }
+
+ // ── 删除 ──
+ const handleDelete = async (site: Site) => {
+ if (!confirm(`确定要删除站点「${site.name}」吗?此操作不可撤销。`)) return
+ try {
+ await sitesAPI.remove(site.id)
+ toast.success('站点已删除')
+ await loadSites()
+ } catch {
+ toast.error('删除站点失败')
+ }
+ }
+
+ // ── 站点类型切换时自动切换认证方式 ──
+ const handleTypeChange = (t: string) => {
+ setForm((f) => ({
+ ...f,
+ type: t,
+ auth_type: t === 'mteam' ? 'api_key' : f.auth_type,
+ }))
+ }
+
+ return (
+
+ {/* 页头 */}
+
+
+ {/* 站点卡片网格 */}
+
+ {sites.map((site) => (
+
+ {/* 头部 */}
+
+
+
+ {SITE_TYPE_ABBR[site.type] || '?'}
+
+
+ {site.name}
+ {site.url}
+
+
+ {/* 状态指示 */}
+
+ {site.last_check_at ? (
+ site.last_error ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+ {!site.enabled && 已停用}
+
+
+
+ {/* 标签 */}
+
+
+ {SITE_TYPE_LABELS[site.type] || site.type}
+
+
+ {AUTH_TYPE_LABELS[site.auth_type] || site.auth_type}
+
+ {site.is_default && (
+ 默认
+ )}
+
+
+ {/* 操作按钮 */}
+
+
+
+
+
+
+ ))}
+
+ {/* 空状态 */}
+ {!loading && sites.length === 0 && (
+
+
+ 暂无站点
+ 点击「添加站点」添加 PT/BT 站点
+
+ )}
+
+ {/* 加载中 */}
+ {loading && (
+
+ )}
+
+
+ {/* ── 创建/编辑弹窗 ── */}
+ {showModal && (
+
+ e.stopPropagation()}
+ >
+ {/* 标题栏 */}
+
+
+ {editingId ? '编辑站点' : '添加站点'}
+
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts
index 119e9af..ba8a5d4 100644
--- a/web/src/stores/auth.ts
+++ b/web/src/stores/auth.ts
@@ -2,26 +2,89 @@ import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User } from '../types'
+import { refreshToken } from '../api/refresh'
// Single source of truth for the authenticated user + JWT.
// Persisted to localStorage so a page reload does not drop the session.
interface AuthState {
token: string | null
+ refreshToken: string | null
user: User | null
- setSession: (token: string, user: User) => void
+ tier: string
+ setSession: (token: string, refreshToken: string, user: User) => void
setUser: (user: User) => void
+ setToken: (token: string) => void
+ setRefreshToken: (refreshToken: string) => void
logout: () => void
+ tokenRefresh: () => Promise
}
export const useAuthStore = create()(
persist(
- (set) => ({
+ (set, get) => ({
token: null,
+ refreshToken: null,
user: null,
- setSession: (token, user) => set({ token, user }),
- setUser: (user) => set({ user }),
- logout: () => set({ token: null, user: null }),
+ tier: 'free',
+ setSession: (token, refreshToken, user) => set({
+ token,
+ refreshToken,
+ user,
+ tier: user.tier || 'free'
+ }),
+ setUser: (user) => set({ user, tier: user.tier || 'free' }),
+ setToken: (token) => set({ token }),
+ setRefreshToken: (refreshToken) => set({ refreshToken }),
+ logout: () => set({ token: null, refreshToken: null, user: null, tier: 'free' }),
+ tokenRefresh: async () => {
+ const rt = get().refreshToken
+ if (!rt) {
+ return false
+ }
+ try {
+ const resp = await refreshToken(rt)
+ set({
+ token: resp.token,
+ refreshToken: resp.refresh_token
+ })
+ return true
+ } catch {
+ // Refresh failed, need to logout
+ set({ token: null, refreshToken: null, user: null, tier: 'free' })
+ return false
+ }
+ },
}),
- { name: 'mediastationgo-auth' },
+ {
+ name: 'mediastationgo-auth',
+ partialize: (state) => ({
+ token: state.token,
+ refreshToken: state.refreshToken,
+ user: state.user,
+ tier: state.tier
+ }),
+ },
),
)
+
+// Helper function to check if user is authenticated
+export function isAuthenticated(): boolean {
+ return useAuthStore.getState().token !== null
+}
+
+// Helper function to check if user is admin
+export function isAdmin(): boolean {
+ const user = useAuthStore.getState().user
+ return user?.role === 'admin'
+}
+
+// Helper function to check if user is plus
+export function isPlus(): boolean {
+ const state = useAuthStore.getState()
+ return state.tier === 'plus' || state.user?.role === 'admin'
+}
+
+// Helper function to check if user is super user (admin or plus)
+export function isSuperUser(): boolean {
+ return isAdmin() || isPlus()
+}
diff --git a/web/src/stores/permissions.ts b/web/src/stores/permissions.ts
new file mode 100644
index 0000000..5d025ae
--- /dev/null
+++ b/web/src/stores/permissions.ts
@@ -0,0 +1,139 @@
+import { create } from 'zustand'
+
+import { getMyPermissions } from '../api/permission'
+
+interface PermissionState {
+ permissions: Record
+ role: string
+ tier: string
+ isSuper: boolean
+ isLoading: boolean
+ error: string | null
+ fetchPermissions: () => Promise
+ hasPermission: (key: string) => boolean
+ clearPermissions: () => void
+}
+
+export const usePermissionStore = create((set, get) => ({
+ permissions: {},
+ role: '',
+ tier: 'free',
+ isSuper: false,
+ isLoading: false,
+ error: null,
+
+ fetchPermissions: async () => {
+ set({ isLoading: true, error: null })
+ try {
+ const result = await getMyPermissions()
+ set({
+ permissions: result.permissions,
+ role: result.role,
+ tier: result.tier,
+ isSuper: result.is_super,
+ isLoading: false,
+ })
+ } catch (err) {
+ set({
+ isLoading: false,
+ error: err instanceof Error ? err.message : 'Failed to fetch permissions',
+ })
+ }
+ },
+
+ hasPermission: (key: string) => {
+ const state = get()
+ // Super user (admin or plus) has all permissions
+ if (state.isSuper) {
+ return true
+ }
+ return state.permissions[key] === true
+ },
+
+ clearPermissions: () => {
+ set({
+ permissions: {},
+ role: '',
+ tier: 'free',
+ isSuper: false,
+ error: null,
+ })
+ },
+}))
+
+// Default permissions for new users (without fetching from server)
+export const defaultPermissions: Record = {
+ can_view_dashboard: true,
+ can_play_media: true,
+ can_cast: true,
+ can_external_player: true,
+ can_favorite: true,
+ can_view_history: true,
+ can_edit_media: false,
+ can_rescrape: false,
+ can_use_ai: false,
+ can_capture_frames: false,
+ can_manage_downloads: false,
+ can_view_discover: false,
+ can_manage_subscriptions: false,
+ can_manage_sites: false,
+ can_use_ai_assistant: false,
+ can_manage_users: false,
+ can_manage_files: false,
+ can_manage_strm: false,
+ can_access_settings: false,
+}
+
+// Permission display names for UI
+export const permissionDisplayNames: Record = {
+ can_view_dashboard: '查看仪表盘',
+ can_play_media: '播放媒体',
+ can_cast: '投屏',
+ can_external_player: '外部播放器',
+ can_favorite: '收藏',
+ can_view_history: '观看历史',
+ can_edit_media: '编辑媒体',
+ can_rescrape: '重新刮削',
+ can_use_ai: '使用 AI 搜索',
+ can_capture_frames: '截图',
+ can_manage_downloads: '管理下载',
+ can_view_discover: '发现',
+ can_manage_subscriptions: '管理订阅',
+ can_manage_sites: '管理站点',
+ can_use_ai_assistant: 'AI 助手',
+ can_manage_users: '管理用户',
+ can_manage_files: '管理文件',
+ can_manage_strm: '管理 STRM',
+ can_access_settings: '访问设置',
+}
+
+// Permission categories for grouping
+export const permissionCategories = {
+ basic: [
+ 'can_view_dashboard',
+ 'can_play_media',
+ 'can_cast',
+ 'can_external_player',
+ 'can_favorite',
+ 'can_view_history',
+ ],
+ media: [
+ 'can_edit_media',
+ 'can_rescrape',
+ 'can_capture_frames',
+ 'can_manage_files',
+ 'can_manage_strm',
+ ],
+ advanced: [
+ 'can_use_ai',
+ 'can_use_ai_assistant',
+ 'can_view_discover',
+ 'can_manage_downloads',
+ 'can_manage_subscriptions',
+ 'can_manage_sites',
+ ],
+ admin: [
+ 'can_manage_users',
+ 'can_access_settings',
+ ],
+}
diff --git a/web/src/types/index.ts b/web/src/types/index.ts
index fb52431..6ffc371 100644
--- a/web/src/types/index.ts
+++ b/web/src/types/index.ts
@@ -4,14 +4,92 @@ export interface User {
id: string
username: string
role: 'admin' | 'user'
+ tier: 'free' | 'plus'
+ nickname?: string
email?: string
avatar_url?: string
force_password_reset: boolean
+ is_active: boolean
last_login_at?: string
created_at: string
updated_at: string
}
+// Token pair
+export interface TokenPair {
+ access_token: string
+ refresh_token: string
+ expires_in: number
+ token_type: string
+}
+
+// User permission (19 granular permissions)
+export interface UserPermission {
+ id: string
+ user_id: string
+ // Default enabled (6)
+ can_view_dashboard: boolean
+ can_play_media: boolean
+ can_cast: boolean
+ can_external_player: boolean
+ can_favorite: boolean
+ can_view_history: boolean
+ // Default disabled (13)
+ can_edit_media: boolean
+ can_rescrape: boolean
+ can_use_ai: boolean
+ can_capture_frames: boolean
+ can_manage_downloads: boolean
+ can_view_discover: boolean
+ can_manage_subscriptions: boolean
+ can_manage_sites: boolean
+ can_use_ai_assistant: boolean
+ can_manage_users: boolean
+ can_manage_files: boolean
+ can_manage_strm: boolean
+ can_access_settings: boolean
+ created_at: string
+ updated_at: string
+}
+
+// API config
+export interface ApiConfig {
+ id: string
+ provider: string
+ api_key?: string
+ base_url?: string
+ extra?: string
+ enabled: boolean
+ description?: string
+ last_tested_at?: string
+ test_result?: string
+ updated_at: string
+}
+
+// API provider
+export interface ApiProvider {
+ id: string
+ name: string
+ description: string
+ has_api_key: boolean
+ has_base_url: boolean
+}
+
+// Refresh token response
+export interface RefreshTokenResponse {
+ token: string
+ refresh_token: string
+ expires_in: number
+ token_type: string
+}
+
+// Login response
+export interface LoginResponse {
+ user: User
+ tokens: TokenPair
+}
+
+// Library
export interface Library {
id: string
name: string
@@ -140,3 +218,107 @@ export interface StatsSnapshot {
hardware: Hardware
generated_at: string
}
+
+// SSE Event types
+export interface SSEEvent {
+ type: string
+ payload: unknown
+}
+
+// Permission check result
+export interface PermissionCheckResult {
+ permissions: Record
+ role: string
+ tier: string
+ is_super: boolean
+}
+
+// Download Client
+export interface DownloadClient {
+ id: string
+ name: string
+ type: 'qbittorrent' | 'transmission' | 'aria2'
+ host: string
+ username: string
+ is_default: boolean
+ enabled: boolean
+ created_at: string
+ updated_at: string
+}
+
+// Download Client type info
+export interface DownloadClientTypeInfo {
+ type: string
+ name: string
+ description: string
+}
+
+// Notify Channel
+export interface NotifyChannel {
+ id: string
+ name: string
+ type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
+ enabled: boolean
+ events: string
+ created_at: string
+ updated_at: string
+}
+
+// Notify Provider type info
+export interface NotifyProviderInfo {
+ type: string
+ name: string
+ description: string
+}
+
+// Scheduler task config
+export interface SchedulerTaskConfig {
+ id: string
+ name: string
+ description: string
+ enabled: boolean
+ interval: number
+ last_run_at?: string
+ next_run_at?: string
+}
+
+// Scheduler status
+export interface SchedulerStatus {
+ running: boolean
+ started_at?: string
+ task_count: number
+ tasks: SchedulerTaskConfig[]
+}
+
+// Site configuration
+export interface Site {
+ id: string
+ name: string
+ type: string // nexusphp / gazelle / unit3d / mteam / discuz / custom_rss
+ url: string
+ auth_type: string // cookie / api_key / auth_header
+ cookie?: string // decrypted only in detail view
+ api_key?: string // decrypted only in detail view
+ auth_header?: string // decrypted only in detail view
+ enabled: boolean
+ is_default: boolean
+ extra?: string
+ last_error?: string
+ last_check_at?: string
+ created_at: string
+ updated_at: string
+}
+
+// Site type info
+export interface SiteTypeInfo {
+ value: string
+ name: string
+ description: string
+}
+
+// Auth type info
+export interface AuthTypeInfo {
+ value: string
+ name: string
+ description: string
+}
|