mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
refactor: split oversized route and subscription modules
This commit is contained in:
+4
-361
@@ -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 都能自动连接。
|
||||
|
||||
@@ -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))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 的站点适配器。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -14,13 +14,9 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -29,21 +25,6 @@ import (
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
seriesPackRE = regexp.MustCompile(`(?i)(complete|batch|合集|全集|全\s*\d+\s*[集话話期]|整季|全季|s\d{1,2}\s*(?:complete|batch|pack)|season\s*\d{1,2}\s*(?:complete|batch|pack)|s\d{1,2}e\d{1,3}\s*[-~–—]\s*(?:e)?\d{1,3}|第\s*\d+\s*[-~–—]\s*\d+\s*[集话話期])`)
|
||||
seasonOnlyRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\s*\d{1,2}(?:[\s._-]|$)|第\s*\d+\s*季`)
|
||||
)
|
||||
|
||||
type siteSearchCandidate struct {
|
||||
Item SearchResult
|
||||
Download string
|
||||
GUID string
|
||||
Season int
|
||||
Episode int
|
||||
Pack bool
|
||||
Score int
|
||||
}
|
||||
|
||||
// SubscriptionService runs the polling loop.
|
||||
type SubscriptionService struct {
|
||||
cfg *config.Config
|
||||
@@ -395,418 +376,6 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, availability ...LocalAvailability) []siteSearchCandidate {
|
||||
candidates := make([]siteSearchCandidate, 0, len(results))
|
||||
for _, item := range results {
|
||||
if !matchesSubscriptionRules(sub, item.Title) {
|
||||
continue
|
||||
}
|
||||
download := strings.TrimSpace(item.DownloadURL)
|
||||
if download == "" {
|
||||
download = strings.TrimSpace(item.TorrentURL)
|
||||
}
|
||||
if download == "" {
|
||||
continue
|
||||
}
|
||||
guid := stableSiteSearchGUID(item, download)
|
||||
if _, ok := seenSet[guid]; ok {
|
||||
continue
|
||||
}
|
||||
season, episode := ParseEpisode(item.Title)
|
||||
score := subscriptionCandidateScore(sub, item)
|
||||
candidates = append(candidates, siteSearchCandidate{
|
||||
Item: item,
|
||||
Download: download,
|
||||
GUID: guid,
|
||||
Season: season,
|
||||
Episode: episode,
|
||||
Pack: isSeriesPackTitle(item.Title),
|
||||
Score: score,
|
||||
})
|
||||
}
|
||||
if len(candidates) > 1 {
|
||||
sort.SliceStable(candidates, func(i, j int) bool {
|
||||
if candidates[i].Score != candidates[j].Score {
|
||||
return candidates[i].Score > candidates[j].Score
|
||||
}
|
||||
if candidates[i].Item.Seeders != candidates[j].Item.Seeders {
|
||||
return candidates[i].Item.Seeders > candidates[j].Item.Seeders
|
||||
}
|
||||
return candidates[i].Item.Size > candidates[j].Item.Size
|
||||
})
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var local LocalAvailability
|
||||
if len(availability) > 0 {
|
||||
local = availability[0]
|
||||
}
|
||||
|
||||
mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "")
|
||||
if !isSubscriptionSeriesType(mediaType) {
|
||||
// 对齐 MoviePilot:非洗版订阅成功下载一次即满足,媒体库/下载中已存在则不再重复下载。
|
||||
if (sub == nil || !sub.WashEnabled) && local.LocalMediaCount > 0 {
|
||||
return nil
|
||||
}
|
||||
return candidates[:1]
|
||||
}
|
||||
|
||||
if local.HasSeriesPack {
|
||||
return nil
|
||||
}
|
||||
if local.LocalMediaCount > 0 {
|
||||
if local.TotalEpisodes > 0 && len(local.MissingEpisodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
missingSet := missingEpisodeSet(local)
|
||||
onlyMissing := make([]siteSearchCandidate, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Episode <= 0 {
|
||||
continue
|
||||
}
|
||||
season := candidate.Season
|
||||
if season <= 0 {
|
||||
season = 1
|
||||
}
|
||||
if _, exists := local.ExistingEpisodeKeys[episodeKey(season, candidate.Episode)]; exists {
|
||||
continue
|
||||
}
|
||||
if local.TotalEpisodes > 0 {
|
||||
if _, missing := missingSet[candidate.Episode]; !missing {
|
||||
continue
|
||||
}
|
||||
}
|
||||
onlyMissing = append(onlyMissing, candidate)
|
||||
}
|
||||
return sortedEpisodeCandidates(onlyMissing)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Pack {
|
||||
return []siteSearchCandidate{candidate}
|
||||
}
|
||||
}
|
||||
|
||||
selected := sortedEpisodeCandidates(candidates)
|
||||
if len(selected) == 0 {
|
||||
return candidates[:1]
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
func stableRSSItemGUID(title, guid, link, enclosureURL string) string {
|
||||
parts := []string{"rss", strings.ToLower(strings.TrimSpace(title))}
|
||||
for _, raw := range []string{guid, enclosureURL, link} {
|
||||
if key := stableDownloadURLKey(raw); key != "" {
|
||||
parts = append(parts, key)
|
||||
return strings.Join(parts, "|")
|
||||
}
|
||||
if raw = strings.TrimSpace(raw); raw != "" {
|
||||
parts = append(parts, strings.ToLower(raw))
|
||||
return strings.Join(parts, "|")
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
func stableSiteSearchGUID(item SearchResult, download string) string {
|
||||
parts := []string{
|
||||
"site",
|
||||
strings.ToLower(strings.TrimSpace(firstNonEmpty(item.SiteID, item.SiteName))),
|
||||
strings.ToLower(strings.TrimSpace(item.Category)),
|
||||
strings.ToLower(strings.TrimSpace(item.Title)),
|
||||
fmt.Sprintf("%d", item.Size),
|
||||
}
|
||||
if key := stableDownloadURLKey(download); key != "" {
|
||||
parts = append(parts, key)
|
||||
}
|
||||
return strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
func stableDownloadURLKey(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return strings.ToLower(raw)
|
||||
}
|
||||
if strings.EqualFold(u.Scheme, "magnet") {
|
||||
xt := strings.ToLower(strings.TrimSpace(u.Query().Get("xt")))
|
||||
if xt != "" {
|
||||
return "magnet:" + xt
|
||||
}
|
||||
return strings.ToLower(raw)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return strings.ToLower(raw)
|
||||
}
|
||||
q := u.Query()
|
||||
kept := make([]string, 0, 4)
|
||||
for _, key := range []string{"id", "tid", "torrent", "torrent_id", "torrentid", "hash", "info_hash"} {
|
||||
if value := strings.TrimSpace(q.Get(key)); value != "" {
|
||||
kept = append(kept, key+"="+strings.ToLower(value))
|
||||
}
|
||||
}
|
||||
base := strings.ToLower(strings.TrimRight(u.Host, "/") + "/" + strings.TrimLeft(u.Path, "/"))
|
||||
if len(kept) > 0 {
|
||||
return base + "?" + strings.Join(kept, "&")
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// defaultExcludeWords 是参考 MoviePilot 默认过滤的「垃圾版本」排除清单,对所有订阅生效,
|
||||
// 与用户自定义排除词合并。拉丁词在 containsAnyExcludeToken 里按词边界匹配以避免子串误伤。
|
||||
const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample"
|
||||
|
||||
func matchesSubscriptionRules(sub *model.Subscription, title string) bool {
|
||||
titleFold := strings.ToLower(title)
|
||||
if containsAnyExcludeToken(titleFold, defaultExcludeWords) {
|
||||
return false
|
||||
}
|
||||
if sub == nil {
|
||||
return true
|
||||
}
|
||||
if sub.ExcludeWords != "" && containsAnyExcludeToken(titleFold, sub.ExcludeWords) {
|
||||
return false
|
||||
}
|
||||
if sub.ReleaseGroups != "" && !containsAnyToken(titleFold, sub.ReleaseGroups) {
|
||||
return false
|
||||
}
|
||||
if sub.Resolution != "" && sub.Resolution != "best" && !titleMatchesResolution(titleFold, sub.Resolution) {
|
||||
return false
|
||||
}
|
||||
if sub.Quality != "" && sub.Quality != "best" && !titleMatchesQuality(titleFold, sub.Quality) {
|
||||
return false
|
||||
}
|
||||
if sub.Effects != "" && !containsAnyEffect(titleFold, sub.Effects) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func subscriptionCandidateScore(sub *model.Subscription, item SearchResult) int {
|
||||
title := strings.ToLower(item.Title)
|
||||
score := item.Seeders
|
||||
if sub == nil || !sub.WashEnabled {
|
||||
if item.Free {
|
||||
score += 25
|
||||
}
|
||||
return score
|
||||
}
|
||||
resolutionScore := detectResolutionScore(title)
|
||||
qualityScore := detectQualityScore(title)
|
||||
effectScore := detectEffectScore(title)
|
||||
|
||||
priority := "balanced"
|
||||
if sub != nil && strings.TrimSpace(sub.WashPriority) != "" {
|
||||
priority = strings.ToLower(strings.TrimSpace(sub.WashPriority))
|
||||
}
|
||||
switch priority {
|
||||
case "resolution":
|
||||
score += resolutionScore*1000 + qualityScore*100 + effectScore*50
|
||||
case "quality":
|
||||
score += qualityScore*1000 + resolutionScore*200 + effectScore*50
|
||||
case "effects":
|
||||
score += effectScore*1000 + resolutionScore*200 + qualityScore*100
|
||||
case "seeders":
|
||||
score += qualityScore*3 + resolutionScore*2 + effectScore
|
||||
default:
|
||||
score += resolutionScore*500 + qualityScore*300 + effectScore*150
|
||||
}
|
||||
if item.Free {
|
||||
score += 25
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func containsAnyToken(titleFold, csv string) bool {
|
||||
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
|
||||
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
|
||||
}) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token != "" && strings.Contains(titleFold, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containsAnyExcludeToken 用于排除词匹配:纯 ASCII 字母数字的词按词边界匹配(避免 "ts"
|
||||
// 误伤 "tsukihime"、"cam" 误伤 "camp" 之类的子串误判),含 CJK/符号的词仍按子串匹配。
|
||||
func containsAnyExcludeToken(titleFold, csv string) bool {
|
||||
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
|
||||
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
|
||||
}) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
if isASCIIWordToken(token) {
|
||||
if matchesWordBoundary(titleFold, token) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.Contains(titleFold, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isASCIIWordToken(token string) bool {
|
||||
for _, r := range token {
|
||||
if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return token != ""
|
||||
}
|
||||
|
||||
// matchesWordBoundary 判断 token 是否作为独立词出现在 title 中,词边界为「非字母数字」。
|
||||
func matchesWordBoundary(titleFold, token string) bool {
|
||||
isWordRune := func(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
}
|
||||
from := 0
|
||||
for {
|
||||
idx := strings.Index(titleFold[from:], token)
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
start := from + idx
|
||||
end := start + len(token)
|
||||
leftOK := start == 0 || !isWordRune(rune(titleFold[start-1]))
|
||||
rightOK := end >= len(titleFold) || !isWordRune(rune(titleFold[end]))
|
||||
if leftOK && rightOK {
|
||||
return true
|
||||
}
|
||||
from = start + 1
|
||||
if from >= len(titleFold) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsAnyEffect(titleFold, csv string) bool {
|
||||
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
|
||||
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
|
||||
}) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
switch token {
|
||||
case "dolby-vision", "dolby vision", "dv":
|
||||
if strings.Contains(titleFold, "dolby vision") || strings.Contains(titleFold, "dovi") || regexp.MustCompile(`\bdv\b`).MatchString(titleFold) {
|
||||
return true
|
||||
}
|
||||
default:
|
||||
if strings.Contains(titleFold, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func titleMatchesResolution(titleFold, resolution string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(resolution)) {
|
||||
case "2160p", "4k", "uhd":
|
||||
return strings.Contains(titleFold, "2160p") || strings.Contains(titleFold, "4k") || strings.Contains(titleFold, "uhd")
|
||||
case "1080p":
|
||||
return strings.Contains(titleFold, "1080p") || strings.Contains(titleFold, "fhd")
|
||||
case "720p":
|
||||
return strings.Contains(titleFold, "720p")
|
||||
default:
|
||||
return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(resolution)))
|
||||
}
|
||||
}
|
||||
|
||||
func titleMatchesQuality(titleFold, quality string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(quality)) {
|
||||
case "webdl", "web-dl":
|
||||
return strings.Contains(titleFold, "web-dl") || strings.Contains(titleFold, "webdl")
|
||||
case "bluray", "blu-ray":
|
||||
return strings.Contains(titleFold, "bluray") || strings.Contains(titleFold, "blu-ray") || strings.Contains(titleFold, "bdrip")
|
||||
case "remux":
|
||||
return strings.Contains(titleFold, "remux")
|
||||
case "hdtv":
|
||||
return strings.Contains(titleFold, "hdtv")
|
||||
default:
|
||||
return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(quality)))
|
||||
}
|
||||
}
|
||||
|
||||
func detectResolutionScore(titleFold string) int {
|
||||
switch {
|
||||
case titleMatchesResolution(titleFold, "2160p"):
|
||||
return 4
|
||||
case titleMatchesResolution(titleFold, "1080p"):
|
||||
return 3
|
||||
case titleMatchesResolution(titleFold, "720p"):
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func detectQualityScore(titleFold string) int {
|
||||
switch {
|
||||
case titleMatchesQuality(titleFold, "remux"):
|
||||
return 5
|
||||
case titleMatchesQuality(titleFold, "bluray"):
|
||||
return 4
|
||||
case titleMatchesQuality(titleFold, "web-dl"):
|
||||
return 3
|
||||
case titleMatchesQuality(titleFold, "hdtv"):
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func detectEffectScore(titleFold string) int {
|
||||
score := 0
|
||||
if containsAnyEffect(titleFold, "dolby-vision") {
|
||||
score += 4
|
||||
}
|
||||
if strings.Contains(titleFold, "hdr10+") {
|
||||
score += 3
|
||||
} else if strings.Contains(titleFold, "hdr") {
|
||||
score += 2
|
||||
}
|
||||
if strings.Contains(titleFold, "atmos") {
|
||||
score += 2
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func isSubscriptionSeriesType(mediaType string) bool {
|
||||
switch normalizeMediaType(mediaType, "", "") {
|
||||
case "tv", "anime", "variety":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isSeriesPackTitle(title string) bool {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return false
|
||||
}
|
||||
if seriesPackRE.MatchString(title) {
|
||||
return true
|
||||
}
|
||||
_, episode := ParseEpisode(title)
|
||||
return episode == 0 && seasonOnlyRE.MatchString(title)
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) shouldSkipExistingTorrent(ctx context.Context, mediaType string, candidate siteSearchCandidate) bool {
|
||||
if s == nil || s.downloads == nil {
|
||||
return false
|
||||
@@ -817,309 +386,6 @@ func (s *SubscriptionService) shouldSkipExistingTorrent(ctx context.Context, med
|
||||
return s.downloads.TorrentExistsByName(ctx, candidate.Item.Title)
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) pendingDownloadAvailability(ctx context.Context, sub *model.Subscription) LocalAvailability {
|
||||
out := LocalAvailability{
|
||||
ExistingEpisodeKeys: map[string]struct{}{},
|
||||
MissingEpisodeKeys: map[string]struct{}{},
|
||||
}
|
||||
if sub != nil {
|
||||
out.TotalEpisodes = sub.TotalEpisodes
|
||||
}
|
||||
query := availabilityQuery(subscriptionName(sub), subscriptionFilter(sub))
|
||||
if query == "" {
|
||||
return s.finalizePendingAvailability(sub, out)
|
||||
}
|
||||
root := s.subscriptionBaseSavePath(ctx, sub)
|
||||
if root != "" {
|
||||
_ = scanDownloadPath(ctx, root, query, func(_ string, season, episode int) bool {
|
||||
out.LocalMediaCount++
|
||||
out.InLibrary = true
|
||||
if episode > 0 {
|
||||
out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
s.addDownloadTaskAvailability(ctx, sub, query, &out)
|
||||
s.addLiveTorrentAvailability(ctx, query, &out)
|
||||
return s.finalizePendingAvailability(sub, out)
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) addDownloadTaskAvailability(ctx context.Context, sub *model.Subscription, query string, out *LocalAvailability) {
|
||||
if s == nil || s.repo == nil || s.repo.Download == nil || out == nil {
|
||||
return
|
||||
}
|
||||
rows, err := s.repo.Download.List(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
baseSavePath := s.subscriptionBaseSavePath(ctx, sub)
|
||||
for _, row := range rows {
|
||||
if !downloadTaskBlocksReadd(row.Status) {
|
||||
continue
|
||||
}
|
||||
if baseSavePath != "" && row.SavePath != "" && !sameOrChildPath(row.SavePath, baseSavePath) && !sameOrChildPath(baseSavePath, row.SavePath) {
|
||||
continue
|
||||
}
|
||||
addAvailabilityTitle(row.Title, query, out)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) addLiveTorrentAvailability(ctx context.Context, query string, out *LocalAvailability) {
|
||||
if s == nil || s.downloads == nil || s.downloads.qb == nil || out == nil {
|
||||
return
|
||||
}
|
||||
live, err := s.downloads.qb.List(ctx, "")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, torrent := range live {
|
||||
addAvailabilityTitle(torrent.Name, query, out)
|
||||
}
|
||||
}
|
||||
|
||||
func addAvailabilityTitle(title, query string, out *LocalAvailability) {
|
||||
if out == nil || strings.TrimSpace(title) == "" || strings.TrimSpace(query) == "" {
|
||||
return
|
||||
}
|
||||
if !strings.Contains(normalizeAvailabilityComparable(title), normalizeAvailabilityComparable(query)) {
|
||||
return
|
||||
}
|
||||
out.LocalMediaCount++
|
||||
out.InLibrary = true
|
||||
season, episode := ParseEpisode(title)
|
||||
if episode > 0 {
|
||||
out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{}
|
||||
return
|
||||
}
|
||||
if isSeriesPackTitle(title) {
|
||||
out.HasSeriesPack = true
|
||||
}
|
||||
}
|
||||
|
||||
func sameOrChildPath(pathValue, root string) bool {
|
||||
pathValue = filepath.Clean(strings.TrimSpace(pathValue))
|
||||
root = filepath.Clean(strings.TrimSpace(root))
|
||||
if pathValue == "" || root == "" || pathValue == "." || root == "." {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(pathValue, root) {
|
||||
return true
|
||||
}
|
||||
rel, err := filepath.Rel(root, pathValue)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel)
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) finalizePendingAvailability(sub *model.Subscription, out LocalAvailability) LocalAvailability {
|
||||
mediaType := ""
|
||||
if sub != nil {
|
||||
mediaType = sub.MediaType
|
||||
}
|
||||
if isSubscriptionSeriesType(mediaType) || len(out.ExistingEpisodeKeys) > 0 {
|
||||
out.DownloadedEpisodes = len(out.ExistingEpisodeKeys)
|
||||
out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes)
|
||||
for _, episode := range out.MissingEpisodes {
|
||||
out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{}
|
||||
}
|
||||
} else if out.LocalMediaCount > 0 {
|
||||
out.DownloadedEpisodes = 1
|
||||
if out.TotalEpisodes == 0 {
|
||||
out.TotalEpisodes = 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) subscriptionBaseSavePath(ctx context.Context, sub *model.Subscription) string {
|
||||
if sub == nil {
|
||||
return ""
|
||||
}
|
||||
base := strings.TrimSpace(sub.SavePath)
|
||||
if base == "" && s != nil && s.repo != nil && s.repo.Setting != nil {
|
||||
base, _ = s.repo.Setting.Get(ctx, "qbittorrent.savepath")
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func subscriptionName(sub *model.Subscription) string {
|
||||
if sub == nil {
|
||||
return ""
|
||||
}
|
||||
return sub.Name
|
||||
}
|
||||
|
||||
func subscriptionFilter(sub *model.Subscription) string {
|
||||
if sub == nil {
|
||||
return ""
|
||||
}
|
||||
return sub.Filter
|
||||
}
|
||||
|
||||
func mergeLocalAvailability(values ...LocalAvailability) LocalAvailability {
|
||||
out := LocalAvailability{
|
||||
ExistingEpisodeKeys: map[string]struct{}{},
|
||||
MissingEpisodeKeys: map[string]struct{}{},
|
||||
}
|
||||
for _, value := range values {
|
||||
if out.TotalEpisodes == 0 {
|
||||
out.TotalEpisodes = value.TotalEpisodes
|
||||
}
|
||||
out.LocalMediaCount += value.LocalMediaCount
|
||||
out.InLibrary = out.InLibrary || value.InLibrary
|
||||
out.HasSeriesPack = out.HasSeriesPack || value.HasSeriesPack
|
||||
for key := range value.ExistingEpisodeKeys {
|
||||
out.ExistingEpisodeKeys[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
out.DownloadedEpisodes = len(out.ExistingEpisodeKeys)
|
||||
if out.TotalEpisodes > 0 {
|
||||
out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes)
|
||||
for _, episode := range out.MissingEpisodes {
|
||||
out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{}
|
||||
}
|
||||
}
|
||||
if out.DownloadedEpisodes == 0 && out.LocalMediaCount > 0 {
|
||||
out.DownloadedEpisodes = out.LocalMediaCount
|
||||
if out.TotalEpisodes == 0 {
|
||||
out.TotalEpisodes = 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// subscriptionItemAlreadyAvailable 判断某个订阅条目(按其标题解析出的季/集)是否已在媒体库存在。
|
||||
// 电影/无集号条目:媒体库已有该片即视为已存在;剧集条目:对应季集已入库即视为已存在。
|
||||
func subscriptionItemAlreadyAvailable(sub *model.Subscription, avail LocalAvailability, title string) bool {
|
||||
if avail.LocalMediaCount == 0 && !avail.HasSeriesPack {
|
||||
return false
|
||||
}
|
||||
if !isSubscriptionSeriesType(subscriptionMediaType(sub)) {
|
||||
return true
|
||||
}
|
||||
if avail.HasSeriesPack {
|
||||
return true
|
||||
}
|
||||
wantSeason, wantEpisode := ParseEpisode(title)
|
||||
if wantEpisode <= 0 {
|
||||
// 整季合集 / 无法解析集号:库里已有内容时保守跳过,避免重复整季下载。
|
||||
return true
|
||||
}
|
||||
if wantSeason <= 0 {
|
||||
wantSeason = 1
|
||||
}
|
||||
_, exists := avail.ExistingEpisodeKeys[episodeKey(wantSeason, wantEpisode)]
|
||||
return exists
|
||||
}
|
||||
|
||||
func subscriptionMediaType(sub *model.Subscription) string {
|
||||
if sub == nil {
|
||||
return ""
|
||||
}
|
||||
return sub.MediaType
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) downloadPathHasCandidate(ctx context.Context, sub *model.Subscription, title, savePath string) bool {
|
||||
savePath = strings.TrimSpace(savePath)
|
||||
if savePath == "" {
|
||||
savePath = s.subscriptionBaseSavePath(ctx, sub)
|
||||
}
|
||||
query := availabilityQuery(title, subscriptionFilter(sub))
|
||||
if savePath == "" || query == "" {
|
||||
return false
|
||||
}
|
||||
wantSeason, wantEpisode := ParseEpisode(title)
|
||||
if wantSeason <= 0 {
|
||||
wantSeason = 1
|
||||
}
|
||||
found := false
|
||||
_ = scanDownloadPath(ctx, savePath, query, func(path string, season, episode int) bool {
|
||||
if wantEpisode <= 0 {
|
||||
found = true
|
||||
return false
|
||||
}
|
||||
if episode <= 0 {
|
||||
return true
|
||||
}
|
||||
if season <= 0 {
|
||||
season = 1
|
||||
}
|
||||
if episodeKey(season, episode) == episodeKey(wantSeason, wantEpisode) {
|
||||
found = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func scanDownloadPath(ctx context.Context, root, query string, visit func(path string, season, episode int) bool) error {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return nil
|
||||
}
|
||||
info, err := os.Stat(root)
|
||||
if err != nil || !info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
normalizedQuery := normalizeAvailabilityComparable(query)
|
||||
if normalizedQuery == "" {
|
||||
return nil
|
||||
}
|
||||
visited := 0
|
||||
return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if d.IsDir() {
|
||||
if path != root && strings.HasPrefix(filepath.Base(path), ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !isDownloadMediaPath(path) {
|
||||
return nil
|
||||
}
|
||||
visited++
|
||||
if visited > 10000 {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
if !strings.Contains(normalizeAvailabilityComparable(path), normalizedQuery) {
|
||||
return nil
|
||||
}
|
||||
season, episode := ParseEpisode(path)
|
||||
if !visit(path, season, episode) {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func isDownloadMediaPath(path string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".!qb", ".part", ".aria2", ".crdownload":
|
||||
path = strings.TrimSuffix(path, filepath.Ext(path))
|
||||
ext = strings.ToLower(filepath.Ext(path))
|
||||
}
|
||||
_, ok := videoExtensions[ext]
|
||||
return ok
|
||||
}
|
||||
|
||||
func normalizeAvailabilityComparable(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(value) {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func siteSearchKeyword(sub *model.Subscription) string {
|
||||
if sub == nil {
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
// Package service — subscription local and pending-download availability helpers.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
func (s *SubscriptionService) pendingDownloadAvailability(ctx context.Context, sub *model.Subscription) LocalAvailability {
|
||||
out := LocalAvailability{
|
||||
ExistingEpisodeKeys: map[string]struct{}{},
|
||||
MissingEpisodeKeys: map[string]struct{}{},
|
||||
}
|
||||
if sub != nil {
|
||||
out.TotalEpisodes = sub.TotalEpisodes
|
||||
}
|
||||
query := availabilityQuery(subscriptionName(sub), subscriptionFilter(sub))
|
||||
if query == "" {
|
||||
return s.finalizePendingAvailability(sub, out)
|
||||
}
|
||||
root := s.subscriptionBaseSavePath(ctx, sub)
|
||||
if root != "" {
|
||||
_ = scanDownloadPath(ctx, root, query, func(_ string, season, episode int) bool {
|
||||
out.LocalMediaCount++
|
||||
out.InLibrary = true
|
||||
if episode > 0 {
|
||||
out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
s.addDownloadTaskAvailability(ctx, sub, query, &out)
|
||||
s.addLiveTorrentAvailability(ctx, query, &out)
|
||||
return s.finalizePendingAvailability(sub, out)
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) addDownloadTaskAvailability(ctx context.Context, sub *model.Subscription, query string, out *LocalAvailability) {
|
||||
if s == nil || s.repo == nil || s.repo.Download == nil || out == nil {
|
||||
return
|
||||
}
|
||||
rows, err := s.repo.Download.List(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
baseSavePath := s.subscriptionBaseSavePath(ctx, sub)
|
||||
for _, row := range rows {
|
||||
if !downloadTaskBlocksReadd(row.Status) {
|
||||
continue
|
||||
}
|
||||
if baseSavePath != "" && row.SavePath != "" && !sameOrChildPath(row.SavePath, baseSavePath) && !sameOrChildPath(baseSavePath, row.SavePath) {
|
||||
continue
|
||||
}
|
||||
addAvailabilityTitle(row.Title, query, out)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) addLiveTorrentAvailability(ctx context.Context, query string, out *LocalAvailability) {
|
||||
if s == nil || s.downloads == nil || s.downloads.qb == nil || out == nil {
|
||||
return
|
||||
}
|
||||
live, err := s.downloads.qb.List(ctx, "")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, torrent := range live {
|
||||
addAvailabilityTitle(torrent.Name, query, out)
|
||||
}
|
||||
}
|
||||
|
||||
func addAvailabilityTitle(title, query string, out *LocalAvailability) {
|
||||
if out == nil || strings.TrimSpace(title) == "" || strings.TrimSpace(query) == "" {
|
||||
return
|
||||
}
|
||||
if !strings.Contains(normalizeAvailabilityComparable(title), normalizeAvailabilityComparable(query)) {
|
||||
return
|
||||
}
|
||||
out.LocalMediaCount++
|
||||
out.InLibrary = true
|
||||
season, episode := ParseEpisode(title)
|
||||
if episode > 0 {
|
||||
out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{}
|
||||
return
|
||||
}
|
||||
if isSeriesPackTitle(title) {
|
||||
out.HasSeriesPack = true
|
||||
}
|
||||
}
|
||||
|
||||
func sameOrChildPath(pathValue, root string) bool {
|
||||
pathValue = filepath.Clean(strings.TrimSpace(pathValue))
|
||||
root = filepath.Clean(strings.TrimSpace(root))
|
||||
if pathValue == "" || root == "" || pathValue == "." || root == "." {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(pathValue, root) {
|
||||
return true
|
||||
}
|
||||
rel, err := filepath.Rel(root, pathValue)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel)
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) finalizePendingAvailability(sub *model.Subscription, out LocalAvailability) LocalAvailability {
|
||||
mediaType := ""
|
||||
if sub != nil {
|
||||
mediaType = sub.MediaType
|
||||
}
|
||||
if isSubscriptionSeriesType(mediaType) || len(out.ExistingEpisodeKeys) > 0 {
|
||||
out.DownloadedEpisodes = len(out.ExistingEpisodeKeys)
|
||||
out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes)
|
||||
for _, episode := range out.MissingEpisodes {
|
||||
out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{}
|
||||
}
|
||||
} else if out.LocalMediaCount > 0 {
|
||||
out.DownloadedEpisodes = 1
|
||||
if out.TotalEpisodes == 0 {
|
||||
out.TotalEpisodes = 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) subscriptionBaseSavePath(ctx context.Context, sub *model.Subscription) string {
|
||||
if sub == nil {
|
||||
return ""
|
||||
}
|
||||
base := strings.TrimSpace(sub.SavePath)
|
||||
if base == "" && s != nil && s.repo != nil && s.repo.Setting != nil {
|
||||
base, _ = s.repo.Setting.Get(ctx, "qbittorrent.savepath")
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func subscriptionName(sub *model.Subscription) string {
|
||||
if sub == nil {
|
||||
return ""
|
||||
}
|
||||
return sub.Name
|
||||
}
|
||||
|
||||
func subscriptionFilter(sub *model.Subscription) string {
|
||||
if sub == nil {
|
||||
return ""
|
||||
}
|
||||
return sub.Filter
|
||||
}
|
||||
|
||||
func mergeLocalAvailability(values ...LocalAvailability) LocalAvailability {
|
||||
out := LocalAvailability{
|
||||
ExistingEpisodeKeys: map[string]struct{}{},
|
||||
MissingEpisodeKeys: map[string]struct{}{},
|
||||
}
|
||||
for _, value := range values {
|
||||
if out.TotalEpisodes == 0 {
|
||||
out.TotalEpisodes = value.TotalEpisodes
|
||||
}
|
||||
out.LocalMediaCount += value.LocalMediaCount
|
||||
out.InLibrary = out.InLibrary || value.InLibrary
|
||||
out.HasSeriesPack = out.HasSeriesPack || value.HasSeriesPack
|
||||
for key := range value.ExistingEpisodeKeys {
|
||||
out.ExistingEpisodeKeys[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
out.DownloadedEpisodes = len(out.ExistingEpisodeKeys)
|
||||
if out.TotalEpisodes > 0 {
|
||||
out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes)
|
||||
for _, episode := range out.MissingEpisodes {
|
||||
out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{}
|
||||
}
|
||||
}
|
||||
if out.DownloadedEpisodes == 0 && out.LocalMediaCount > 0 {
|
||||
out.DownloadedEpisodes = out.LocalMediaCount
|
||||
if out.TotalEpisodes == 0 {
|
||||
out.TotalEpisodes = 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// subscriptionItemAlreadyAvailable 判断某个订阅条目(按其标题解析出的季/集)是否已在媒体库存在。
|
||||
// 电影/无集号条目:媒体库已有该片即视为已存在;剧集条目:对应季集已入库即视为已存在。
|
||||
func subscriptionItemAlreadyAvailable(sub *model.Subscription, avail LocalAvailability, title string) bool {
|
||||
if avail.LocalMediaCount == 0 && !avail.HasSeriesPack {
|
||||
return false
|
||||
}
|
||||
if !isSubscriptionSeriesType(subscriptionMediaType(sub)) {
|
||||
return true
|
||||
}
|
||||
if avail.HasSeriesPack {
|
||||
return true
|
||||
}
|
||||
wantSeason, wantEpisode := ParseEpisode(title)
|
||||
if wantEpisode <= 0 {
|
||||
// 整季合集 / 无法解析集号:库里已有内容时保守跳过,避免重复整季下载。
|
||||
return true
|
||||
}
|
||||
if wantSeason <= 0 {
|
||||
wantSeason = 1
|
||||
}
|
||||
_, exists := avail.ExistingEpisodeKeys[episodeKey(wantSeason, wantEpisode)]
|
||||
return exists
|
||||
}
|
||||
|
||||
func subscriptionMediaType(sub *model.Subscription) string {
|
||||
if sub == nil {
|
||||
return ""
|
||||
}
|
||||
return sub.MediaType
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) downloadPathHasCandidate(ctx context.Context, sub *model.Subscription, title, savePath string) bool {
|
||||
savePath = strings.TrimSpace(savePath)
|
||||
if savePath == "" {
|
||||
savePath = s.subscriptionBaseSavePath(ctx, sub)
|
||||
}
|
||||
query := availabilityQuery(title, subscriptionFilter(sub))
|
||||
if savePath == "" || query == "" {
|
||||
return false
|
||||
}
|
||||
wantSeason, wantEpisode := ParseEpisode(title)
|
||||
if wantSeason <= 0 {
|
||||
wantSeason = 1
|
||||
}
|
||||
found := false
|
||||
_ = scanDownloadPath(ctx, savePath, query, func(path string, season, episode int) bool {
|
||||
if wantEpisode <= 0 {
|
||||
found = true
|
||||
return false
|
||||
}
|
||||
if episode <= 0 {
|
||||
return true
|
||||
}
|
||||
if season <= 0 {
|
||||
season = 1
|
||||
}
|
||||
if episodeKey(season, episode) == episodeKey(wantSeason, wantEpisode) {
|
||||
found = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func scanDownloadPath(ctx context.Context, root, query string, visit func(path string, season, episode int) bool) error {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return nil
|
||||
}
|
||||
info, err := os.Stat(root)
|
||||
if err != nil || !info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
normalizedQuery := normalizeAvailabilityComparable(query)
|
||||
if normalizedQuery == "" {
|
||||
return nil
|
||||
}
|
||||
visited := 0
|
||||
return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if d.IsDir() {
|
||||
if path != root && strings.HasPrefix(filepath.Base(path), ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !isDownloadMediaPath(path) {
|
||||
return nil
|
||||
}
|
||||
visited++
|
||||
if visited > 10000 {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
if !strings.Contains(normalizeAvailabilityComparable(path), normalizedQuery) {
|
||||
return nil
|
||||
}
|
||||
season, episode := ParseEpisode(path)
|
||||
if !visit(path, season, episode) {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func isDownloadMediaPath(path string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".!qb", ".part", ".aria2", ".crdownload":
|
||||
path = strings.TrimSuffix(path, filepath.Ext(path))
|
||||
ext = strings.ToLower(filepath.Ext(path))
|
||||
}
|
||||
_, ok := videoExtensions[ext]
|
||||
return ok
|
||||
}
|
||||
|
||||
func normalizeAvailabilityComparable(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(value) {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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": {},
|
||||
}
|
||||
Reference in New Issue
Block a user