mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 04:16:38 +08:00
refactor: split oversized route and subscription modules
This commit is contained in:
+4
-361
@@ -5,13 +5,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
"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.
|
// Telegram Bot webhook — called by Telegram servers, no auth.
|
||||||
api.POST("/telegram/webhook", telegramWebhookHandler(svc))
|
api.POST("/telegram/webhook", telegramWebhookHandler(svc))
|
||||||
|
|
||||||
// Rate limiter for credential endpoints (login/register): brute-force
|
registerPublicAuthRoutes(api, svc, log)
|
||||||
// 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.
|
registerAuthenticatedRoutes(api, cfg, svc)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authenticated endpoints.
|
registerAdminRoutes(api, cfg, svc)
|
||||||
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.
|
registerAPIConfigRoutes(api, cfg, svc)
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emby/Jellyfin compatibility shim — routes mounted at /emby/* AND
|
// Emby/Jellyfin compatibility shim — routes mounted at /emby/* AND
|
||||||
// the root path so Infuse / Yamby / Hills / Senplayer 都能自动连接。
|
// 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
|
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 适配器 ───────────────────────────────────────────────────────────
|
// ─── Discuz 适配器 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// DiscuzAdapter 基于 Discuz! X 的站点适配器。
|
// 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"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
@@ -29,21 +25,6 @@ import (
|
|||||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
"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.
|
// SubscriptionService runs the polling loop.
|
||||||
type SubscriptionService struct {
|
type SubscriptionService struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
@@ -395,418 +376,6 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
|||||||
return 0, nil
|
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 {
|
func (s *SubscriptionService) shouldSkipExistingTorrent(ctx context.Context, mediaType string, candidate siteSearchCandidate) bool {
|
||||||
if s == nil || s.downloads == nil {
|
if s == nil || s.downloads == nil {
|
||||||
return false
|
return false
|
||||||
@@ -817,309 +386,6 @@ func (s *SubscriptionService) shouldSkipExistingTorrent(ctx context.Context, med
|
|||||||
return s.downloads.TorrentExistsByName(ctx, candidate.Item.Title)
|
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 {
|
func siteSearchKeyword(sub *model.Subscription) string {
|
||||||
if sub == nil {
|
if sub == nil {
|
||||||
return ""
|
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
|
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 {
|
func telegramIsCommandText(text string) bool {
|
||||||
return strings.HasPrefix(strings.TrimSpace(text), "/") && telegramCommandName(text) != ""
|
return strings.HasPrefix(strings.TrimSpace(text), "/") && telegramCommandName(text) != ""
|
||||||
}
|
}
|
||||||
@@ -382,21 +244,6 @@ func telegramCommandName(text string) string {
|
|||||||
return cmd
|
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 命令。
|
// cmdStart 处理 /start 命令。
|
||||||
func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
|
func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
|
||||||
name := msg.From.FirstName
|
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": {},
|
||||||
|
}
|
||||||
@@ -7,69 +7,14 @@ import type { Site } from '../types'
|
|||||||
import { ManagementShortcuts } from '../components/ManagementShortcuts'
|
import { ManagementShortcuts } from '../components/ManagementShortcuts'
|
||||||
import { confirmAction } from '../components/ConfirmDialog'
|
import { confirmAction } from '../components/ConfirmDialog'
|
||||||
|
|
||||||
// ── 站点类型映射 ──
|
import { AUTH_TYPE_LABELS, defaultSiteForm, siteFormToPayload, siteToForm, SITE_TYPE_ABBR, SITE_TYPE_COLORS, SITE_TYPE_LABELS } from './sitesPageModel'
|
||||||
const SITE_TYPE_LABELS: Record<string, string> = {
|
|
||||||
nexusphp: 'NexusPHP',
|
|
||||||
gazelle: 'Gazelle',
|
|
||||||
unit3d: 'UNIT3D',
|
|
||||||
mteam: 'M-Team',
|
|
||||||
discuz: 'Discuz',
|
|
||||||
custom_rss: '自定义 RSS',
|
|
||||||
}
|
|
||||||
|
|
||||||
const SITE_TYPE_ABBR: Record<string, string> = {
|
|
||||||
nexusphp: 'NP',
|
|
||||||
gazelle: 'GZ',
|
|
||||||
unit3d: 'U3',
|
|
||||||
mteam: 'MT',
|
|
||||||
discuz: 'DZ',
|
|
||||||
custom_rss: 'RS',
|
|
||||||
}
|
|
||||||
|
|
||||||
const SITE_TYPE_COLORS: Record<string, string> = {
|
|
||||||
nexusphp: 'bg-blue-500/15 text-blue-400',
|
|
||||||
gazelle: 'bg-purple-500/15 text-purple-400',
|
|
||||||
unit3d: 'bg-orange-500/15 text-orange-400',
|
|
||||||
mteam: 'bg-green-500/15 text-green-400',
|
|
||||||
discuz: 'bg-yellow-500/15 text-yellow-400',
|
|
||||||
custom_rss: 'bg-sand-500/15 text-ink-50',
|
|
||||||
}
|
|
||||||
|
|
||||||
const AUTH_TYPE_LABELS: Record<string, string> = {
|
|
||||||
cookie: 'Cookie',
|
|
||||||
api_key: 'API Key',
|
|
||||||
auth_header: 'Auth Header',
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 默认表单 ──
|
|
||||||
const defaultForm = () => ({
|
|
||||||
name: '',
|
|
||||||
url: '',
|
|
||||||
type: 'nexusphp',
|
|
||||||
auth_type: 'cookie',
|
|
||||||
cookie: '',
|
|
||||||
api_key: '',
|
|
||||||
auth_header: '',
|
|
||||||
enabled: true,
|
|
||||||
is_default: false,
|
|
||||||
extra: '',
|
|
||||||
// 高级设置
|
|
||||||
user_agent: '',
|
|
||||||
rss_url: '',
|
|
||||||
timeout: 15,
|
|
||||||
priority: 50,
|
|
||||||
use_proxy: false,
|
|
||||||
rate_limit: false,
|
|
||||||
browser_emulation: false,
|
|
||||||
downloader: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
export function SitesPage() {
|
export function SitesPage() {
|
||||||
const [sites, setSites] = useState<Site[]>([])
|
const [sites, setSites] = useState<Site[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [form, setForm] = useState(defaultForm())
|
const [form, setForm] = useState(defaultSiteForm())
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [testingId, setTestingId] = useState<string | null>(null)
|
const [testingId, setTestingId] = useState<string | null>(null)
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
@@ -93,7 +38,7 @@ export function SitesPage() {
|
|||||||
// ── 弹窗操作 ──
|
// ── 弹窗操作 ──
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
setForm(defaultForm())
|
setForm(defaultSiteForm())
|
||||||
setAdvancedOpen(false)
|
setAdvancedOpen(false)
|
||||||
setShowModal(true)
|
setShowModal(true)
|
||||||
}
|
}
|
||||||
@@ -103,27 +48,7 @@ export function SitesPage() {
|
|||||||
const res = await sitesAPI.get(id)
|
const res = await sitesAPI.get(id)
|
||||||
const s = res.data as Site
|
const s = res.data as Site
|
||||||
setEditingId(id)
|
setEditingId(id)
|
||||||
setForm({
|
setForm(siteToForm(s))
|
||||||
name: s.name || '',
|
|
||||||
url: s.url || '',
|
|
||||||
type: s.type || 'nexusphp',
|
|
||||||
auth_type: s.auth_type || 'cookie',
|
|
||||||
cookie: s.cookie || '',
|
|
||||||
api_key: s.api_key || '',
|
|
||||||
auth_header: s.auth_header || '',
|
|
||||||
enabled: s.enabled !== false,
|
|
||||||
is_default: s.is_default || false,
|
|
||||||
extra: s.extra || '',
|
|
||||||
// 高级设置
|
|
||||||
user_agent: s.user_agent || '',
|
|
||||||
rss_url: s.rss_url || '',
|
|
||||||
timeout: s.timeout ?? 15,
|
|
||||||
priority: s.priority ?? 50,
|
|
||||||
use_proxy: s.use_proxy || false,
|
|
||||||
rate_limit: s.rate_limit || false,
|
|
||||||
browser_emulation: s.browser_emulation || false,
|
|
||||||
downloader: s.downloader || '',
|
|
||||||
})
|
|
||||||
setAdvancedOpen(false)
|
setAdvancedOpen(false)
|
||||||
setShowModal(true)
|
setShowModal(true)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -139,26 +64,7 @@ export function SitesPage() {
|
|||||||
// ── 保存(支持静默模式)──
|
// ── 保存(支持静默模式)──
|
||||||
const silentSave = async (): Promise<boolean> => {
|
const silentSave = async (): Promise<boolean> => {
|
||||||
if (!form.name.trim() || !form.url.trim()) return false
|
if (!form.name.trim() || !form.url.trim()) return false
|
||||||
const payload: Record<string, unknown> = {
|
const payload = siteFormToPayload(form, !editingId)
|
||||||
name: form.name.trim(),
|
|
||||||
url: form.url.trim(),
|
|
||||||
type: form.type,
|
|
||||||
auth_type: form.auth_type,
|
|
||||||
enabled: form.enabled,
|
|
||||||
is_default: form.is_default,
|
|
||||||
extra: form.extra || '',
|
|
||||||
user_agent: form.user_agent || '',
|
|
||||||
rss_url: form.rss_url || '',
|
|
||||||
timeout: Number(form.timeout) || 15,
|
|
||||||
priority: Number(form.priority) || 50,
|
|
||||||
use_proxy: !!form.use_proxy,
|
|
||||||
rate_limit: !!form.rate_limit,
|
|
||||||
browser_emulation: !!form.browser_emulation,
|
|
||||||
downloader: form.downloader || '',
|
|
||||||
}
|
|
||||||
if (!editingId || form.cookie.trim()) payload.cookie = form.cookie.trim()
|
|
||||||
if (!editingId || form.api_key.trim()) payload.api_key = form.api_key.trim()
|
|
||||||
if (!editingId || form.auth_header.trim()) payload.auth_header = form.auth_header.trim()
|
|
||||||
try {
|
try {
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
await sitesAPI.update(editingId, payload)
|
await sitesAPI.update(editingId, payload)
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { Site } from '../types'
|
||||||
|
|
||||||
|
// ── 站点类型映射 ──
|
||||||
|
export const SITE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
nexusphp: 'NexusPHP',
|
||||||
|
gazelle: 'Gazelle',
|
||||||
|
unit3d: 'UNIT3D',
|
||||||
|
mteam: 'M-Team',
|
||||||
|
discuz: 'Discuz',
|
||||||
|
custom_rss: '自定义 RSS',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SITE_TYPE_ABBR: Record<string, string> = {
|
||||||
|
nexusphp: 'NP',
|
||||||
|
gazelle: 'GZ',
|
||||||
|
unit3d: 'U3',
|
||||||
|
mteam: 'MT',
|
||||||
|
discuz: 'DZ',
|
||||||
|
custom_rss: 'RS',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SITE_TYPE_COLORS: Record<string, string> = {
|
||||||
|
nexusphp: 'bg-blue-500/15 text-blue-400',
|
||||||
|
gazelle: 'bg-purple-500/15 text-purple-400',
|
||||||
|
unit3d: 'bg-orange-500/15 text-orange-400',
|
||||||
|
mteam: 'bg-green-500/15 text-green-400',
|
||||||
|
discuz: 'bg-yellow-500/15 text-yellow-400',
|
||||||
|
custom_rss: 'bg-sand-500/15 text-ink-50',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTH_TYPE_LABELS: Record<string, string> = {
|
||||||
|
cookie: 'Cookie',
|
||||||
|
api_key: 'API Key',
|
||||||
|
auth_header: 'Auth Header',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 默认表单 ──
|
||||||
|
export const defaultSiteForm = () => ({
|
||||||
|
name: '',
|
||||||
|
url: '',
|
||||||
|
type: 'nexusphp',
|
||||||
|
auth_type: 'cookie',
|
||||||
|
cookie: '',
|
||||||
|
api_key: '',
|
||||||
|
auth_header: '',
|
||||||
|
enabled: true,
|
||||||
|
is_default: false,
|
||||||
|
extra: '',
|
||||||
|
// 高级设置
|
||||||
|
user_agent: '',
|
||||||
|
rss_url: '',
|
||||||
|
timeout: 15,
|
||||||
|
priority: 50,
|
||||||
|
use_proxy: false,
|
||||||
|
rate_limit: false,
|
||||||
|
browser_emulation: false,
|
||||||
|
downloader: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
export type SiteForm = ReturnType<typeof defaultSiteForm>
|
||||||
|
|
||||||
|
export function siteToForm(site: Site): SiteForm {
|
||||||
|
return {
|
||||||
|
name: site.name || '',
|
||||||
|
url: site.url || '',
|
||||||
|
type: site.type || 'nexusphp',
|
||||||
|
auth_type: site.auth_type || 'cookie',
|
||||||
|
cookie: site.cookie || '',
|
||||||
|
api_key: site.api_key || '',
|
||||||
|
auth_header: site.auth_header || '',
|
||||||
|
enabled: site.enabled !== false,
|
||||||
|
is_default: site.is_default || false,
|
||||||
|
extra: site.extra || '',
|
||||||
|
user_agent: site.user_agent || '',
|
||||||
|
rss_url: site.rss_url || '',
|
||||||
|
timeout: site.timeout ?? 15,
|
||||||
|
priority: site.priority ?? 50,
|
||||||
|
use_proxy: site.use_proxy || false,
|
||||||
|
rate_limit: site.rate_limit || false,
|
||||||
|
browser_emulation: site.browser_emulation || false,
|
||||||
|
downloader: site.downloader || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function siteFormToPayload(form: SiteForm, includeEmptySecrets: boolean): Record<string, unknown> {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
url: form.url.trim(),
|
||||||
|
type: form.type,
|
||||||
|
auth_type: form.auth_type,
|
||||||
|
enabled: form.enabled,
|
||||||
|
is_default: form.is_default,
|
||||||
|
extra: form.extra || '',
|
||||||
|
user_agent: form.user_agent || '',
|
||||||
|
rss_url: form.rss_url || '',
|
||||||
|
timeout: Number(form.timeout) || 15,
|
||||||
|
priority: Number(form.priority) || 50,
|
||||||
|
use_proxy: !!form.use_proxy,
|
||||||
|
rate_limit: !!form.rate_limit,
|
||||||
|
browser_emulation: !!form.browser_emulation,
|
||||||
|
downloader: form.downloader || '',
|
||||||
|
}
|
||||||
|
if (includeEmptySecrets || form.cookie.trim()) payload.cookie = form.cookie.trim()
|
||||||
|
if (includeEmptySecrets || form.api_key.trim()) payload.api_key = form.api_key.trim()
|
||||||
|
if (includeEmptySecrets || form.auth_header.trim()) payload.auth_header = form.auth_header.trim()
|
||||||
|
return payload
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user