From 33698daa3f0e7ab6def1bbc4e052bb5eb0799428 Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Sun, 7 Jun 2026 14:59:32 +0800 Subject: [PATCH] refactor: split oversized route and subscription modules --- internal/handler/handler.go | 365 +-------- internal/handler/routes_admin.go | 96 +++ internal/handler/routes_api_config.go | 26 + internal/handler/routes_authenticated.go | 257 ++++++ internal/handler/routes_public.go | 37 + internal/service/site_adapter.go | 332 -------- internal/service/site_adapter_mteam.go | 345 ++++++++ internal/service/subscription.go | 734 ------------------ internal/service/subscription_availability.go | 315 ++++++++ internal/service/subscription_planner.go | 457 +++++++++++ internal/service/telegram_bot.go | 153 ---- internal/service/telegram_commands.go | 124 +++ web/src/pages/SitesPage.tsx | 104 +-- web/src/pages/sitesPageModel.ts | 108 +++ 14 files changed, 1774 insertions(+), 1679 deletions(-) create mode 100644 internal/handler/routes_admin.go create mode 100644 internal/handler/routes_api_config.go create mode 100644 internal/handler/routes_authenticated.go create mode 100644 internal/handler/routes_public.go create mode 100644 internal/service/site_adapter_mteam.go create mode 100644 internal/service/subscription_availability.go create mode 100644 internal/service/subscription_planner.go create mode 100644 internal/service/telegram_commands.go create mode 100644 web/src/pages/sitesPageModel.ts diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 146978b..fe7642c 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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 都能自动连接。 diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go new file mode 100644 index 0000000..18c72aa --- /dev/null +++ b/internal/handler/routes_admin.go @@ -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)) + + } + +} diff --git a/internal/handler/routes_api_config.go b/internal/handler/routes_api_config.go new file mode 100644 index 0000000..b70714f --- /dev/null +++ b/internal/handler/routes_api_config.go @@ -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)) + } + +} diff --git a/internal/handler/routes_authenticated.go b/internal/handler/routes_authenticated.go new file mode 100644 index 0000000..8d38db3 --- /dev/null +++ b/internal/handler/routes_authenticated.go @@ -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)) + } + +} diff --git a/internal/handler/routes_public.go b/internal/handler/routes_public.go new file mode 100644 index 0000000..6969502 --- /dev/null +++ b/internal/handler/routes_public.go @@ -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) + } + +} diff --git a/internal/service/site_adapter.go b/internal/service/site_adapter.go index 7ad4998..c9b771f 100644 --- a/internal/service/site_adapter.go +++ b/internal/service/site_adapter.go @@ -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 的站点适配器。 diff --git a/internal/service/site_adapter_mteam.go b/internal/service/site_adapter_mteam.go new file mode 100644 index 0000000..f6a052c --- /dev/null +++ b/internal/service/site_adapter_mteam.go @@ -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 +} diff --git a/internal/service/subscription.go b/internal/service/subscription.go index 6c4eee3..62a45c2 100644 --- a/internal/service/subscription.go +++ b/internal/service/subscription.go @@ -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 "" diff --git a/internal/service/subscription_availability.go b/internal/service/subscription_availability.go new file mode 100644 index 0000000..989ea47 --- /dev/null +++ b/internal/service/subscription_availability.go @@ -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() +} diff --git a/internal/service/subscription_planner.go b/internal/service/subscription_planner.go new file mode 100644 index 0000000..8562695 --- /dev/null +++ b/internal/service/subscription_planner.go @@ -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) +} diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go index 5da5f25..a05f173 100644 --- a/internal/service/telegram_bot.go +++ b/internal/service/telegram_bot.go @@ -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 diff --git a/internal/service/telegram_commands.go b/internal/service/telegram_commands.go new file mode 100644 index 0000000..25dfb93 --- /dev/null +++ b/internal/service/telegram_commands.go @@ -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": {}, +} diff --git a/web/src/pages/SitesPage.tsx b/web/src/pages/SitesPage.tsx index 0c5f435..c9195bd 100644 --- a/web/src/pages/SitesPage.tsx +++ b/web/src/pages/SitesPage.tsx @@ -7,69 +7,14 @@ import type { Site } from '../types' import { ManagementShortcuts } from '../components/ManagementShortcuts' import { confirmAction } from '../components/ConfirmDialog' -// ── 站点类型映射 ── -const SITE_TYPE_LABELS: Record = { - nexusphp: 'NexusPHP', - gazelle: 'Gazelle', - unit3d: 'UNIT3D', - mteam: 'M-Team', - discuz: 'Discuz', - custom_rss: '自定义 RSS', -} - -const SITE_TYPE_ABBR: Record = { - nexusphp: 'NP', - gazelle: 'GZ', - unit3d: 'U3', - mteam: 'MT', - discuz: 'DZ', - custom_rss: 'RS', -} - -const SITE_TYPE_COLORS: Record = { - nexusphp: 'bg-blue-500/15 text-blue-400', - gazelle: 'bg-purple-500/15 text-purple-400', - unit3d: 'bg-orange-500/15 text-orange-400', - mteam: 'bg-green-500/15 text-green-400', - discuz: 'bg-yellow-500/15 text-yellow-400', - custom_rss: 'bg-sand-500/15 text-ink-50', -} - -const AUTH_TYPE_LABELS: Record = { - cookie: 'Cookie', - api_key: 'API Key', - auth_header: 'Auth Header', -} - -// ── 默认表单 ── -const defaultForm = () => ({ - name: '', - url: '', - type: 'nexusphp', - auth_type: 'cookie', - cookie: '', - api_key: '', - auth_header: '', - enabled: true, - is_default: false, - extra: '', - // 高级设置 - 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([]) const [loading, setLoading] = useState(true) const [showModal, setShowModal] = useState(false) const [editingId, setEditingId] = useState(null) - const [form, setForm] = useState(defaultForm()) + const [form, setForm] = useState(defaultSiteForm()) const [saving, setSaving] = useState(false) const [testingId, setTestingId] = useState(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 => { if (!form.name.trim() || !form.url.trim()) return false - const payload: Record = { - 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) diff --git a/web/src/pages/sitesPageModel.ts b/web/src/pages/sitesPageModel.ts new file mode 100644 index 0000000..7c173b2 --- /dev/null +++ b/web/src/pages/sitesPageModel.ts @@ -0,0 +1,108 @@ +import type { Site } from '../types' + +// ── 站点类型映射 ── +export const SITE_TYPE_LABELS: Record = { + nexusphp: 'NexusPHP', + gazelle: 'Gazelle', + unit3d: 'UNIT3D', + mteam: 'M-Team', + discuz: 'Discuz', + custom_rss: '自定义 RSS', +} + +export const SITE_TYPE_ABBR: Record = { + nexusphp: 'NP', + gazelle: 'GZ', + unit3d: 'U3', + mteam: 'MT', + discuz: 'DZ', + custom_rss: 'RS', +} + +export const SITE_TYPE_COLORS: Record = { + nexusphp: 'bg-blue-500/15 text-blue-400', + gazelle: 'bg-purple-500/15 text-purple-400', + unit3d: 'bg-orange-500/15 text-orange-400', + mteam: 'bg-green-500/15 text-green-400', + discuz: 'bg-yellow-500/15 text-yellow-400', + custom_rss: 'bg-sand-500/15 text-ink-50', +} + +export const AUTH_TYPE_LABELS: Record = { + 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 + +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 { + const payload: Record = { + 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 +}