refactor: split oversized route and subscription modules

This commit is contained in:
ShukeBta
2026-06-07 14:59:32 +08:00
parent b35b36738e
commit 33698daa3f
14 changed files with 1774 additions and 1679 deletions
+4 -361
View File
@@ -5,13 +5,10 @@
package handler
import (
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
@@ -25,367 +22,13 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
// Telegram Bot webhook — called by Telegram servers, no auth.
api.POST("/telegram/webhook", telegramWebhookHandler(svc))
// Rate limiter for credential endpoints (login/register): brute-force
// protection. 30/min per IP tolerates many users behind a single NAT
// or reverse-proxy IP while still throttling password guessing.
authLimiter := middleware.NewRateLimiter(30, 1*time.Minute)
registerPublicAuthRoutes(api, svc, log)
// Public auth.
auth := api.Group("/auth")
{
auth.POST("/login", middleware.RateLimit(authLimiter), loginHandler(svc))
auth.POST("/register", middleware.RateLimit(authLimiter), registerHandler(svc))
// /auth/refresh 用 RefreshHandler.RefreshToken:它从 body 读
// refresh_token 并签发新 access/refresh 对。旧的 refreshHandler
// 依赖 AuthRequired 中间件,永远 401,因此弃用。
//
// 刷新端点【不】做 IP 限流:刷新本身就是防止掉登录的机制,且已
// 由一次性轮换的 refresh token 强校验。若按 IP 限流,多个用户/
// 标签页共用一个反代 IP 时会把正常刷新打成 429,反而导致频繁
// 掉登录。
refreshHd := NewRefreshHandler(svc, log)
auth.POST("/refresh", refreshHd.RefreshToken)
}
registerAuthenticatedRoutes(api, cfg, svc)
// Authenticated endpoints.
authed := api.Group("/")
authed.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
authed.Use(activeUserRequired(svc))
{
authed.GET("/me", meHandler(svc))
authed.PATCH("/me", updateProfileHandler(svc))
authed.POST("/me/password", changePasswordHandler(svc))
authed.POST("/me/logout", logoutHandler(svc))
registerAdminRoutes(api, cfg, svc)
// Permissions.
authed.GET("/auth/permissions", getMyPermissionsHandler(svc))
// License activation bridge (admin only; talks to MediaStationLicenseServer).
authed.GET("/license/status", middleware.AdminRequired(), licenseStatusHandler(svc))
authed.POST("/license/activate", middleware.AdminRequired(), licenseActivateHandler(svc))
authed.POST("/license/heartbeat", middleware.AdminRequired(), licenseHeartbeatHandler(svc))
// Libraries.
authed.GET("/libraries", listLibrariesHandler(svc))
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
authed.POST("/libraries/:id/scan", middleware.AdminRequired(), scanLibraryHandler(svc))
authed.POST("/libraries/:id/scrape", middleware.AdminRequired(), scrapeLibraryHandler(svc))
authed.GET("/libraries/:id/media", listMediaHandler(svc))
authed.GET("/libraries/:id/seasons", listSeasonsHandler(svc))
// Media.
authed.GET("/media/:id", getMediaHandler(svc))
authed.GET("/media", searchMediaHandler(svc))
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
authed.POST("/media/:id/probe", middleware.AdminRequired(), reprobeHandler(svc))
authed.DELETE("/media/:id", middleware.AdminRequired(), deleteMediaHandler(svc))
authed.POST("/media/:id/restore", middleware.AdminRequired(), restoreMediaHandler(svc))
authed.DELETE("/media/:id/purge", middleware.AdminRequired(), purgeMediaHandler(svc))
authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
authed.GET("/subtitles/:id", serveSubtitleHandler(svc))
authed.POST("/media/:id/nfo", middleware.AdminRequired(), exportNFOHandler(svc))
authed.POST("/libraries/:id/nfo", middleware.AdminRequired(), exportLibraryNFOHandler(svc))
// Streaming.
authed.GET("/stream/:id", streamHandler(svc))
authed.HEAD("/stream/:id", streamHandler(svc))
authed.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc))
authed.GET("/hls/:id/:seg", hlsSegmentHandler(svc))
authed.DELETE("/hls/:id", stopTranscodeHandler(svc))
// Cloud-disk 302 playback redirect (resolves a fresh direct link).
authed.GET("/cloud/play/:type", cloudPlayHandler(svc))
authed.HEAD("/cloud/play/:type", cloudPlayHandler(svc))
// Image proxy (URL passed as ?url=...).
authed.GET("/img", imageProxyHandler(svc))
// History / favourites / playlists.
authed.GET("/history", recentHistoryHandler(svc))
authed.POST("/history", recordProgressHandler(svc))
authed.GET("/favourites", listFavouritesHandler(svc))
authed.POST("/favourites/:id", toggleFavouriteHandler(svc))
authed.GET("/playlists", listPlaylistsHandler(svc))
authed.POST("/playlists", createPlaylistHandler(svc))
authed.GET("/playlists/:id", getPlaylistHandler(svc))
authed.POST("/playlists/:id/items", addPlaylistItemHandler(svc))
authed.DELETE("/playlists/:id/items/:media_id", removePlaylistItemHandler(svc))
authed.DELETE("/playlists/:id", deletePlaylistHandler(svc))
// Downloads.
authed.GET("/downloads", requirePermission(svc, "can_manage_downloads"), listDownloadsHandler(svc))
authed.POST("/downloads", requirePermission(svc, "can_manage_downloads"), addDownloadHandler(svc))
authed.DELETE("/downloads/:hash", requirePermission(svc, "can_manage_downloads"), deleteDownloadHandler(svc))
authed.POST("/downloads/relocate", requirePermission(svc, "can_manage_downloads"), relocateDownloadHandler(svc))
authed.POST("/downloads/reload", requirePermission(svc, "can_manage_downloads"), reloadDownloadConfigHandler(svc))
// Subscriptions.
authed.GET("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionsHandler(svc))
authed.POST("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), createSubscriptionHandler(svc))
authed.DELETE("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), deleteSubscriptionHandler(svc))
authed.POST("/subscriptions/:id/run", requirePermission(svc, "can_manage_subscriptions"), runSubscriptionHandler(svc))
// Stats / dashboard.
authed.GET("/stats", statsHandler(svc))
authed.GET("/tasks", middleware.AdminRequired(), tasksHandler(svc))
// Discover (TMDb trending / popular).
authed.GET("/discover/trending", requirePermission(svc, "can_view_discover"), trendingHandler(svc))
authed.GET("/discover/popular", requirePermission(svc, "can_view_discover"), popularHandler(svc))
// AI.
authed.GET("/ai/status", requirePermission(svc, "can_use_ai"), aiStatusHandler(svc))
authed.POST("/ai/search", requirePermission(svc, "can_use_ai"), smartSearchHandler(svc))
authed.GET("/ai/recommend", requirePermission(svc, "can_use_ai"), aiRecommendHandler(svc))
// File browser (used by the library-path picker).
authed.GET("/files", middleware.AdminRequired(), browseFilesHandler(svc))
// Disk usage breakdown.
authed.GET("/storage", middleware.AdminRequired(), storageHandler(svc))
// DLNA discovery + cast.
authed.GET("/dlna/devices", dlnaListHandler(svc))
authed.POST("/dlna/cast", dlnaCastHandler(svc))
// STRM (URL-as-file).
authed.PUT("/media/:id/strm", middleware.AdminRequired(), setSTRMHandler(svc))
authed.DELETE("/media/:id/strm", middleware.AdminRequired(), clearSTRMHandler(svc))
authed.POST("/strm/import", middleware.AdminRequired(), importSTRMHandler(svc))
// Duplicate finder.
authed.GET("/duplicates", middleware.AdminRequired(), listDuplicatesHandler(svc))
authed.POST("/duplicates/scan", middleware.AdminRequired(), detectDuplicatesHandler(svc))
authed.POST("/duplicates/unmark", middleware.AdminRequired(), unmarkDuplicatesHandler(svc))
// Site management + cross-site torrent search (via SiteHandler).
siteHandler := NewSiteHandler(svc)
authed.GET("/sites", requirePermission(svc, "can_manage_sites"), siteHandler.ListSites)
authed.GET("/sites/types", requirePermission(svc, "can_manage_sites"), siteHandler.GetSiteTypes)
authed.GET("/sites/auth-types", requirePermission(svc, "can_manage_sites"), siteHandler.GetAuthTypes)
authed.POST("/sites", requirePermission(svc, "can_manage_sites"), siteHandler.CreateSite)
authed.GET("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.GetSite)
authed.PUT("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.UpdateSite)
authed.DELETE("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.DeleteSite)
authed.POST("/sites/:id/test", requirePermission(svc, "can_manage_sites"), siteHandler.TestSite)
authed.GET("/sites/search", requirePermission(svc, "can_manage_sites"), siteSearchHandler(svc))
// Recycle bin.
authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
authed.GET("/ws", wsHandler(svc))
// SSE event stream.
authed.GET("/events", sseHandler(svc))
// Scheduler.
authed.GET("/scheduler/tasks", schedulerListTasksHandler(svc))
authed.POST("/scheduler/tasks/:id/run", middleware.AdminRequired(), schedulerRunTaskHandler(svc))
authed.GET("/scheduler/status", schedulerGetStatusHandler(svc))
// ── Auxiliary endpoints used by the React UI rails ──
authed.GET("/media/recent", recentMediaHandler(svc))
authed.GET("/media/stats", mediaStatsHandler(svc))
// Watch history (extra surface beyond /history).
authed.GET("/watch-history", historyListHandler(svc))
authed.GET("/watch-history/stats", historyStatsHandler(svc))
authed.GET("/watch-history/continue", historyContinueHandler(svc))
authed.DELETE("/watch-history", historyDeleteHandler(svc))
authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc))
// Multi-section TMDb feed used by DiscoverPage.
authed.GET("/discover/sections", requirePermission(svc, "can_view_discover"), discoverSectionsHandler(svc))
authed.GET("/discover/feed", requirePermission(svc, "can_view_discover"), discoverFeedHandler(svc))
// System metadata + read-only scheduler view.
authed.GET("/system/info", systemInfoHandler(svc))
authed.GET("/system/status", systemStatusHandler(svc))
authed.GET("/system/scheduler", systemSchedulerHandler(svc))
// Richer dashboard rails.
authed.GET("/stats/overview", statsOverviewHandler(svc))
authed.GET("/stats/trend", statsTrendHandler(svc))
authed.GET("/stats/top-content", statsTopContentHandler(svc))
authed.GET("/stats/libraries", statsLibrariesHandler(svc))
authed.GET("/stats/monitor", statsMonitorHandler(svc))
// Multi-persona play profiles (caller-scoped).
authed.GET("/play-profiles", listPlayProfilesHandler(svc))
authed.POST("/play-profiles", createPlayProfileHandler(svc))
authed.PUT("/play-profiles/:id", updatePlayProfileHandler(svc))
authed.POST("/play-profiles/:id/verify-pin", verifyPlayProfilePINHandler(svc))
authed.DELETE("/play-profiles/:id", deletePlayProfileHandler(svc))
// ── Search aliases ──
authed.GET("/search", searchUnifiedHandler(svc))
authed.GET("/search/advanced", searchAdvancedHandler(svc))
authed.GET("/search/tmdb", searchTMDbHandler(svc))
authed.GET("/search/sites", searchSitesHandler(svc))
// ── System extras ──
authed.GET("/system/config", listSystemConfigHandler(svc))
authed.GET("/settings/schema", schemaHandler(svc))
authed.GET("/system/events/ticket", systemEventsTicketHandler(svc))
// ── Per-user stats ──
authed.GET("/stats/user/:id", statsUserHandler(svc))
authed.GET("/stats/top-users", statsTopUsersHandler(svc))
authed.POST("/stats/play", statsPlayHandler(svc))
// ── Sites extras ──
authed.GET("/sites/:id/resource", requirePermission(svc, "can_manage_sites"), siteResourceHandler(svc))
authed.GET("/sites/:id/userdata", requirePermission(svc, "can_manage_sites"), siteUserdataHandler(svc))
// ── Subscription extras ──
authed.PUT("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), updateSubscriptionHandler(svc))
authed.POST("/subscriptions/:id/search", requirePermission(svc, "can_manage_subscriptions"), searchSubscriptionHandler(svc))
// ── Playlist extras ──
authed.POST("/playlists/:id/reorder", reorderPlaylistHandler(svc))
authed.DELETE("/playlists/:id/items/by-id/:item_id", deletePlaylistItemByIDHandler(svc))
// ── DLNA per-renderer control ──
authed.POST("/dlna/:uuid/play", dlnaPlayHandler(svc))
authed.POST("/dlna/:uuid/pause", dlnaPauseHandler(svc))
authed.POST("/dlna/:uuid/stop", dlnaStopHandler(svc))
authed.GET("/dlna/:uuid/status", dlnaStatusHandler(svc))
// ── Media favourite alias surface ──
authed.GET("/favorites", listFavoritesAliasHandler(svc))
authed.POST("/media/:id/favorite", addMediaFavoriteHandler(svc))
authed.DELETE("/media/:id/favorite", removeMediaFavoriteHandler(svc))
authed.GET("/media/:id/favorite/status", getMediaFavoriteStatusHandler(svc))
authed.POST("/media/:id/ai-scrape", requirePermission(svc, "can_rescrape"), aiScrapeMediaHandler(svc))
authed.POST("/media/scrape/test", requirePermission(svc, "can_rescrape"), scrapeTestHandler(svc))
authed.POST("/media/organize", requirePermission(svc, "can_manage_files"), organizeBulkHandler(svc))
// ── Playback metadata + external player handoff ──
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
authed.GET("/playback/transcode/:job_id/status", transcodeStatusHandler(svc))
// ── Download task ops + sync triggers ──
authed.POST("/download/:id/pause", requirePermission(svc, "can_manage_downloads"), downloadPauseHandler(svc))
authed.POST("/download/:id/resume", requirePermission(svc, "can_manage_downloads"), downloadResumeHandler(svc))
authed.POST("/download/:id/organize", requirePermission(svc, "can_manage_files"), downloadOrganizeOneHandler(svc))
authed.POST("/download/organize", requirePermission(svc, "can_manage_files"), downloadOrganizeAllHandler(svc))
authed.POST("/download/sync", requirePermission(svc, "can_manage_downloads"), downloadSyncHandler(svc))
authed.POST("/download/start-auto-sync", requirePermission(svc, "can_manage_downloads"), downloadAutoSyncHandler(svc))
authed.GET("/download/tasks", requirePermission(svc, "can_manage_downloads"), downloadTasksAliasHandler(svc))
// ── Assistant (multi-turn AI chat) ──
authed.GET("/admin/assistant/sessions", listAssistantSessionsHandler(svc))
authed.POST("/admin/assistant/sessions", createAssistantSessionHandler(svc))
authed.GET("/admin/assistant/session/:id", getAssistantSessionHandler(svc))
authed.DELETE("/admin/assistant/session/:id", deleteAssistantSessionHandler(svc))
authed.POST("/admin/assistant/chat", assistantChatHandler(svc))
authed.POST("/admin/assistant/execute", assistantExecuteHandler(svc))
authed.POST("/admin/assistant/undo/:op_id", assistantUndoHandler(svc))
authed.GET("/admin/assistant/history", assistantHistoryHandler(svc))
}
// Admin-only endpoints.
admin := api.Group("/admin")
admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired())
{
admin.GET("/users", listUsersHandler(svc))
admin.POST("/users", createUserHandler(svc))
admin.PATCH("/users/:id", updateUserHandler(svc))
admin.PATCH("/users/:id/password", resetUserPasswordHandler(svc))
admin.PATCH("/users/:id/status", updateUserStatusHandler(svc))
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
admin.DELETE("/users/:id", deleteUserHandler(svc))
admin.GET("/settings", listSettingsHandler(svc))
admin.PUT("/settings", updateSettingHandler(svc))
admin.GET("/logs", recentLogsHandler(svc))
// Permissions admin.
admin.GET("/users/:id/permissions", getUserPermissionsHandler(svc))
admin.PUT("/users/:id/permissions", updateUserPermissionsHandler(svc))
admin.POST("/users/:id/permissions/reset", resetUserPermissionsHandler(svc))
// Storage configs (Alist / S3 / WebDAV / 网盘).
admin.GET("/storage/status", listStorageConfigsHandler(svc))
admin.GET("/storage/:type", getStorageConfigHandler(svc))
admin.PUT("/storage/:type", saveStorageConfigHandler(svc))
admin.POST("/storage/:type/test", testStorageConfigHandler(svc))
// Cloud disk (115 / 夸克) browsing, QR login and 302 import.
admin.GET("/cloud/:type/list", cloudListHandler(svc))
admin.POST("/cloud/:type/import", cloudImportHandler(svc))
admin.POST("/cloud/:type/qr/start", cloud115QRStartHandler(svc))
admin.POST("/cloud/:type/qr/poll", cloud115QRPollHandler(svc))
// Download client CRUD.
admin.GET("/download/clients", listDownloadClientsHandler(svc))
admin.POST("/download/clients", createDownloadClientHandler(svc))
admin.PUT("/download/clients/:id", updateDownloadClientHandler(svc))
admin.DELETE("/download/clients/:id", deleteDownloadClientHandler(svc))
admin.POST("/download/clients/:id/test", testDownloadClientHandler(svc))
admin.GET("/download/aria2/stats", aria2StatsHandler(svc))
// System scheduler trigger alias.
admin.POST("/system/scheduler/:name/trigger", schedulerTriggerHandler(svc))
// Database backup.
admin.GET("/backups", listBackupsHandler(svc))
admin.POST("/backups", createBackupHandler(svc))
admin.DELETE("/backups", deleteBackupHandler(svc))
admin.POST("/backups/restore", restoreBackupHandler(svc))
// Notifications (test endpoint).
admin.POST("/notify/test", notifyTestHandler(svc))
// Notify channels CRUD + per-channel test.
admin.GET("/notify/channels", listNotifyChannelsHandler(svc))
admin.POST("/notify/channels", createNotifyChannelHandler(svc))
admin.PUT("/notify/channels/:id", updateNotifyChannelHandler(svc))
admin.DELETE("/notify/channels/:id", deleteNotifyChannelHandler(svc))
admin.POST("/notify/channels/:id/test", testNotifyChannelHandler(svc))
// Telegram Bot webhook management.
admin.GET("/telegram/webhook", telegramGetWebhookHandler(svc))
admin.POST("/telegram/webhook", telegramSetWebhookHandler(svc))
admin.POST("/telegram/polling/start", telegramStartPollingHandler(svc))
admin.POST("/telegram/polling/stop", telegramStopPollingHandler(svc))
// File organizer.
admin.POST("/media/:id/organize", organizeMediaHandler(svc))
admin.POST("/libraries/:id/organize", organizeLibraryHandler(svc))
admin.GET("/organize/sources", organizeSourcesHandler(svc))
admin.POST("/organize/source", organizeDirectoryHandler(svc))
// API key management (encrypted at rest).
admin.GET("/api-configs", listAPIConfigsHandler(svc))
admin.GET("/api-configs/:provider", getAPIConfigHandler(svc))
admin.PUT("/api-configs/:provider", updateAPIConfigHandler(svc))
admin.DELETE("/api-configs/:provider", deleteAPIConfigHandler(svc))
// Scheduled jobs.
admin.GET("/scheduler", schedulerStatusHandler(svc))
admin.POST("/scheduler/:name/run", schedulerRunHandler(svc))
}
// API Config management (admin only).
apiConfig := api.Group("/api-config")
apiConfig.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired())
{
apiConfig.GET("", listApiConfigsHandler(svc))
apiConfig.GET("/providers/list", listProvidersHandler(svc))
apiConfig.GET("/:provider", getApiConfigHandler(svc))
apiConfig.GET("/:provider/effective", getEffectiveConfigHandler(svc))
apiConfig.POST("/:provider", upsertApiConfigHandler(svc))
apiConfig.DELETE("/:provider", deleteApiConfigHandler(svc))
apiConfig.POST("/:provider/test", testApiConfigHandler(svc))
}
registerAPIConfigRoutes(api, cfg, svc)
// Emby/Jellyfin compatibility shim — routes mounted at /emby/* AND
// the root path so Infuse / Yamby / Hills / Senplayer 都能自动连接。
+96
View File
@@ -0,0 +1,96 @@
// Package handler — admin-only routes.
package handler
import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.Container) {
// Admin-only endpoints.
admin := api.Group("/admin")
admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired())
{
admin.GET("/users", listUsersHandler(svc))
admin.POST("/users", createUserHandler(svc))
admin.PATCH("/users/:id", updateUserHandler(svc))
admin.PATCH("/users/:id/password", resetUserPasswordHandler(svc))
admin.PATCH("/users/:id/status", updateUserStatusHandler(svc))
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
admin.DELETE("/users/:id", deleteUserHandler(svc))
admin.GET("/settings", listSettingsHandler(svc))
admin.PUT("/settings", updateSettingHandler(svc))
admin.GET("/logs", recentLogsHandler(svc))
// Permissions admin.
admin.GET("/users/:id/permissions", getUserPermissionsHandler(svc))
admin.PUT("/users/:id/permissions", updateUserPermissionsHandler(svc))
admin.POST("/users/:id/permissions/reset", resetUserPermissionsHandler(svc))
// Storage configs (Alist / S3 / WebDAV / 网盘).
admin.GET("/storage/status", listStorageConfigsHandler(svc))
admin.GET("/storage/:type", getStorageConfigHandler(svc))
admin.PUT("/storage/:type", saveStorageConfigHandler(svc))
admin.POST("/storage/:type/test", testStorageConfigHandler(svc))
// Cloud disk (115 / 夸克) browsing, QR login and 302 import.
admin.GET("/cloud/:type/list", cloudListHandler(svc))
admin.POST("/cloud/:type/import", cloudImportHandler(svc))
admin.POST("/cloud/:type/qr/start", cloud115QRStartHandler(svc))
admin.POST("/cloud/:type/qr/poll", cloud115QRPollHandler(svc))
// Download client CRUD.
admin.GET("/download/clients", listDownloadClientsHandler(svc))
admin.POST("/download/clients", createDownloadClientHandler(svc))
admin.PUT("/download/clients/:id", updateDownloadClientHandler(svc))
admin.DELETE("/download/clients/:id", deleteDownloadClientHandler(svc))
admin.POST("/download/clients/:id/test", testDownloadClientHandler(svc))
admin.GET("/download/aria2/stats", aria2StatsHandler(svc))
// System scheduler trigger alias.
admin.POST("/system/scheduler/:name/trigger", schedulerTriggerHandler(svc))
// Database backup.
admin.GET("/backups", listBackupsHandler(svc))
admin.POST("/backups", createBackupHandler(svc))
admin.DELETE("/backups", deleteBackupHandler(svc))
admin.POST("/backups/restore", restoreBackupHandler(svc))
// Notifications (test endpoint).
admin.POST("/notify/test", notifyTestHandler(svc))
// Notify channels CRUD + per-channel test.
admin.GET("/notify/channels", listNotifyChannelsHandler(svc))
admin.POST("/notify/channels", createNotifyChannelHandler(svc))
admin.PUT("/notify/channels/:id", updateNotifyChannelHandler(svc))
admin.DELETE("/notify/channels/:id", deleteNotifyChannelHandler(svc))
admin.POST("/notify/channels/:id/test", testNotifyChannelHandler(svc))
// Telegram Bot webhook management.
admin.GET("/telegram/webhook", telegramGetWebhookHandler(svc))
admin.POST("/telegram/webhook", telegramSetWebhookHandler(svc))
admin.POST("/telegram/polling/start", telegramStartPollingHandler(svc))
admin.POST("/telegram/polling/stop", telegramStopPollingHandler(svc))
// File organizer.
admin.POST("/media/:id/organize", organizeMediaHandler(svc))
admin.POST("/libraries/:id/organize", organizeLibraryHandler(svc))
admin.GET("/organize/sources", organizeSourcesHandler(svc))
admin.POST("/organize/source", organizeDirectoryHandler(svc))
// API key management (encrypted at rest).
admin.GET("/api-configs", listAPIConfigsHandler(svc))
admin.GET("/api-configs/:provider", getAPIConfigHandler(svc))
admin.PUT("/api-configs/:provider", updateAPIConfigHandler(svc))
admin.DELETE("/api-configs/:provider", deleteAPIConfigHandler(svc))
// Scheduled jobs.
admin.GET("/scheduler", schedulerStatusHandler(svc))
admin.POST("/scheduler/:name/run", schedulerRunHandler(svc))
}
}
+26
View File
@@ -0,0 +1,26 @@
// Package handler — API configuration routes.
package handler
import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func registerAPIConfigRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.Container) {
// API Config management (admin only).
apiConfig := api.Group("/api-config")
apiConfig.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired())
{
apiConfig.GET("", listApiConfigsHandler(svc))
apiConfig.GET("/providers/list", listProvidersHandler(svc))
apiConfig.GET("/:provider", getApiConfigHandler(svc))
apiConfig.GET("/:provider/effective", getEffectiveConfigHandler(svc))
apiConfig.POST("/:provider", upsertApiConfigHandler(svc))
apiConfig.DELETE("/:provider", deleteApiConfigHandler(svc))
apiConfig.POST("/:provider/test", testApiConfigHandler(svc))
}
}
+257
View File
@@ -0,0 +1,257 @@
// Package handler — authenticated application routes.
package handler
import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.Container) {
// Authenticated endpoints.
authed := api.Group("/")
authed.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
authed.Use(activeUserRequired(svc))
{
authed.GET("/me", meHandler(svc))
authed.PATCH("/me", updateProfileHandler(svc))
authed.POST("/me/password", changePasswordHandler(svc))
authed.POST("/me/logout", logoutHandler(svc))
// Permissions.
authed.GET("/auth/permissions", getMyPermissionsHandler(svc))
// License activation bridge (admin only; talks to MediaStationLicenseServer).
authed.GET("/license/status", middleware.AdminRequired(), licenseStatusHandler(svc))
authed.POST("/license/activate", middleware.AdminRequired(), licenseActivateHandler(svc))
authed.POST("/license/heartbeat", middleware.AdminRequired(), licenseHeartbeatHandler(svc))
// Libraries.
authed.GET("/libraries", listLibrariesHandler(svc))
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
authed.POST("/libraries/:id/scan", middleware.AdminRequired(), scanLibraryHandler(svc))
authed.POST("/libraries/:id/scrape", middleware.AdminRequired(), scrapeLibraryHandler(svc))
authed.GET("/libraries/:id/media", listMediaHandler(svc))
authed.GET("/libraries/:id/seasons", listSeasonsHandler(svc))
// Media.
authed.GET("/media/:id", getMediaHandler(svc))
authed.GET("/media", searchMediaHandler(svc))
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
authed.POST("/media/:id/probe", middleware.AdminRequired(), reprobeHandler(svc))
authed.DELETE("/media/:id", middleware.AdminRequired(), deleteMediaHandler(svc))
authed.POST("/media/:id/restore", middleware.AdminRequired(), restoreMediaHandler(svc))
authed.DELETE("/media/:id/purge", middleware.AdminRequired(), purgeMediaHandler(svc))
authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
authed.GET("/subtitles/:id", serveSubtitleHandler(svc))
authed.POST("/media/:id/nfo", middleware.AdminRequired(), exportNFOHandler(svc))
authed.POST("/libraries/:id/nfo", middleware.AdminRequired(), exportLibraryNFOHandler(svc))
// Streaming.
authed.GET("/stream/:id", streamHandler(svc))
authed.HEAD("/stream/:id", streamHandler(svc))
authed.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc))
authed.GET("/hls/:id/:seg", hlsSegmentHandler(svc))
authed.DELETE("/hls/:id", stopTranscodeHandler(svc))
// Cloud-disk 302 playback redirect (resolves a fresh direct link).
authed.GET("/cloud/play/:type", cloudPlayHandler(svc))
authed.HEAD("/cloud/play/:type", cloudPlayHandler(svc))
// Image proxy (URL passed as ?url=...).
authed.GET("/img", imageProxyHandler(svc))
// History / favourites / playlists.
authed.GET("/history", recentHistoryHandler(svc))
authed.POST("/history", recordProgressHandler(svc))
authed.GET("/favourites", listFavouritesHandler(svc))
authed.POST("/favourites/:id", toggleFavouriteHandler(svc))
authed.GET("/playlists", listPlaylistsHandler(svc))
authed.POST("/playlists", createPlaylistHandler(svc))
authed.GET("/playlists/:id", getPlaylistHandler(svc))
authed.POST("/playlists/:id/items", addPlaylistItemHandler(svc))
authed.DELETE("/playlists/:id/items/:media_id", removePlaylistItemHandler(svc))
authed.DELETE("/playlists/:id", deletePlaylistHandler(svc))
// Downloads.
authed.GET("/downloads", requirePermission(svc, "can_manage_downloads"), listDownloadsHandler(svc))
authed.POST("/downloads", requirePermission(svc, "can_manage_downloads"), addDownloadHandler(svc))
authed.DELETE("/downloads/:hash", requirePermission(svc, "can_manage_downloads"), deleteDownloadHandler(svc))
authed.POST("/downloads/relocate", requirePermission(svc, "can_manage_downloads"), relocateDownloadHandler(svc))
authed.POST("/downloads/reload", requirePermission(svc, "can_manage_downloads"), reloadDownloadConfigHandler(svc))
// Subscriptions.
authed.GET("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionsHandler(svc))
authed.POST("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), createSubscriptionHandler(svc))
authed.DELETE("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), deleteSubscriptionHandler(svc))
authed.POST("/subscriptions/:id/run", requirePermission(svc, "can_manage_subscriptions"), runSubscriptionHandler(svc))
// Stats / dashboard.
authed.GET("/stats", statsHandler(svc))
authed.GET("/tasks", middleware.AdminRequired(), tasksHandler(svc))
// Discover (TMDb trending / popular).
authed.GET("/discover/trending", requirePermission(svc, "can_view_discover"), trendingHandler(svc))
authed.GET("/discover/popular", requirePermission(svc, "can_view_discover"), popularHandler(svc))
// AI.
authed.GET("/ai/status", requirePermission(svc, "can_use_ai"), aiStatusHandler(svc))
authed.POST("/ai/search", requirePermission(svc, "can_use_ai"), smartSearchHandler(svc))
authed.GET("/ai/recommend", requirePermission(svc, "can_use_ai"), aiRecommendHandler(svc))
// File browser (used by the library-path picker).
authed.GET("/files", middleware.AdminRequired(), browseFilesHandler(svc))
// Disk usage breakdown.
authed.GET("/storage", middleware.AdminRequired(), storageHandler(svc))
// DLNA discovery + cast.
authed.GET("/dlna/devices", dlnaListHandler(svc))
authed.POST("/dlna/cast", dlnaCastHandler(svc))
// STRM (URL-as-file).
authed.PUT("/media/:id/strm", middleware.AdminRequired(), setSTRMHandler(svc))
authed.DELETE("/media/:id/strm", middleware.AdminRequired(), clearSTRMHandler(svc))
authed.POST("/strm/import", middleware.AdminRequired(), importSTRMHandler(svc))
// Duplicate finder.
authed.GET("/duplicates", middleware.AdminRequired(), listDuplicatesHandler(svc))
authed.POST("/duplicates/scan", middleware.AdminRequired(), detectDuplicatesHandler(svc))
authed.POST("/duplicates/unmark", middleware.AdminRequired(), unmarkDuplicatesHandler(svc))
// Site management + cross-site torrent search (via SiteHandler).
siteHandler := NewSiteHandler(svc)
authed.GET("/sites", requirePermission(svc, "can_manage_sites"), siteHandler.ListSites)
authed.GET("/sites/types", requirePermission(svc, "can_manage_sites"), siteHandler.GetSiteTypes)
authed.GET("/sites/auth-types", requirePermission(svc, "can_manage_sites"), siteHandler.GetAuthTypes)
authed.POST("/sites", requirePermission(svc, "can_manage_sites"), siteHandler.CreateSite)
authed.GET("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.GetSite)
authed.PUT("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.UpdateSite)
authed.DELETE("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.DeleteSite)
authed.POST("/sites/:id/test", requirePermission(svc, "can_manage_sites"), siteHandler.TestSite)
authed.GET("/sites/search", requirePermission(svc, "can_manage_sites"), siteSearchHandler(svc))
// Recycle bin.
authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
authed.GET("/ws", wsHandler(svc))
// SSE event stream.
authed.GET("/events", sseHandler(svc))
// Scheduler.
authed.GET("/scheduler/tasks", schedulerListTasksHandler(svc))
authed.POST("/scheduler/tasks/:id/run", middleware.AdminRequired(), schedulerRunTaskHandler(svc))
authed.GET("/scheduler/status", schedulerGetStatusHandler(svc))
// ── Auxiliary endpoints used by the React UI rails ──
authed.GET("/media/recent", recentMediaHandler(svc))
authed.GET("/media/stats", mediaStatsHandler(svc))
// Watch history (extra surface beyond /history).
authed.GET("/watch-history", historyListHandler(svc))
authed.GET("/watch-history/stats", historyStatsHandler(svc))
authed.GET("/watch-history/continue", historyContinueHandler(svc))
authed.DELETE("/watch-history", historyDeleteHandler(svc))
authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc))
// Multi-section TMDb feed used by DiscoverPage.
authed.GET("/discover/sections", requirePermission(svc, "can_view_discover"), discoverSectionsHandler(svc))
authed.GET("/discover/feed", requirePermission(svc, "can_view_discover"), discoverFeedHandler(svc))
// System metadata + read-only scheduler view.
authed.GET("/system/info", systemInfoHandler(svc))
authed.GET("/system/status", systemStatusHandler(svc))
authed.GET("/system/scheduler", systemSchedulerHandler(svc))
// Richer dashboard rails.
authed.GET("/stats/overview", statsOverviewHandler(svc))
authed.GET("/stats/trend", statsTrendHandler(svc))
authed.GET("/stats/top-content", statsTopContentHandler(svc))
authed.GET("/stats/libraries", statsLibrariesHandler(svc))
authed.GET("/stats/monitor", statsMonitorHandler(svc))
// Multi-persona play profiles (caller-scoped).
authed.GET("/play-profiles", listPlayProfilesHandler(svc))
authed.POST("/play-profiles", createPlayProfileHandler(svc))
authed.PUT("/play-profiles/:id", updatePlayProfileHandler(svc))
authed.POST("/play-profiles/:id/verify-pin", verifyPlayProfilePINHandler(svc))
authed.DELETE("/play-profiles/:id", deletePlayProfileHandler(svc))
// ── Search aliases ──
authed.GET("/search", searchUnifiedHandler(svc))
authed.GET("/search/advanced", searchAdvancedHandler(svc))
authed.GET("/search/tmdb", searchTMDbHandler(svc))
authed.GET("/search/sites", searchSitesHandler(svc))
// ── System extras ──
authed.GET("/system/config", listSystemConfigHandler(svc))
authed.GET("/settings/schema", schemaHandler(svc))
authed.GET("/system/events/ticket", systemEventsTicketHandler(svc))
// ── Per-user stats ──
authed.GET("/stats/user/:id", statsUserHandler(svc))
authed.GET("/stats/top-users", statsTopUsersHandler(svc))
authed.POST("/stats/play", statsPlayHandler(svc))
// ── Sites extras ──
authed.GET("/sites/:id/resource", requirePermission(svc, "can_manage_sites"), siteResourceHandler(svc))
authed.GET("/sites/:id/userdata", requirePermission(svc, "can_manage_sites"), siteUserdataHandler(svc))
// ── Subscription extras ──
authed.PUT("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), updateSubscriptionHandler(svc))
authed.POST("/subscriptions/:id/search", requirePermission(svc, "can_manage_subscriptions"), searchSubscriptionHandler(svc))
// ── Playlist extras ──
authed.POST("/playlists/:id/reorder", reorderPlaylistHandler(svc))
authed.DELETE("/playlists/:id/items/by-id/:item_id", deletePlaylistItemByIDHandler(svc))
// ── DLNA per-renderer control ──
authed.POST("/dlna/:uuid/play", dlnaPlayHandler(svc))
authed.POST("/dlna/:uuid/pause", dlnaPauseHandler(svc))
authed.POST("/dlna/:uuid/stop", dlnaStopHandler(svc))
authed.GET("/dlna/:uuid/status", dlnaStatusHandler(svc))
// ── Media favourite alias surface ──
authed.GET("/favorites", listFavoritesAliasHandler(svc))
authed.POST("/media/:id/favorite", addMediaFavoriteHandler(svc))
authed.DELETE("/media/:id/favorite", removeMediaFavoriteHandler(svc))
authed.GET("/media/:id/favorite/status", getMediaFavoriteStatusHandler(svc))
authed.POST("/media/:id/ai-scrape", requirePermission(svc, "can_rescrape"), aiScrapeMediaHandler(svc))
authed.POST("/media/scrape/test", requirePermission(svc, "can_rescrape"), scrapeTestHandler(svc))
authed.POST("/media/organize", requirePermission(svc, "can_manage_files"), organizeBulkHandler(svc))
// ── Playback metadata + external player handoff ──
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
authed.GET("/playback/transcode/:job_id/status", transcodeStatusHandler(svc))
// ── Download task ops + sync triggers ──
authed.POST("/download/:id/pause", requirePermission(svc, "can_manage_downloads"), downloadPauseHandler(svc))
authed.POST("/download/:id/resume", requirePermission(svc, "can_manage_downloads"), downloadResumeHandler(svc))
authed.POST("/download/:id/organize", requirePermission(svc, "can_manage_files"), downloadOrganizeOneHandler(svc))
authed.POST("/download/organize", requirePermission(svc, "can_manage_files"), downloadOrganizeAllHandler(svc))
authed.POST("/download/sync", requirePermission(svc, "can_manage_downloads"), downloadSyncHandler(svc))
authed.POST("/download/start-auto-sync", requirePermission(svc, "can_manage_downloads"), downloadAutoSyncHandler(svc))
authed.GET("/download/tasks", requirePermission(svc, "can_manage_downloads"), downloadTasksAliasHandler(svc))
// ── Assistant (multi-turn AI chat) ──
authed.GET("/admin/assistant/sessions", listAssistantSessionsHandler(svc))
authed.POST("/admin/assistant/sessions", createAssistantSessionHandler(svc))
authed.GET("/admin/assistant/session/:id", getAssistantSessionHandler(svc))
authed.DELETE("/admin/assistant/session/:id", deleteAssistantSessionHandler(svc))
authed.POST("/admin/assistant/chat", assistantChatHandler(svc))
authed.POST("/admin/assistant/execute", assistantExecuteHandler(svc))
authed.POST("/admin/assistant/undo/:op_id", assistantUndoHandler(svc))
authed.GET("/admin/assistant/history", assistantHistoryHandler(svc))
}
}
+37
View File
@@ -0,0 +1,37 @@
// Package handler — public authentication routes.
package handler
import (
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func registerPublicAuthRoutes(api *gin.RouterGroup, svc *service.Container, log *zap.Logger) {
// Rate limiter for credential endpoints (login/register): brute-force
// protection. 30/min per IP tolerates many users behind a single NAT
// or reverse-proxy IP while still throttling password guessing.
authLimiter := middleware.NewRateLimiter(30, 1*time.Minute)
// Public auth.
auth := api.Group("/auth")
{
auth.POST("/login", middleware.RateLimit(authLimiter), loginHandler(svc))
auth.POST("/register", middleware.RateLimit(authLimiter), registerHandler(svc))
// /auth/refresh 用 RefreshHandler.RefreshToken:它从 body 读
// refresh_token 并签发新 access/refresh 对。旧的 refreshHandler
// 依赖 AuthRequired 中间件,永远 401,因此弃用。
//
// 刷新端点【不】做 IP 限流:刷新本身就是防止掉登录的机制,且已
// 由一次性轮换的 refresh token 强校验。若按 IP 限流,多个用户/
// 标签页共用一个反代 IP 时会把正常刷新打成 429,反而导致频繁
// 掉登录。
refreshHd := NewRefreshHandler(svc, log)
auth.POST("/refresh", refreshHd.RefreshToken)
}
}
-332
View File
@@ -839,338 +839,6 @@ func parseUNIT3DJSON(data []byte, siteName, baseURL string) (*SiteSearchResult,
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 {
if strings.TrimSpace(cfg.APIKey) == "" {
return fmt.Errorf("M-Team 需要填写 API Access Token(控制台 → 实验室 → 存取令牌),不能使用 Cookie 访问开放 API")
}
// 与 ShukeBta/MediaStation 参考实现对齐:
// 用 camelCase 参数(pageNumber / pageSize),同时接受 code 为字符串 "0"
// 或数值 0;兼容 M-Team v3 API 不同版本的返回。
u := cfg.URL + "/api/torrent/search"
payload := `{"pageNumber":1,"pageSize":1,"mode":"all"}`
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte(payload))
if err != nil {
return fmt.Errorf("authenticate: %w", err)
}
preview := string(data)
if len(preview) > 400 {
preview = preview[:400] + "..."
}
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return fmt.Errorf("authentication failed: status %d, body=%s", status, preview)
}
if status >= 300 && status < 400 {
return fmt.Errorf("authentication failed: HTTP %d (API Key 无效或未登录), body=%s", status, preview)
}
if status != http.StatusOK {
return fmt.Errorf("authenticate failed: status %d, body=%s", status, preview)
}
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("parse response: %w (body=%s)", err, preview)
}
if mteamCodeOK(resp["code"]) {
return nil
}
msg, _ := resp["message"].(string)
if msg == "" {
msg = fmt.Sprintf("code=%s", mteamCodeString(resp["code"]))
}
return fmt.Errorf("authentication failed: %s (body=%s)", msg, preview)
}
func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
// 与参考项目对齐:使用 camelCase 字段名,page 从 1 开始。
if page <= 0 {
page = 1
}
payload := map[string]interface{}{
"keyword": keyword,
"pageNumber": page,
"pageSize": 50,
}
body, _ := json.Marshal(payload)
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) (*SiteSearchResult, error) {
if page <= 0 {
page = 1
}
payload := map[string]interface{}{
"keyword": "",
"pageNumber": page,
"pageSize": 50,
}
if category != "" {
payload["categories"] = []string{category}
}
body, _ := json.Marshal(payload)
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=" + url.QueryEscape(id)
data, status, err := doRequestJSON(ctx, a.client, "POST", 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
}
// GetDownloadURL 解析 M-Team 种子的真实下载链接。
//
// M-Team v3 流程:
//
// POST /api/torrent/genDlToken?id={tid} (带 x-api-key)
// → {"code":"0","data":"https://api.m-team.cc/api/rss/dlv2?sign=..."}
//
// 拿到的 sign URL 可被任何下载客户端无认证地直接 GET。这是参考项目
// (ShukeBta/MediaStation) 的 _download_torrent_file 方法的子集。
func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
u := cfg.URL + "/api/torrent/genDlToken?id=" + id
// genDlToken 是 POST 但参数走 query string;body 留空。
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte("{}"))
if err != nil {
return "", fmt.Errorf("genDlToken: %w", err)
}
if status >= 300 {
return "", fmt.Errorf("genDlToken: HTTP %d", status)
}
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err != nil {
return "", fmt.Errorf("genDlToken parse: %w", err)
}
codeStr := ""
switch v := resp["code"].(type) {
case string:
codeStr = v
case float64:
codeStr = strconv.Itoa(int(v))
}
if codeStr != "0" && codeStr != "200" {
msg, _ := resp["message"].(string)
if msg == "" {
msg = "unknown error"
}
return "", fmt.Errorf("genDlToken: %s", msg)
}
dl, _ := resp["data"].(string)
if dl == "" {
return "", fmt.Errorf("genDlToken: empty data field")
}
return dl, nil
}
// parseMTeamJSON 解析 MTeam v3 JSON 响应。
//
// 响应结构(与 ShukeBta/MediaStation 参考项目一致):
//
// {
// "code": "0", // 字符串 "0" 表示成功
// "message": "SUCCESS",
// "data": {
// "total": "123",
// "data": [ ... ] // 旧字段名 "lists" 已被替换为 "data"
// }
// }
func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
// 用 map 反序列化以兼容 code/total 既可能是字符串又可能是数字。
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parse JSON: %w", err)
}
// code 兼容字符串与数字。
codeStr := ""
switch v := raw["code"].(type) {
case string:
codeStr = v
case float64:
codeStr = strconv.Itoa(int(v))
}
if codeStr != "" && codeStr != "0" && codeStr != "200" {
msg, _ := raw["message"].(string)
if msg == "" {
msg = fmt.Sprintf("code=%s", codeStr)
}
return nil, fmt.Errorf("mteam: %s", msg)
}
dataField, _ := raw["data"].(map[string]interface{})
if dataField == nil {
return &SiteSearchResult{SiteName: siteName, Items: []TorrentItem{}}, nil
}
// total 兼容字符串与数字。
total := 0
switch v := dataField["total"].(type) {
case string:
total, _ = strconv.Atoi(v)
case float64:
total = int(v)
}
// data.data(v3)优先;兜底兼容旧的 data.lists。
var rows []interface{}
switch v := dataField["data"].(type) {
case []interface{}:
rows = v
}
if rows == nil {
if v, ok := dataField["lists"].([]interface{}); ok {
rows = v
}
}
result := &SiteSearchResult{
SiteName: siteName,
Items: []TorrentItem{},
Total: total,
}
for _, rawT := range rows {
t, ok := rawT.(map[string]interface{})
if !ok {
continue
}
item := TorrentItem{}
if v, ok := t["id"].(string); ok {
item.ID = v
} else if v, ok := t["id"].(float64); ok {
item.ID = strconv.Itoa(int(v))
}
if v, ok := t["name"].(string); ok {
item.Title = v
}
if v, ok := t["subtitle"].(string); ok {
item.Subtitle = v
}
if v, ok := t["category"].(map[string]interface{}); ok {
if name, ok := v["name"].(string); ok {
item.Category = name
}
}
if v, ok := t["size"].(float64); ok {
item.Size = int64(v)
} else if v, ok := t["size"].(string); ok {
// v3 API 把 size 序列化成字符串。
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
item.Size = n
}
}
if v, ok := t["status"].(map[string]interface{}); ok {
if seeders, ok := v["seeders"].(float64); ok {
item.Seeders = int(seeders)
}
if leechers, ok := v["leechers"].(float64); ok {
item.Leechers = int(leechers)
}
if snatched, ok := v["completed"].(float64); ok {
item.Snatched = int(snatched)
}
}
if v, ok := t["free"].(bool); ok {
item.Free = v
}
if v, ok := t["uploadTime"].(float64); ok {
item.UploadTime = time.Unix(int64(v), 0)
}
item.DetailURL = baseURL + "/detail/" + item.ID
// 标记 download_url 指向 genDlToken;真正的下载链接由 handler 层
// 在用户点"下载"时通过 MTeamAdapter.GetDownloadURL 解析。
// 这样前端 SiteSearchPage 才知道这一行有可用的下载入口。
item.DownloadURL = baseURL + "/api/torrent/genDlToken?id=" + item.ID
result.Items = append(result.Items, item)
}
return result, nil
}
// ─── Discuz 适配器 ───────────────────────────────────────────────────────────
// DiscuzAdapter 基于 Discuz! X 的站点适配器。
+345
View File
@@ -0,0 +1,345 @@
// Package service — M-Team site adapter.
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// ─── MTeam 适配器 ────────────────────────────────────────────────────────────
// MTeamAdapter MTeam.cc 独立站适配器。
type MTeamAdapter struct {
client *http.Client
}
// NewMTeamAdapter 创建 MTeam 适配器。
func NewMTeamAdapter() *MTeamAdapter {
return &MTeamAdapter{
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
if strings.TrimSpace(cfg.APIKey) == "" {
return fmt.Errorf("M-Team 需要填写 API Access Token(控制台 → 实验室 → 存取令牌),不能使用 Cookie 访问开放 API")
}
// 与 ShukeBta/MediaStation 参考实现对齐:
// 用 camelCase 参数(pageNumber / pageSize),同时接受 code 为字符串 "0"
// 或数值 0;兼容 M-Team v3 API 不同版本的返回。
u := cfg.URL + "/api/torrent/search"
payload := `{"pageNumber":1,"pageSize":1,"mode":"all"}`
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte(payload))
if err != nil {
return fmt.Errorf("authenticate: %w", err)
}
preview := string(data)
if len(preview) > 400 {
preview = preview[:400] + "..."
}
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return fmt.Errorf("authentication failed: status %d, body=%s", status, preview)
}
if status >= 300 && status < 400 {
return fmt.Errorf("authentication failed: HTTP %d (API Key 无效或未登录), body=%s", status, preview)
}
if status != http.StatusOK {
return fmt.Errorf("authenticate failed: status %d, body=%s", status, preview)
}
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("parse response: %w (body=%s)", err, preview)
}
if mteamCodeOK(resp["code"]) {
return nil
}
msg, _ := resp["message"].(string)
if msg == "" {
msg = fmt.Sprintf("code=%s", mteamCodeString(resp["code"]))
}
return fmt.Errorf("authentication failed: %s (body=%s)", msg, preview)
}
func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
// 与参考项目对齐:使用 camelCase 字段名,page 从 1 开始。
if page <= 0 {
page = 1
}
payload := map[string]interface{}{
"keyword": keyword,
"pageNumber": page,
"pageSize": 50,
}
body, _ := json.Marshal(payload)
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) (*SiteSearchResult, error) {
if page <= 0 {
page = 1
}
payload := map[string]interface{}{
"keyword": "",
"pageNumber": page,
"pageSize": 50,
}
if category != "" {
payload["categories"] = []string{category}
}
body, _ := json.Marshal(payload)
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=" + url.QueryEscape(id)
data, status, err := doRequestJSON(ctx, a.client, "POST", 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
}
// GetDownloadURL 解析 M-Team 种子的真实下载链接。
//
// M-Team v3 流程:
//
// POST /api/torrent/genDlToken?id={tid} (带 x-api-key)
// → {"code":"0","data":"https://api.m-team.cc/api/rss/dlv2?sign=..."}
//
// 拿到的 sign URL 可被任何下载客户端无认证地直接 GET。这是参考项目
// (ShukeBta/MediaStation) 的 _download_torrent_file 方法的子集。
func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
u := cfg.URL + "/api/torrent/genDlToken?id=" + id
// genDlToken 是 POST 但参数走 query string;body 留空。
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte("{}"))
if err != nil {
return "", fmt.Errorf("genDlToken: %w", err)
}
if status >= 300 {
return "", fmt.Errorf("genDlToken: HTTP %d", status)
}
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err != nil {
return "", fmt.Errorf("genDlToken parse: %w", err)
}
codeStr := ""
switch v := resp["code"].(type) {
case string:
codeStr = v
case float64:
codeStr = strconv.Itoa(int(v))
}
if codeStr != "0" && codeStr != "200" {
msg, _ := resp["message"].(string)
if msg == "" {
msg = "unknown error"
}
return "", fmt.Errorf("genDlToken: %s", msg)
}
dl, _ := resp["data"].(string)
if dl == "" {
return "", fmt.Errorf("genDlToken: empty data field")
}
return dl, nil
}
// parseMTeamJSON 解析 MTeam v3 JSON 响应。
//
// 响应结构(与 ShukeBta/MediaStation 参考项目一致):
//
// {
// "code": "0", // 字符串 "0" 表示成功
// "message": "SUCCESS",
// "data": {
// "total": "123",
// "data": [ ... ] // 旧字段名 "lists" 已被替换为 "data"
// }
// }
func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
// 用 map 反序列化以兼容 code/total 既可能是字符串又可能是数字。
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parse JSON: %w", err)
}
// code 兼容字符串与数字。
codeStr := ""
switch v := raw["code"].(type) {
case string:
codeStr = v
case float64:
codeStr = strconv.Itoa(int(v))
}
if codeStr != "" && codeStr != "0" && codeStr != "200" {
msg, _ := raw["message"].(string)
if msg == "" {
msg = fmt.Sprintf("code=%s", codeStr)
}
return nil, fmt.Errorf("mteam: %s", msg)
}
dataField, _ := raw["data"].(map[string]interface{})
if dataField == nil {
return &SiteSearchResult{SiteName: siteName, Items: []TorrentItem{}}, nil
}
// total 兼容字符串与数字。
total := 0
switch v := dataField["total"].(type) {
case string:
total, _ = strconv.Atoi(v)
case float64:
total = int(v)
}
// data.data(v3)优先;兜底兼容旧的 data.lists。
var rows []interface{}
switch v := dataField["data"].(type) {
case []interface{}:
rows = v
}
if rows == nil {
if v, ok := dataField["lists"].([]interface{}); ok {
rows = v
}
}
result := &SiteSearchResult{
SiteName: siteName,
Items: []TorrentItem{},
Total: total,
}
for _, rawT := range rows {
t, ok := rawT.(map[string]interface{})
if !ok {
continue
}
item := TorrentItem{}
if v, ok := t["id"].(string); ok {
item.ID = v
} else if v, ok := t["id"].(float64); ok {
item.ID = strconv.Itoa(int(v))
}
if v, ok := t["name"].(string); ok {
item.Title = v
}
if v, ok := t["subtitle"].(string); ok {
item.Subtitle = v
}
if v, ok := t["category"].(map[string]interface{}); ok {
if name, ok := v["name"].(string); ok {
item.Category = name
}
}
if v, ok := t["size"].(float64); ok {
item.Size = int64(v)
} else if v, ok := t["size"].(string); ok {
// v3 API 把 size 序列化成字符串。
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
item.Size = n
}
}
if v, ok := t["status"].(map[string]interface{}); ok {
if seeders, ok := v["seeders"].(float64); ok {
item.Seeders = int(seeders)
}
if leechers, ok := v["leechers"].(float64); ok {
item.Leechers = int(leechers)
}
if snatched, ok := v["completed"].(float64); ok {
item.Snatched = int(snatched)
}
}
if v, ok := t["free"].(bool); ok {
item.Free = v
}
if v, ok := t["uploadTime"].(float64); ok {
item.UploadTime = time.Unix(int64(v), 0)
}
item.DetailURL = baseURL + "/detail/" + item.ID
// 标记 download_url 指向 genDlToken;真正的下载链接由 handler 层
// 在用户点"下载"时通过 MTeamAdapter.GetDownloadURL 解析。
// 这样前端 SiteSearchPage 才知道这一行有可用的下载入口。
item.DownloadURL = baseURL + "/api/torrent/genDlToken?id=" + item.ID
result.Items = append(result.Items, item)
}
return result, nil
}
-734
View File
@@ -14,13 +14,9 @@ import (
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"unicode"
"go.uber.org/zap"
@@ -29,21 +25,6 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
var (
seriesPackRE = regexp.MustCompile(`(?i)(complete|batch|合集|全集|全\s*\d+\s*[集话話期]|整季|全季|s\d{1,2}\s*(?:complete|batch|pack)|season\s*\d{1,2}\s*(?:complete|batch|pack)|s\d{1,2}e\d{1,3}\s*[-~–—]\s*(?:e)?\d{1,3}|第\s*\d+\s*[-~–—]\s*\d+\s*[集话話期])`)
seasonOnlyRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\s*\d{1,2}(?:[\s._-]|$)|第\s*\d+\s*季`)
)
type siteSearchCandidate struct {
Item SearchResult
Download string
GUID string
Season int
Episode int
Pack bool
Score int
}
// SubscriptionService runs the polling loop.
type SubscriptionService struct {
cfg *config.Config
@@ -395,418 +376,6 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
return 0, nil
}
func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, availability ...LocalAvailability) []siteSearchCandidate {
candidates := make([]siteSearchCandidate, 0, len(results))
for _, item := range results {
if !matchesSubscriptionRules(sub, item.Title) {
continue
}
download := strings.TrimSpace(item.DownloadURL)
if download == "" {
download = strings.TrimSpace(item.TorrentURL)
}
if download == "" {
continue
}
guid := stableSiteSearchGUID(item, download)
if _, ok := seenSet[guid]; ok {
continue
}
season, episode := ParseEpisode(item.Title)
score := subscriptionCandidateScore(sub, item)
candidates = append(candidates, siteSearchCandidate{
Item: item,
Download: download,
GUID: guid,
Season: season,
Episode: episode,
Pack: isSeriesPackTitle(item.Title),
Score: score,
})
}
if len(candidates) > 1 {
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].Score != candidates[j].Score {
return candidates[i].Score > candidates[j].Score
}
if candidates[i].Item.Seeders != candidates[j].Item.Seeders {
return candidates[i].Item.Seeders > candidates[j].Item.Seeders
}
return candidates[i].Item.Size > candidates[j].Item.Size
})
}
if len(candidates) == 0 {
return nil
}
var local LocalAvailability
if len(availability) > 0 {
local = availability[0]
}
mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "")
if !isSubscriptionSeriesType(mediaType) {
// 对齐 MoviePilot:非洗版订阅成功下载一次即满足,媒体库/下载中已存在则不再重复下载。
if (sub == nil || !sub.WashEnabled) && local.LocalMediaCount > 0 {
return nil
}
return candidates[:1]
}
if local.HasSeriesPack {
return nil
}
if local.LocalMediaCount > 0 {
if local.TotalEpisodes > 0 && len(local.MissingEpisodes) == 0 {
return nil
}
missingSet := missingEpisodeSet(local)
onlyMissing := make([]siteSearchCandidate, 0, len(candidates))
for _, candidate := range candidates {
if candidate.Episode <= 0 {
continue
}
season := candidate.Season
if season <= 0 {
season = 1
}
if _, exists := local.ExistingEpisodeKeys[episodeKey(season, candidate.Episode)]; exists {
continue
}
if local.TotalEpisodes > 0 {
if _, missing := missingSet[candidate.Episode]; !missing {
continue
}
}
onlyMissing = append(onlyMissing, candidate)
}
return sortedEpisodeCandidates(onlyMissing)
}
for _, candidate := range candidates {
if candidate.Pack {
return []siteSearchCandidate{candidate}
}
}
selected := sortedEpisodeCandidates(candidates)
if len(selected) == 0 {
return candidates[:1]
}
return selected
}
func stableRSSItemGUID(title, guid, link, enclosureURL string) string {
parts := []string{"rss", strings.ToLower(strings.TrimSpace(title))}
for _, raw := range []string{guid, enclosureURL, link} {
if key := stableDownloadURLKey(raw); key != "" {
parts = append(parts, key)
return strings.Join(parts, "|")
}
if raw = strings.TrimSpace(raw); raw != "" {
parts = append(parts, strings.ToLower(raw))
return strings.Join(parts, "|")
}
}
return strings.Join(parts, "|")
}
func stableSiteSearchGUID(item SearchResult, download string) string {
parts := []string{
"site",
strings.ToLower(strings.TrimSpace(firstNonEmpty(item.SiteID, item.SiteName))),
strings.ToLower(strings.TrimSpace(item.Category)),
strings.ToLower(strings.TrimSpace(item.Title)),
fmt.Sprintf("%d", item.Size),
}
if key := stableDownloadURLKey(download); key != "" {
parts = append(parts, key)
}
return strings.Join(parts, "|")
}
func stableDownloadURLKey(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil {
return strings.ToLower(raw)
}
if strings.EqualFold(u.Scheme, "magnet") {
xt := strings.ToLower(strings.TrimSpace(u.Query().Get("xt")))
if xt != "" {
return "magnet:" + xt
}
return strings.ToLower(raw)
}
if u.Host == "" {
return strings.ToLower(raw)
}
q := u.Query()
kept := make([]string, 0, 4)
for _, key := range []string{"id", "tid", "torrent", "torrent_id", "torrentid", "hash", "info_hash"} {
if value := strings.TrimSpace(q.Get(key)); value != "" {
kept = append(kept, key+"="+strings.ToLower(value))
}
}
base := strings.ToLower(strings.TrimRight(u.Host, "/") + "/" + strings.TrimLeft(u.Path, "/"))
if len(kept) > 0 {
return base + "?" + strings.Join(kept, "&")
}
return base
}
// defaultExcludeWords 是参考 MoviePilot 默认过滤的「垃圾版本」排除清单,对所有订阅生效,
// 与用户自定义排除词合并。拉丁词在 containsAnyExcludeToken 里按词边界匹配以避免子串误伤。
const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample"
func matchesSubscriptionRules(sub *model.Subscription, title string) bool {
titleFold := strings.ToLower(title)
if containsAnyExcludeToken(titleFold, defaultExcludeWords) {
return false
}
if sub == nil {
return true
}
if sub.ExcludeWords != "" && containsAnyExcludeToken(titleFold, sub.ExcludeWords) {
return false
}
if sub.ReleaseGroups != "" && !containsAnyToken(titleFold, sub.ReleaseGroups) {
return false
}
if sub.Resolution != "" && sub.Resolution != "best" && !titleMatchesResolution(titleFold, sub.Resolution) {
return false
}
if sub.Quality != "" && sub.Quality != "best" && !titleMatchesQuality(titleFold, sub.Quality) {
return false
}
if sub.Effects != "" && !containsAnyEffect(titleFold, sub.Effects) {
return false
}
return true
}
func subscriptionCandidateScore(sub *model.Subscription, item SearchResult) int {
title := strings.ToLower(item.Title)
score := item.Seeders
if sub == nil || !sub.WashEnabled {
if item.Free {
score += 25
}
return score
}
resolutionScore := detectResolutionScore(title)
qualityScore := detectQualityScore(title)
effectScore := detectEffectScore(title)
priority := "balanced"
if sub != nil && strings.TrimSpace(sub.WashPriority) != "" {
priority = strings.ToLower(strings.TrimSpace(sub.WashPriority))
}
switch priority {
case "resolution":
score += resolutionScore*1000 + qualityScore*100 + effectScore*50
case "quality":
score += qualityScore*1000 + resolutionScore*200 + effectScore*50
case "effects":
score += effectScore*1000 + resolutionScore*200 + qualityScore*100
case "seeders":
score += qualityScore*3 + resolutionScore*2 + effectScore
default:
score += resolutionScore*500 + qualityScore*300 + effectScore*150
}
if item.Free {
score += 25
}
return score
}
func containsAnyToken(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token != "" && strings.Contains(titleFold, token) {
return true
}
}
return false
}
// containsAnyExcludeToken 用于排除词匹配:纯 ASCII 字母数字的词按词边界匹配(避免 "ts"
// 误伤 "tsukihime"、"cam" 误伤 "camp" 之类的子串误判),含 CJK/符号的词仍按子串匹配。
func containsAnyExcludeToken(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token == "" {
continue
}
if isASCIIWordToken(token) {
if matchesWordBoundary(titleFold, token) {
return true
}
continue
}
if strings.Contains(titleFold, token) {
return true
}
}
return false
}
func isASCIIWordToken(token string) bool {
for _, r := range token {
if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r)) {
return false
}
}
return token != ""
}
// matchesWordBoundary 判断 token 是否作为独立词出现在 title 中,词边界为「非字母数字」。
func matchesWordBoundary(titleFold, token string) bool {
isWordRune := func(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
from := 0
for {
idx := strings.Index(titleFold[from:], token)
if idx < 0 {
return false
}
start := from + idx
end := start + len(token)
leftOK := start == 0 || !isWordRune(rune(titleFold[start-1]))
rightOK := end >= len(titleFold) || !isWordRune(rune(titleFold[end]))
if leftOK && rightOK {
return true
}
from = start + 1
if from >= len(titleFold) {
return false
}
}
}
func containsAnyEffect(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token == "" {
continue
}
switch token {
case "dolby-vision", "dolby vision", "dv":
if strings.Contains(titleFold, "dolby vision") || strings.Contains(titleFold, "dovi") || regexp.MustCompile(`\bdv\b`).MatchString(titleFold) {
return true
}
default:
if strings.Contains(titleFold, token) {
return true
}
}
}
return false
}
func titleMatchesResolution(titleFold, resolution string) bool {
switch strings.ToLower(strings.TrimSpace(resolution)) {
case "2160p", "4k", "uhd":
return strings.Contains(titleFold, "2160p") || strings.Contains(titleFold, "4k") || strings.Contains(titleFold, "uhd")
case "1080p":
return strings.Contains(titleFold, "1080p") || strings.Contains(titleFold, "fhd")
case "720p":
return strings.Contains(titleFold, "720p")
default:
return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(resolution)))
}
}
func titleMatchesQuality(titleFold, quality string) bool {
switch strings.ToLower(strings.TrimSpace(quality)) {
case "webdl", "web-dl":
return strings.Contains(titleFold, "web-dl") || strings.Contains(titleFold, "webdl")
case "bluray", "blu-ray":
return strings.Contains(titleFold, "bluray") || strings.Contains(titleFold, "blu-ray") || strings.Contains(titleFold, "bdrip")
case "remux":
return strings.Contains(titleFold, "remux")
case "hdtv":
return strings.Contains(titleFold, "hdtv")
default:
return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(quality)))
}
}
func detectResolutionScore(titleFold string) int {
switch {
case titleMatchesResolution(titleFold, "2160p"):
return 4
case titleMatchesResolution(titleFold, "1080p"):
return 3
case titleMatchesResolution(titleFold, "720p"):
return 2
default:
return 1
}
}
func detectQualityScore(titleFold string) int {
switch {
case titleMatchesQuality(titleFold, "remux"):
return 5
case titleMatchesQuality(titleFold, "bluray"):
return 4
case titleMatchesQuality(titleFold, "web-dl"):
return 3
case titleMatchesQuality(titleFold, "hdtv"):
return 2
default:
return 1
}
}
func detectEffectScore(titleFold string) int {
score := 0
if containsAnyEffect(titleFold, "dolby-vision") {
score += 4
}
if strings.Contains(titleFold, "hdr10+") {
score += 3
} else if strings.Contains(titleFold, "hdr") {
score += 2
}
if strings.Contains(titleFold, "atmos") {
score += 2
}
return score
}
func isSubscriptionSeriesType(mediaType string) bool {
switch normalizeMediaType(mediaType, "", "") {
case "tv", "anime", "variety":
return true
default:
return false
}
}
func isSeriesPackTitle(title string) bool {
title = strings.TrimSpace(title)
if title == "" {
return false
}
if seriesPackRE.MatchString(title) {
return true
}
_, episode := ParseEpisode(title)
return episode == 0 && seasonOnlyRE.MatchString(title)
}
func (s *SubscriptionService) shouldSkipExistingTorrent(ctx context.Context, mediaType string, candidate siteSearchCandidate) bool {
if s == nil || s.downloads == nil {
return false
@@ -817,309 +386,6 @@ func (s *SubscriptionService) shouldSkipExistingTorrent(ctx context.Context, med
return s.downloads.TorrentExistsByName(ctx, candidate.Item.Title)
}
func (s *SubscriptionService) pendingDownloadAvailability(ctx context.Context, sub *model.Subscription) LocalAvailability {
out := LocalAvailability{
ExistingEpisodeKeys: map[string]struct{}{},
MissingEpisodeKeys: map[string]struct{}{},
}
if sub != nil {
out.TotalEpisodes = sub.TotalEpisodes
}
query := availabilityQuery(subscriptionName(sub), subscriptionFilter(sub))
if query == "" {
return s.finalizePendingAvailability(sub, out)
}
root := s.subscriptionBaseSavePath(ctx, sub)
if root != "" {
_ = scanDownloadPath(ctx, root, query, func(_ string, season, episode int) bool {
out.LocalMediaCount++
out.InLibrary = true
if episode > 0 {
out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{}
}
return true
})
}
s.addDownloadTaskAvailability(ctx, sub, query, &out)
s.addLiveTorrentAvailability(ctx, query, &out)
return s.finalizePendingAvailability(sub, out)
}
func (s *SubscriptionService) addDownloadTaskAvailability(ctx context.Context, sub *model.Subscription, query string, out *LocalAvailability) {
if s == nil || s.repo == nil || s.repo.Download == nil || out == nil {
return
}
rows, err := s.repo.Download.List(ctx)
if err != nil {
return
}
baseSavePath := s.subscriptionBaseSavePath(ctx, sub)
for _, row := range rows {
if !downloadTaskBlocksReadd(row.Status) {
continue
}
if baseSavePath != "" && row.SavePath != "" && !sameOrChildPath(row.SavePath, baseSavePath) && !sameOrChildPath(baseSavePath, row.SavePath) {
continue
}
addAvailabilityTitle(row.Title, query, out)
}
}
func (s *SubscriptionService) addLiveTorrentAvailability(ctx context.Context, query string, out *LocalAvailability) {
if s == nil || s.downloads == nil || s.downloads.qb == nil || out == nil {
return
}
live, err := s.downloads.qb.List(ctx, "")
if err != nil {
return
}
for _, torrent := range live {
addAvailabilityTitle(torrent.Name, query, out)
}
}
func addAvailabilityTitle(title, query string, out *LocalAvailability) {
if out == nil || strings.TrimSpace(title) == "" || strings.TrimSpace(query) == "" {
return
}
if !strings.Contains(normalizeAvailabilityComparable(title), normalizeAvailabilityComparable(query)) {
return
}
out.LocalMediaCount++
out.InLibrary = true
season, episode := ParseEpisode(title)
if episode > 0 {
out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{}
return
}
if isSeriesPackTitle(title) {
out.HasSeriesPack = true
}
}
func sameOrChildPath(pathValue, root string) bool {
pathValue = filepath.Clean(strings.TrimSpace(pathValue))
root = filepath.Clean(strings.TrimSpace(root))
if pathValue == "" || root == "" || pathValue == "." || root == "." {
return false
}
if strings.EqualFold(pathValue, root) {
return true
}
rel, err := filepath.Rel(root, pathValue)
if err != nil {
return false
}
return rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel)
}
func (s *SubscriptionService) finalizePendingAvailability(sub *model.Subscription, out LocalAvailability) LocalAvailability {
mediaType := ""
if sub != nil {
mediaType = sub.MediaType
}
if isSubscriptionSeriesType(mediaType) || len(out.ExistingEpisodeKeys) > 0 {
out.DownloadedEpisodes = len(out.ExistingEpisodeKeys)
out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes)
for _, episode := range out.MissingEpisodes {
out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{}
}
} else if out.LocalMediaCount > 0 {
out.DownloadedEpisodes = 1
if out.TotalEpisodes == 0 {
out.TotalEpisodes = 1
}
}
return out
}
func (s *SubscriptionService) subscriptionBaseSavePath(ctx context.Context, sub *model.Subscription) string {
if sub == nil {
return ""
}
base := strings.TrimSpace(sub.SavePath)
if base == "" && s != nil && s.repo != nil && s.repo.Setting != nil {
base, _ = s.repo.Setting.Get(ctx, "qbittorrent.savepath")
}
return base
}
func subscriptionName(sub *model.Subscription) string {
if sub == nil {
return ""
}
return sub.Name
}
func subscriptionFilter(sub *model.Subscription) string {
if sub == nil {
return ""
}
return sub.Filter
}
func mergeLocalAvailability(values ...LocalAvailability) LocalAvailability {
out := LocalAvailability{
ExistingEpisodeKeys: map[string]struct{}{},
MissingEpisodeKeys: map[string]struct{}{},
}
for _, value := range values {
if out.TotalEpisodes == 0 {
out.TotalEpisodes = value.TotalEpisodes
}
out.LocalMediaCount += value.LocalMediaCount
out.InLibrary = out.InLibrary || value.InLibrary
out.HasSeriesPack = out.HasSeriesPack || value.HasSeriesPack
for key := range value.ExistingEpisodeKeys {
out.ExistingEpisodeKeys[key] = struct{}{}
}
}
out.DownloadedEpisodes = len(out.ExistingEpisodeKeys)
if out.TotalEpisodes > 0 {
out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes)
for _, episode := range out.MissingEpisodes {
out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{}
}
}
if out.DownloadedEpisodes == 0 && out.LocalMediaCount > 0 {
out.DownloadedEpisodes = out.LocalMediaCount
if out.TotalEpisodes == 0 {
out.TotalEpisodes = 1
}
}
return out
}
// subscriptionItemAlreadyAvailable 判断某个订阅条目(按其标题解析出的季/集)是否已在媒体库存在。
// 电影/无集号条目:媒体库已有该片即视为已存在;剧集条目:对应季集已入库即视为已存在。
func subscriptionItemAlreadyAvailable(sub *model.Subscription, avail LocalAvailability, title string) bool {
if avail.LocalMediaCount == 0 && !avail.HasSeriesPack {
return false
}
if !isSubscriptionSeriesType(subscriptionMediaType(sub)) {
return true
}
if avail.HasSeriesPack {
return true
}
wantSeason, wantEpisode := ParseEpisode(title)
if wantEpisode <= 0 {
// 整季合集 / 无法解析集号:库里已有内容时保守跳过,避免重复整季下载。
return true
}
if wantSeason <= 0 {
wantSeason = 1
}
_, exists := avail.ExistingEpisodeKeys[episodeKey(wantSeason, wantEpisode)]
return exists
}
func subscriptionMediaType(sub *model.Subscription) string {
if sub == nil {
return ""
}
return sub.MediaType
}
func (s *SubscriptionService) downloadPathHasCandidate(ctx context.Context, sub *model.Subscription, title, savePath string) bool {
savePath = strings.TrimSpace(savePath)
if savePath == "" {
savePath = s.subscriptionBaseSavePath(ctx, sub)
}
query := availabilityQuery(title, subscriptionFilter(sub))
if savePath == "" || query == "" {
return false
}
wantSeason, wantEpisode := ParseEpisode(title)
if wantSeason <= 0 {
wantSeason = 1
}
found := false
_ = scanDownloadPath(ctx, savePath, query, func(path string, season, episode int) bool {
if wantEpisode <= 0 {
found = true
return false
}
if episode <= 0 {
return true
}
if season <= 0 {
season = 1
}
if episodeKey(season, episode) == episodeKey(wantSeason, wantEpisode) {
found = true
return false
}
return true
})
return found
}
func scanDownloadPath(ctx context.Context, root, query string, visit func(path string, season, episode int) bool) error {
if strings.TrimSpace(root) == "" {
return nil
}
info, err := os.Stat(root)
if err != nil || !info.IsDir() {
return nil
}
normalizedQuery := normalizeAvailabilityComparable(query)
if normalizedQuery == "" {
return nil
}
visited := 0
return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if ctx.Err() != nil {
return ctx.Err()
}
if d.IsDir() {
if path != root && strings.HasPrefix(filepath.Base(path), ".") {
return filepath.SkipDir
}
return nil
}
if !isDownloadMediaPath(path) {
return nil
}
visited++
if visited > 10000 {
return filepath.SkipAll
}
if !strings.Contains(normalizeAvailabilityComparable(path), normalizedQuery) {
return nil
}
season, episode := ParseEpisode(path)
if !visit(path, season, episode) {
return filepath.SkipAll
}
return nil
})
}
func isDownloadMediaPath(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".!qb", ".part", ".aria2", ".crdownload":
path = strings.TrimSuffix(path, filepath.Ext(path))
ext = strings.ToLower(filepath.Ext(path))
}
_, ok := videoExtensions[ext]
return ok
}
func normalizeAvailabilityComparable(value string) string {
var b strings.Builder
for _, r := range strings.ToLower(value) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
func siteSearchKeyword(sub *model.Subscription) string {
if sub == nil {
return ""
@@ -0,0 +1,315 @@
// Package service — subscription local and pending-download availability helpers.
package service
import (
"context"
"os"
"path/filepath"
"strings"
"unicode"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func (s *SubscriptionService) pendingDownloadAvailability(ctx context.Context, sub *model.Subscription) LocalAvailability {
out := LocalAvailability{
ExistingEpisodeKeys: map[string]struct{}{},
MissingEpisodeKeys: map[string]struct{}{},
}
if sub != nil {
out.TotalEpisodes = sub.TotalEpisodes
}
query := availabilityQuery(subscriptionName(sub), subscriptionFilter(sub))
if query == "" {
return s.finalizePendingAvailability(sub, out)
}
root := s.subscriptionBaseSavePath(ctx, sub)
if root != "" {
_ = scanDownloadPath(ctx, root, query, func(_ string, season, episode int) bool {
out.LocalMediaCount++
out.InLibrary = true
if episode > 0 {
out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{}
}
return true
})
}
s.addDownloadTaskAvailability(ctx, sub, query, &out)
s.addLiveTorrentAvailability(ctx, query, &out)
return s.finalizePendingAvailability(sub, out)
}
func (s *SubscriptionService) addDownloadTaskAvailability(ctx context.Context, sub *model.Subscription, query string, out *LocalAvailability) {
if s == nil || s.repo == nil || s.repo.Download == nil || out == nil {
return
}
rows, err := s.repo.Download.List(ctx)
if err != nil {
return
}
baseSavePath := s.subscriptionBaseSavePath(ctx, sub)
for _, row := range rows {
if !downloadTaskBlocksReadd(row.Status) {
continue
}
if baseSavePath != "" && row.SavePath != "" && !sameOrChildPath(row.SavePath, baseSavePath) && !sameOrChildPath(baseSavePath, row.SavePath) {
continue
}
addAvailabilityTitle(row.Title, query, out)
}
}
func (s *SubscriptionService) addLiveTorrentAvailability(ctx context.Context, query string, out *LocalAvailability) {
if s == nil || s.downloads == nil || s.downloads.qb == nil || out == nil {
return
}
live, err := s.downloads.qb.List(ctx, "")
if err != nil {
return
}
for _, torrent := range live {
addAvailabilityTitle(torrent.Name, query, out)
}
}
func addAvailabilityTitle(title, query string, out *LocalAvailability) {
if out == nil || strings.TrimSpace(title) == "" || strings.TrimSpace(query) == "" {
return
}
if !strings.Contains(normalizeAvailabilityComparable(title), normalizeAvailabilityComparable(query)) {
return
}
out.LocalMediaCount++
out.InLibrary = true
season, episode := ParseEpisode(title)
if episode > 0 {
out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{}
return
}
if isSeriesPackTitle(title) {
out.HasSeriesPack = true
}
}
func sameOrChildPath(pathValue, root string) bool {
pathValue = filepath.Clean(strings.TrimSpace(pathValue))
root = filepath.Clean(strings.TrimSpace(root))
if pathValue == "" || root == "" || pathValue == "." || root == "." {
return false
}
if strings.EqualFold(pathValue, root) {
return true
}
rel, err := filepath.Rel(root, pathValue)
if err != nil {
return false
}
return rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel)
}
func (s *SubscriptionService) finalizePendingAvailability(sub *model.Subscription, out LocalAvailability) LocalAvailability {
mediaType := ""
if sub != nil {
mediaType = sub.MediaType
}
if isSubscriptionSeriesType(mediaType) || len(out.ExistingEpisodeKeys) > 0 {
out.DownloadedEpisodes = len(out.ExistingEpisodeKeys)
out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes)
for _, episode := range out.MissingEpisodes {
out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{}
}
} else if out.LocalMediaCount > 0 {
out.DownloadedEpisodes = 1
if out.TotalEpisodes == 0 {
out.TotalEpisodes = 1
}
}
return out
}
func (s *SubscriptionService) subscriptionBaseSavePath(ctx context.Context, sub *model.Subscription) string {
if sub == nil {
return ""
}
base := strings.TrimSpace(sub.SavePath)
if base == "" && s != nil && s.repo != nil && s.repo.Setting != nil {
base, _ = s.repo.Setting.Get(ctx, "qbittorrent.savepath")
}
return base
}
func subscriptionName(sub *model.Subscription) string {
if sub == nil {
return ""
}
return sub.Name
}
func subscriptionFilter(sub *model.Subscription) string {
if sub == nil {
return ""
}
return sub.Filter
}
func mergeLocalAvailability(values ...LocalAvailability) LocalAvailability {
out := LocalAvailability{
ExistingEpisodeKeys: map[string]struct{}{},
MissingEpisodeKeys: map[string]struct{}{},
}
for _, value := range values {
if out.TotalEpisodes == 0 {
out.TotalEpisodes = value.TotalEpisodes
}
out.LocalMediaCount += value.LocalMediaCount
out.InLibrary = out.InLibrary || value.InLibrary
out.HasSeriesPack = out.HasSeriesPack || value.HasSeriesPack
for key := range value.ExistingEpisodeKeys {
out.ExistingEpisodeKeys[key] = struct{}{}
}
}
out.DownloadedEpisodes = len(out.ExistingEpisodeKeys)
if out.TotalEpisodes > 0 {
out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes)
for _, episode := range out.MissingEpisodes {
out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{}
}
}
if out.DownloadedEpisodes == 0 && out.LocalMediaCount > 0 {
out.DownloadedEpisodes = out.LocalMediaCount
if out.TotalEpisodes == 0 {
out.TotalEpisodes = 1
}
}
return out
}
// subscriptionItemAlreadyAvailable 判断某个订阅条目(按其标题解析出的季/集)是否已在媒体库存在。
// 电影/无集号条目:媒体库已有该片即视为已存在;剧集条目:对应季集已入库即视为已存在。
func subscriptionItemAlreadyAvailable(sub *model.Subscription, avail LocalAvailability, title string) bool {
if avail.LocalMediaCount == 0 && !avail.HasSeriesPack {
return false
}
if !isSubscriptionSeriesType(subscriptionMediaType(sub)) {
return true
}
if avail.HasSeriesPack {
return true
}
wantSeason, wantEpisode := ParseEpisode(title)
if wantEpisode <= 0 {
// 整季合集 / 无法解析集号:库里已有内容时保守跳过,避免重复整季下载。
return true
}
if wantSeason <= 0 {
wantSeason = 1
}
_, exists := avail.ExistingEpisodeKeys[episodeKey(wantSeason, wantEpisode)]
return exists
}
func subscriptionMediaType(sub *model.Subscription) string {
if sub == nil {
return ""
}
return sub.MediaType
}
func (s *SubscriptionService) downloadPathHasCandidate(ctx context.Context, sub *model.Subscription, title, savePath string) bool {
savePath = strings.TrimSpace(savePath)
if savePath == "" {
savePath = s.subscriptionBaseSavePath(ctx, sub)
}
query := availabilityQuery(title, subscriptionFilter(sub))
if savePath == "" || query == "" {
return false
}
wantSeason, wantEpisode := ParseEpisode(title)
if wantSeason <= 0 {
wantSeason = 1
}
found := false
_ = scanDownloadPath(ctx, savePath, query, func(path string, season, episode int) bool {
if wantEpisode <= 0 {
found = true
return false
}
if episode <= 0 {
return true
}
if season <= 0 {
season = 1
}
if episodeKey(season, episode) == episodeKey(wantSeason, wantEpisode) {
found = true
return false
}
return true
})
return found
}
func scanDownloadPath(ctx context.Context, root, query string, visit func(path string, season, episode int) bool) error {
if strings.TrimSpace(root) == "" {
return nil
}
info, err := os.Stat(root)
if err != nil || !info.IsDir() {
return nil
}
normalizedQuery := normalizeAvailabilityComparable(query)
if normalizedQuery == "" {
return nil
}
visited := 0
return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if ctx.Err() != nil {
return ctx.Err()
}
if d.IsDir() {
if path != root && strings.HasPrefix(filepath.Base(path), ".") {
return filepath.SkipDir
}
return nil
}
if !isDownloadMediaPath(path) {
return nil
}
visited++
if visited > 10000 {
return filepath.SkipAll
}
if !strings.Contains(normalizeAvailabilityComparable(path), normalizedQuery) {
return nil
}
season, episode := ParseEpisode(path)
if !visit(path, season, episode) {
return filepath.SkipAll
}
return nil
})
}
func isDownloadMediaPath(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".!qb", ".part", ".aria2", ".crdownload":
path = strings.TrimSuffix(path, filepath.Ext(path))
ext = strings.ToLower(filepath.Ext(path))
}
_, ok := videoExtensions[ext]
return ok
}
func normalizeAvailabilityComparable(value string) string {
var b strings.Builder
for _, r := range strings.ToLower(value) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
+457
View File
@@ -0,0 +1,457 @@
// Package service — subscription planning and release candidate selection.
package service
import (
"fmt"
"net/url"
"regexp"
"sort"
"strings"
"unicode"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
var (
seriesPackRE = regexp.MustCompile(`(?i)(complete|batch|合集|全集|全\s*\d+\s*[集话話期]|整季|全季|s\d{1,2}\s*(?:complete|batch|pack)|season\s*\d{1,2}\s*(?:complete|batch|pack)|s\d{1,2}e\d{1,3}\s*[-~–—]\s*(?:e)?\d{1,3}|第\s*\d+\s*[-~–—]\s*\d+\s*[集话話期])`)
seasonOnlyRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\s*\d{1,2}(?:[\s._-]|$)|第\s*\d+\s*季`)
)
type siteSearchCandidate struct {
Item SearchResult
Download string
GUID string
Season int
Episode int
Pack bool
Score int
}
// SubscriptionPlanner owns release selection decisions for subscriptions:
// rule matching, candidate scoring, and filtering against known availability.
type SubscriptionPlanner struct{}
func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, availability ...LocalAvailability) []siteSearchCandidate {
return SubscriptionPlanner{}.SelectSiteSearchCandidates(results, sub, seenSet, availability...)
}
func (SubscriptionPlanner) SelectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, availability ...LocalAvailability) []siteSearchCandidate {
if sub == nil {
return nil
}
if seenSet == nil {
seenSet = map[string]struct{}{}
}
local := LocalAvailability{}
if len(availability) > 0 {
local = availability[0]
}
return selectSiteSearchCandidatesWithAvailability(results, sub, seenSet, local)
}
func selectSiteSearchCandidatesWithAvailability(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, local LocalAvailability) []siteSearchCandidate {
candidates := make([]siteSearchCandidate, 0, len(results))
for _, item := range results {
if !matchesSubscriptionRules(sub, item.Title) {
continue
}
download := strings.TrimSpace(item.DownloadURL)
if download == "" {
download = strings.TrimSpace(item.TorrentURL)
}
if download == "" {
continue
}
guid := stableSiteSearchGUID(item, download)
if _, ok := seenSet[guid]; ok {
continue
}
season, episode := ParseEpisode(item.Title)
score := subscriptionCandidateScore(sub, item)
candidates = append(candidates, siteSearchCandidate{
Item: item,
Download: download,
GUID: guid,
Season: season,
Episode: episode,
Pack: isSeriesPackTitle(item.Title),
Score: score,
})
}
if len(candidates) > 1 {
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].Score != candidates[j].Score {
return candidates[i].Score > candidates[j].Score
}
if candidates[i].Item.Seeders != candidates[j].Item.Seeders {
return candidates[i].Item.Seeders > candidates[j].Item.Seeders
}
return candidates[i].Item.Size > candidates[j].Item.Size
})
}
if len(candidates) == 0 {
return nil
}
mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "")
if !isSubscriptionSeriesType(mediaType) {
// 对齐 MoviePilot:非洗版订阅成功下载一次即满足,媒体库/下载中已存在则不再重复下载。
if (sub == nil || !sub.WashEnabled) && local.LocalMediaCount > 0 {
return nil
}
return candidates[:1]
}
if local.HasSeriesPack {
return nil
}
if local.LocalMediaCount > 0 {
if local.TotalEpisodes > 0 && len(local.MissingEpisodes) == 0 {
return nil
}
missingSet := missingEpisodeSet(local)
onlyMissing := make([]siteSearchCandidate, 0, len(candidates))
for _, candidate := range candidates {
if candidate.Episode <= 0 {
continue
}
season := candidate.Season
if season <= 0 {
season = 1
}
if _, exists := local.ExistingEpisodeKeys[episodeKey(season, candidate.Episode)]; exists {
continue
}
if local.TotalEpisodes > 0 {
if _, missing := missingSet[candidate.Episode]; !missing {
continue
}
}
onlyMissing = append(onlyMissing, candidate)
}
return sortedEpisodeCandidates(onlyMissing)
}
for _, candidate := range candidates {
if candidate.Pack {
return []siteSearchCandidate{candidate}
}
}
selected := sortedEpisodeCandidates(candidates)
if len(selected) == 0 {
return candidates[:1]
}
return selected
}
func stableRSSItemGUID(title, guid, link, enclosureURL string) string {
parts := []string{"rss", strings.ToLower(strings.TrimSpace(title))}
for _, raw := range []string{guid, enclosureURL, link} {
if key := stableDownloadURLKey(raw); key != "" {
parts = append(parts, key)
return strings.Join(parts, "|")
}
if raw = strings.TrimSpace(raw); raw != "" {
parts = append(parts, strings.ToLower(raw))
return strings.Join(parts, "|")
}
}
return strings.Join(parts, "|")
}
func stableSiteSearchGUID(item SearchResult, download string) string {
parts := []string{
"site",
strings.ToLower(strings.TrimSpace(firstNonEmpty(item.SiteID, item.SiteName))),
strings.ToLower(strings.TrimSpace(item.Category)),
strings.ToLower(strings.TrimSpace(item.Title)),
fmt.Sprintf("%d", item.Size),
}
if key := stableDownloadURLKey(download); key != "" {
parts = append(parts, key)
}
return strings.Join(parts, "|")
}
func stableDownloadURLKey(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil {
return strings.ToLower(raw)
}
if strings.EqualFold(u.Scheme, "magnet") {
xt := strings.ToLower(strings.TrimSpace(u.Query().Get("xt")))
if xt != "" {
return "magnet:" + xt
}
return strings.ToLower(raw)
}
if u.Host == "" {
return strings.ToLower(raw)
}
q := u.Query()
kept := make([]string, 0, 4)
for _, key := range []string{"id", "tid", "torrent", "torrent_id", "torrentid", "hash", "info_hash"} {
if value := strings.TrimSpace(q.Get(key)); value != "" {
kept = append(kept, key+"="+strings.ToLower(value))
}
}
base := strings.ToLower(strings.TrimRight(u.Host, "/") + "/" + strings.TrimLeft(u.Path, "/"))
if len(kept) > 0 {
return base + "?" + strings.Join(kept, "&")
}
return base
}
// defaultExcludeWords 是参考 MoviePilot 默认过滤的「垃圾版本」排除清单,对所有订阅生效,
// 与用户自定义排除词合并。拉丁词在 containsAnyExcludeToken 里按词边界匹配以避免子串误伤。
const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample"
func matchesSubscriptionRules(sub *model.Subscription, title string) bool {
titleFold := strings.ToLower(title)
if containsAnyExcludeToken(titleFold, defaultExcludeWords) {
return false
}
if sub == nil {
return true
}
if sub.ExcludeWords != "" && containsAnyExcludeToken(titleFold, sub.ExcludeWords) {
return false
}
if sub.ReleaseGroups != "" && !containsAnyToken(titleFold, sub.ReleaseGroups) {
return false
}
if sub.Resolution != "" && sub.Resolution != "best" && !titleMatchesResolution(titleFold, sub.Resolution) {
return false
}
if sub.Quality != "" && sub.Quality != "best" && !titleMatchesQuality(titleFold, sub.Quality) {
return false
}
if sub.Effects != "" && !containsAnyEffect(titleFold, sub.Effects) {
return false
}
return true
}
func subscriptionCandidateScore(sub *model.Subscription, item SearchResult) int {
title := strings.ToLower(item.Title)
score := item.Seeders
if sub == nil || !sub.WashEnabled {
if item.Free {
score += 25
}
return score
}
resolutionScore := detectResolutionScore(title)
qualityScore := detectQualityScore(title)
effectScore := detectEffectScore(title)
priority := "balanced"
if sub != nil && strings.TrimSpace(sub.WashPriority) != "" {
priority = strings.ToLower(strings.TrimSpace(sub.WashPriority))
}
switch priority {
case "resolution":
score += resolutionScore*1000 + qualityScore*100 + effectScore*50
case "quality":
score += qualityScore*1000 + resolutionScore*200 + effectScore*50
case "effects":
score += effectScore*1000 + resolutionScore*200 + qualityScore*100
case "seeders":
score += qualityScore*3 + resolutionScore*2 + effectScore
default:
score += resolutionScore*500 + qualityScore*300 + effectScore*150
}
if item.Free {
score += 25
}
return score
}
func containsAnyToken(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token != "" && strings.Contains(titleFold, token) {
return true
}
}
return false
}
// containsAnyExcludeToken 用于排除词匹配:纯 ASCII 字母数字的词按词边界匹配(避免 "ts"
// 误伤 "tsukihime"、"cam" 误伤 "camp" 之类的子串误判),含 CJK/符号的词仍按子串匹配。
func containsAnyExcludeToken(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token == "" {
continue
}
if isASCIIWordToken(token) {
if matchesWordBoundary(titleFold, token) {
return true
}
continue
}
if strings.Contains(titleFold, token) {
return true
}
}
return false
}
func isASCIIWordToken(token string) bool {
for _, r := range token {
if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r)) {
return false
}
}
return token != ""
}
// matchesWordBoundary 判断 token 是否作为独立词出现在 title 中,词边界为「非字母数字」。
func matchesWordBoundary(titleFold, token string) bool {
isWordRune := func(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
from := 0
for {
idx := strings.Index(titleFold[from:], token)
if idx < 0 {
return false
}
start := from + idx
end := start + len(token)
leftOK := start == 0 || !isWordRune(rune(titleFold[start-1]))
rightOK := end >= len(titleFold) || !isWordRune(rune(titleFold[end]))
if leftOK && rightOK {
return true
}
from = start + 1
if from >= len(titleFold) {
return false
}
}
}
func containsAnyEffect(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token == "" {
continue
}
switch token {
case "dolby-vision", "dolby vision", "dv":
if strings.Contains(titleFold, "dolby vision") || strings.Contains(titleFold, "dovi") || regexp.MustCompile(`\bdv\b`).MatchString(titleFold) {
return true
}
default:
if strings.Contains(titleFold, token) {
return true
}
}
}
return false
}
func titleMatchesResolution(titleFold, resolution string) bool {
switch strings.ToLower(strings.TrimSpace(resolution)) {
case "2160p", "4k", "uhd":
return strings.Contains(titleFold, "2160p") || strings.Contains(titleFold, "4k") || strings.Contains(titleFold, "uhd")
case "1080p":
return strings.Contains(titleFold, "1080p") || strings.Contains(titleFold, "fhd")
case "720p":
return strings.Contains(titleFold, "720p")
default:
return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(resolution)))
}
}
func titleMatchesQuality(titleFold, quality string) bool {
switch strings.ToLower(strings.TrimSpace(quality)) {
case "webdl", "web-dl":
return strings.Contains(titleFold, "web-dl") || strings.Contains(titleFold, "webdl")
case "bluray", "blu-ray":
return strings.Contains(titleFold, "bluray") || strings.Contains(titleFold, "blu-ray") || strings.Contains(titleFold, "bdrip")
case "remux":
return strings.Contains(titleFold, "remux")
case "hdtv":
return strings.Contains(titleFold, "hdtv")
default:
return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(quality)))
}
}
func detectResolutionScore(titleFold string) int {
switch {
case titleMatchesResolution(titleFold, "2160p"):
return 4
case titleMatchesResolution(titleFold, "1080p"):
return 3
case titleMatchesResolution(titleFold, "720p"):
return 2
default:
return 1
}
}
func detectQualityScore(titleFold string) int {
switch {
case titleMatchesQuality(titleFold, "remux"):
return 5
case titleMatchesQuality(titleFold, "bluray"):
return 4
case titleMatchesQuality(titleFold, "web-dl"):
return 3
case titleMatchesQuality(titleFold, "hdtv"):
return 2
default:
return 1
}
}
func detectEffectScore(titleFold string) int {
score := 0
if containsAnyEffect(titleFold, "dolby-vision") {
score += 4
}
if strings.Contains(titleFold, "hdr10+") {
score += 3
} else if strings.Contains(titleFold, "hdr") {
score += 2
}
if strings.Contains(titleFold, "atmos") {
score += 2
}
return score
}
func isSubscriptionSeriesType(mediaType string) bool {
switch normalizeMediaType(mediaType, "", "") {
case "tv", "anime", "variety":
return true
default:
return false
}
}
func isSeriesPackTitle(title string) bool {
title = strings.TrimSpace(title)
if title == "" {
return false
}
if seriesPackRE.MatchString(title) {
return true
}
_, episode := ParseEpisode(title)
return episode == 0 && seasonOnlyRE.MatchString(title)
}
-153
View File
@@ -225,144 +225,6 @@ func (s *TelegramBotService) HandleWebhook(ctx context.Context, body []byte) err
return nil
}
// executeCommand 解析命令并执行。
func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, text string) (telegramCommandReply, error) {
parts := strings.Fields(text)
if len(parts) == 0 {
return telegramCommandReply{}, nil
}
cmd := telegramCommandName(parts[0])
args := parts[1:]
if msg.Chat.Type != "" && msg.Chat.Type != "private" && !s.telegramChatAllowed(channel, msg.Chat.ID) {
return telegramCommandReply{Text: "此群组/频道未绑定到 Bot 管理入口,请在通知渠道里填写「绑定群组 ID」或「绑定频道 ID」。"}, nil
}
switch cmd {
case "/start":
if len(args) == 0 {
return s.mainMenu(ctx, channel, msg), nil
}
return s.cmdStart(ctx, msg, args), nil
case "/menu":
return s.mainMenu(ctx, channel, msg), nil
case "/cancel":
s.takePending(int64(msg.From.ID))
return telegramCommandReply{Text: "已取消当前操作。"}, nil
case "/help":
return telegramCommandReply{Text: s.cmdHelp(ctx, msg)}, nil
case "/hideadult", "/hide_adult", "/adult":
return s.cmdHideAdult(ctx, msg, args), nil
case "/account", "/me":
return s.replyAccount(ctx, msg), nil
case "/signin", "/checkin":
return s.replySignIn(ctx, msg), nil
case "/devices":
return s.replyDevices(ctx, msg), nil
case "/kick":
return s.cmdKick(ctx, msg, args), nil
case "/setname", "/rename":
return s.cmdSetName(ctx, msg, args), nil
case "/setpass", "/passwd", "/password":
return s.cmdSetPass(ctx, msg, args), nil
case "/redeem":
return s.cmdRedeem(ctx, channel, msg, args), nil
case "/redeem_register":
return s.cmdRedeemRegister(ctx, channel, msg, args), nil
case "/redeem_renew":
return s.cmdRedeemRenew(ctx, msg, args), nil
case "/register", "/reg", "/signup":
return s.cmdRegister(ctx, channel, msg, args), nil
case "/registration", "/reg_switch", "/openreg":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdRegistrationToggle(ctx, args), nil
case "/capacity":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.replyCapacity(ctx), nil
case "/users":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.replyUserList(ctx), nil
case "/gencode":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdGenCode(ctx, msg, args), nil
case "/renew_user":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdUserRenew(ctx, args), nil
case "/delete_user":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdUserDelete(ctx, args), nil
case "/devicepolicy", "/policy":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdDevicePolicy(ctx, args), nil
case "/antishare":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdAntiShare(ctx, args), nil
case "/cleanup":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdCleanup(ctx, args), nil
case "/cleanup_mode":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdCleanupMode(ctx, args), nil
case "/cleanup_rule":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdCleanupRule(ctx, args), nil
case "/ban":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdUserBan(ctx, args, false), nil
case "/unban":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdUserBan(ctx, args, true), nil
case "/status":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。普通用户只能使用 /start 绑定账号,并通过按钮隐藏成人目录。"}, nil
}
return s.cmdStatus(ctx)
case "/search":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdSearch(ctx, args)
case "/downloads":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdDownloads(ctx)
case "/stats":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdStats(ctx)
default:
return telegramCommandReply{Text: fmt.Sprintf("未知命令: %s\n\n输入 /help 查看可用命令列表。", cmd)}, nil
}
}
func telegramIsCommandText(text string) bool {
return strings.HasPrefix(strings.TrimSpace(text), "/") && telegramCommandName(text) != ""
}
@@ -382,21 +244,6 @@ func telegramCommandName(text string) string {
return cmd
}
func telegramSupportedCommand(cmd string) bool {
switch cmd {
case "/start", "/menu", "/cancel", "/help", "/hideadult", "/hide_adult", "/adult",
"/account", "/me", "/signin", "/checkin", "/devices", "/kick", "/setname", "/rename", "/setpass", "/passwd", "/password",
"/redeem", "/redeem_register", "/redeem_renew",
"/register", "/reg", "/signup", "/registration", "/reg_switch", "/openreg",
"/capacity", "/users", "/gencode", "/renew_user", "/delete_user",
"/devicepolicy", "/policy", "/antishare", "/cleanup", "/cleanup_mode", "/cleanup_rule",
"/ban", "/unban", "/status", "/search", "/downloads", "/stats":
return true
default:
return false
}
}
// cmdStart 处理 /start 命令。
func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
name := msg.From.FirstName
+124
View File
@@ -0,0 +1,124 @@
// Package service — Telegram command registry and dispatch.
package service
import (
"context"
"fmt"
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
type telegramCommandHandler func(args []string) (telegramCommandReply, error)
type telegramCommandDefinition struct {
Aliases []string
AdminOnly bool
AdminOnlyText string
Handle telegramCommandHandler
}
func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) []telegramCommandDefinition {
adminOnly := "此命令仅管理员可用。"
return []telegramCommandDefinition{
{Aliases: []string{"/start"}, Handle: func(args []string) (telegramCommandReply, error) {
if len(args) == 0 {
return s.mainMenu(ctx, channel, msg), nil
}
return s.cmdStart(ctx, msg, args), nil
}},
{Aliases: []string{"/menu"}, Handle: func(args []string) (telegramCommandReply, error) { return s.mainMenu(ctx, channel, msg), nil }},
{Aliases: []string{"/cancel"}, Handle: func(args []string) (telegramCommandReply, error) {
s.takePending(int64(msg.From.ID))
return telegramCommandReply{Text: "已取消当前操作。"}, nil
}},
{Aliases: []string{"/help"}, Handle: func(args []string) (telegramCommandReply, error) {
return telegramCommandReply{Text: s.cmdHelp(ctx, msg)}, nil
}},
{Aliases: []string{"/hideadult", "/hide_adult", "/adult"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdHideAdult(ctx, msg, args), nil }},
{Aliases: []string{"/account", "/me"}, Handle: func(args []string) (telegramCommandReply, error) { return s.replyAccount(ctx, msg), nil }},
{Aliases: []string{"/signin", "/checkin"}, Handle: func(args []string) (telegramCommandReply, error) { return s.replySignIn(ctx, msg), nil }},
{Aliases: []string{"/devices"}, Handle: func(args []string) (telegramCommandReply, error) { return s.replyDevices(ctx, msg), nil }},
{Aliases: []string{"/kick"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdKick(ctx, msg, args), nil }},
{Aliases: []string{"/setname", "/rename"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSetName(ctx, msg, args), nil }},
{Aliases: []string{"/setpass", "/passwd", "/password"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSetPass(ctx, msg, args), nil }},
{Aliases: []string{"/redeem"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRedeem(ctx, channel, msg, args), nil }},
{Aliases: []string{"/redeem_register"}, Handle: func(args []string) (telegramCommandReply, error) {
return s.cmdRedeemRegister(ctx, channel, msg, args), nil
}},
{Aliases: []string{"/redeem_renew"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRedeemRenew(ctx, msg, args), nil }},
{Aliases: []string{"/register", "/reg", "/signup"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRegister(ctx, channel, msg, args), nil }},
{Aliases: []string{"/registration", "/reg_switch", "/openreg"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRegistrationToggle(ctx, args), nil }},
{Aliases: []string{"/capacity"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.replyCapacity(ctx), nil }},
{Aliases: []string{"/users"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.replyUserList(ctx), nil }},
{Aliases: []string{"/gencode"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdGenCode(ctx, msg, args), nil }},
{Aliases: []string{"/renew_user"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserRenew(ctx, args), nil }},
{Aliases: []string{"/delete_user"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserDelete(ctx, args), nil }},
{Aliases: []string{"/devicepolicy", "/policy"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdDevicePolicy(ctx, args), nil }},
{Aliases: []string{"/antishare"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdAntiShare(ctx, args), nil }},
{Aliases: []string{"/cleanup"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanup(ctx, args), nil }},
{Aliases: []string{"/cleanup_mode"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanupMode(ctx, args), nil }},
{Aliases: []string{"/cleanup_rule"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanupRule(ctx, args), nil }},
{Aliases: []string{"/ban"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserBan(ctx, args, false), nil }},
{Aliases: []string{"/unban"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserBan(ctx, args, true), nil }},
{Aliases: []string{"/status"}, AdminOnly: true, AdminOnlyText: "此命令仅管理员可用。普通用户只能使用 /start 绑定账号,并通过按钮隐藏成人目录。", Handle: func(args []string) (telegramCommandReply, error) { return s.cmdStatus(ctx) }},
{Aliases: []string{"/search"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSearch(ctx, args) }},
{Aliases: []string{"/downloads"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdDownloads(ctx) }},
{Aliases: []string{"/stats"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdStats(ctx) }},
}
}
func (s *TelegramBotService) telegramCommandRegistry(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) map[string]telegramCommandDefinition {
defs := s.telegramCommandDefinitions(ctx, channel, msg)
registry := make(map[string]telegramCommandDefinition, len(defs)*2)
for _, def := range defs {
for _, alias := range def.Aliases {
registry[alias] = def
}
}
return registry
}
// executeCommand parses and dispatches Telegram commands through a registry so
// adding a command does not grow a monolithic switch.
func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, text string) (telegramCommandReply, error) {
parts := strings.Fields(text)
if len(parts) == 0 {
return telegramCommandReply{}, nil
}
cmd := telegramCommandName(parts[0])
args := parts[1:]
if msg.Chat.Type != "" && msg.Chat.Type != "private" && !s.telegramChatAllowed(channel, msg.Chat.ID) {
return telegramCommandReply{Text: "此群组/频道未绑定到 Bot 管理入口,请在通知渠道里填写「绑定群组 ID」或「绑定频道 ID」。"}, nil
}
def, ok := s.telegramCommandRegistry(ctx, channel, msg)[cmd]
if !ok {
return telegramCommandReply{Text: fmt.Sprintf("未知命令: %s\n\n输入 /help 查看可用命令列表。", cmd)}, nil
}
if def.AdminOnly && !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: def.AdminOnlyText}, nil
}
return def.Handle(args)
}
func telegramSupportedCommand(cmd string) bool {
cmd = telegramCommandName(cmd)
if cmd == "" {
return false
}
_, ok := telegramSupportedCommandSet[cmd]
return ok
}
var telegramSupportedCommandSet = map[string]struct{}{
"/start": {}, "/menu": {}, "/cancel": {}, "/help": {}, "/hideadult": {}, "/hide_adult": {}, "/adult": {},
"/account": {}, "/me": {}, "/signin": {}, "/checkin": {}, "/devices": {}, "/kick": {}, "/setname": {}, "/rename": {}, "/setpass": {}, "/passwd": {}, "/password": {},
"/redeem": {}, "/redeem_register": {}, "/redeem_renew": {},
"/register": {}, "/reg": {}, "/signup": {}, "/registration": {}, "/reg_switch": {}, "/openreg": {},
"/capacity": {}, "/users": {}, "/gencode": {}, "/renew_user": {}, "/delete_user": {},
"/devicepolicy": {}, "/policy": {}, "/antishare": {}, "/cleanup": {}, "/cleanup_mode": {}, "/cleanup_rule": {},
"/ban": {}, "/unban": {}, "/status": {}, "/search": {}, "/downloads": {}, "/stats": {},
}
+5 -99
View File
@@ -7,69 +7,14 @@ import type { Site } from '../types'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
import { confirmAction } from '../components/ConfirmDialog'
// ── 站点类型映射 ──
const SITE_TYPE_LABELS: Record<string, string> = {
nexusphp: 'NexusPHP',
gazelle: 'Gazelle',
unit3d: 'UNIT3D',
mteam: 'M-Team',
discuz: 'Discuz',
custom_rss: '自定义 RSS',
}
const SITE_TYPE_ABBR: Record<string, string> = {
nexusphp: 'NP',
gazelle: 'GZ',
unit3d: 'U3',
mteam: 'MT',
discuz: 'DZ',
custom_rss: 'RS',
}
const SITE_TYPE_COLORS: Record<string, string> = {
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-sand-500/15 text-ink-50',
}
const AUTH_TYPE_LABELS: Record<string, string> = {
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: '',
// 高级设置
user_agent: '',
rss_url: '',
timeout: 15,
priority: 50,
use_proxy: false,
rate_limit: false,
browser_emulation: false,
downloader: '',
})
import { AUTH_TYPE_LABELS, defaultSiteForm, siteFormToPayload, siteToForm, SITE_TYPE_ABBR, SITE_TYPE_COLORS, SITE_TYPE_LABELS } from './sitesPageModel'
export function SitesPage() {
const [sites, setSites] = useState<Site[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState(defaultForm())
const [form, setForm] = useState(defaultSiteForm())
const [saving, setSaving] = useState(false)
const [testingId, setTestingId] = useState<string | null>(null)
const [advancedOpen, setAdvancedOpen] = useState(false)
@@ -93,7 +38,7 @@ export function SitesPage() {
// ── 弹窗操作 ──
const openCreate = () => {
setEditingId(null)
setForm(defaultForm())
setForm(defaultSiteForm())
setAdvancedOpen(false)
setShowModal(true)
}
@@ -103,27 +48,7 @@ export function SitesPage() {
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 || '',
// 高级设置
user_agent: s.user_agent || '',
rss_url: s.rss_url || '',
timeout: s.timeout ?? 15,
priority: s.priority ?? 50,
use_proxy: s.use_proxy || false,
rate_limit: s.rate_limit || false,
browser_emulation: s.browser_emulation || false,
downloader: s.downloader || '',
})
setForm(siteToForm(s))
setAdvancedOpen(false)
setShowModal(true)
} catch {
@@ -139,26 +64,7 @@ export function SitesPage() {
// ── 保存(支持静默模式)──
const silentSave = async (): Promise<boolean> => {
if (!form.name.trim() || !form.url.trim()) return false
const payload: Record<string, unknown> = {
name: form.name.trim(),
url: form.url.trim(),
type: form.type,
auth_type: form.auth_type,
enabled: form.enabled,
is_default: form.is_default,
extra: form.extra || '',
user_agent: form.user_agent || '',
rss_url: form.rss_url || '',
timeout: Number(form.timeout) || 15,
priority: Number(form.priority) || 50,
use_proxy: !!form.use_proxy,
rate_limit: !!form.rate_limit,
browser_emulation: !!form.browser_emulation,
downloader: form.downloader || '',
}
if (!editingId || form.cookie.trim()) payload.cookie = form.cookie.trim()
if (!editingId || form.api_key.trim()) payload.api_key = form.api_key.trim()
if (!editingId || form.auth_header.trim()) payload.auth_header = form.auth_header.trim()
const payload = siteFormToPayload(form, !editingId)
try {
if (editingId) {
await sitesAPI.update(editingId, payload)
+108
View File
@@ -0,0 +1,108 @@
import type { Site } from '../types'
// ── 站点类型映射 ──
export const SITE_TYPE_LABELS: Record<string, string> = {
nexusphp: 'NexusPHP',
gazelle: 'Gazelle',
unit3d: 'UNIT3D',
mteam: 'M-Team',
discuz: 'Discuz',
custom_rss: '自定义 RSS',
}
export const SITE_TYPE_ABBR: Record<string, string> = {
nexusphp: 'NP',
gazelle: 'GZ',
unit3d: 'U3',
mteam: 'MT',
discuz: 'DZ',
custom_rss: 'RS',
}
export const SITE_TYPE_COLORS: Record<string, string> = {
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-sand-500/15 text-ink-50',
}
export const AUTH_TYPE_LABELS: Record<string, string> = {
cookie: 'Cookie',
api_key: 'API Key',
auth_header: 'Auth Header',
}
// ── 默认表单 ──
export const defaultSiteForm = () => ({
name: '',
url: '',
type: 'nexusphp',
auth_type: 'cookie',
cookie: '',
api_key: '',
auth_header: '',
enabled: true,
is_default: false,
extra: '',
// 高级设置
user_agent: '',
rss_url: '',
timeout: 15,
priority: 50,
use_proxy: false,
rate_limit: false,
browser_emulation: false,
downloader: '',
})
export type SiteForm = ReturnType<typeof defaultSiteForm>
export function siteToForm(site: Site): SiteForm {
return {
name: site.name || '',
url: site.url || '',
type: site.type || 'nexusphp',
auth_type: site.auth_type || 'cookie',
cookie: site.cookie || '',
api_key: site.api_key || '',
auth_header: site.auth_header || '',
enabled: site.enabled !== false,
is_default: site.is_default || false,
extra: site.extra || '',
user_agent: site.user_agent || '',
rss_url: site.rss_url || '',
timeout: site.timeout ?? 15,
priority: site.priority ?? 50,
use_proxy: site.use_proxy || false,
rate_limit: site.rate_limit || false,
browser_emulation: site.browser_emulation || false,
downloader: site.downloader || '',
}
}
export function siteFormToPayload(form: SiteForm, includeEmptySecrets: boolean): Record<string, unknown> {
const payload: Record<string, unknown> = {
name: form.name.trim(),
url: form.url.trim(),
type: form.type,
auth_type: form.auth_type,
enabled: form.enabled,
is_default: form.is_default,
extra: form.extra || '',
user_agent: form.user_agent || '',
rss_url: form.rss_url || '',
timeout: Number(form.timeout) || 15,
priority: Number(form.priority) || 50,
use_proxy: !!form.use_proxy,
rate_limit: !!form.rate_limit,
browser_emulation: !!form.browser_emulation,
downloader: form.downloader || '',
}
if (includeEmptySecrets || form.cookie.trim()) payload.cookie = form.cookie.trim()
if (includeEmptySecrets || form.api_key.trim()) payload.api_key = form.api_key.trim()
if (includeEmptySecrets || form.auth_header.trim()) payload.auth_header = form.auth_header.trim()
return payload
}