refactor: split oversized route and subscription modules

This commit is contained in:
ShukeBta
2026-06-07 14:59:32 +08:00
parent b35b36738e
commit 33698daa3f
14 changed files with 1774 additions and 1679 deletions
-332
View File
@@ -839,338 +839,6 @@ func parseUNIT3DJSON(data []byte, siteName, baseURL string) (*SiteSearchResult,
return result, nil
}
// ─── MTeam 适配器 ────────────────────────────────────────────────────────────
// MTeamAdapter MTeam.cc 独立站适配器。
type MTeamAdapter struct {
client *http.Client
}
// NewMTeamAdapter 创建 MTeam 适配器。
func NewMTeamAdapter() *MTeamAdapter {
return &MTeamAdapter{
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
if strings.TrimSpace(cfg.APIKey) == "" {
return fmt.Errorf("M-Team 需要填写 API Access Token(控制台 → 实验室 → 存取令牌),不能使用 Cookie 访问开放 API")
}
// 与 ShukeBta/MediaStation 参考实现对齐:
// 用 camelCase 参数(pageNumber / pageSize),同时接受 code 为字符串 "0"
// 或数值 0;兼容 M-Team v3 API 不同版本的返回。
u := cfg.URL + "/api/torrent/search"
payload := `{"pageNumber":1,"pageSize":1,"mode":"all"}`
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte(payload))
if err != nil {
return fmt.Errorf("authenticate: %w", err)
}
preview := string(data)
if len(preview) > 400 {
preview = preview[:400] + "..."
}
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return fmt.Errorf("authentication failed: status %d, body=%s", status, preview)
}
if status >= 300 && status < 400 {
return fmt.Errorf("authentication failed: HTTP %d (API Key 无效或未登录), body=%s", status, preview)
}
if status != http.StatusOK {
return fmt.Errorf("authenticate failed: status %d, body=%s", status, preview)
}
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("parse response: %w (body=%s)", err, preview)
}
if mteamCodeOK(resp["code"]) {
return nil
}
msg, _ := resp["message"].(string)
if msg == "" {
msg = fmt.Sprintf("code=%s", mteamCodeString(resp["code"]))
}
return fmt.Errorf("authentication failed: %s (body=%s)", msg, preview)
}
func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
// 与参考项目对齐:使用 camelCase 字段名,page 从 1 开始。
if page <= 0 {
page = 1
}
payload := map[string]interface{}{
"keyword": keyword,
"pageNumber": page,
"pageSize": 50,
}
body, _ := json.Marshal(payload)
u := cfg.URL + "/api/torrent/search"
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, body)
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
if status != http.StatusOK {
return nil, fmt.Errorf("search failed: status %d", status)
}
return parseMTeamJSON(data, cfg.Name, cfg.URL)
}
func (a *MTeamAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
if page <= 0 {
page = 1
}
payload := map[string]interface{}{
"keyword": "",
"pageNumber": page,
"pageSize": 50,
}
if category != "" {
payload["categories"] = []string{category}
}
body, _ := json.Marshal(payload)
u := cfg.URL + "/api/torrent/search"
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, body)
if err != nil {
return nil, fmt.Errorf("browse: %w", err)
}
if status != http.StatusOK {
return nil, fmt.Errorf("browse failed: status %d", status)
}
return parseMTeamJSON(data, cfg.Name, cfg.URL)
}
func (a *MTeamAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
u := cfg.URL + "/api/torrent/detail?id=" + url.QueryEscape(id)
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, nil)
if err != nil {
return nil, fmt.Errorf("detail: %w", err)
}
if status != http.StatusOK {
return nil, fmt.Errorf("detail failed: status %d", status)
}
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
dataField, ok := resp["data"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("detail not found")
}
detail := &TorrentDetail{
ID: id,
DetailURL: cfg.URL + "/detail/" + id,
}
if v, ok := dataField["name"].(string); ok {
detail.Title = v
}
if v, ok := dataField["subtitle"].(string); ok {
detail.Subtitle = v
}
if v, ok := dataField["size"].(float64); ok {
detail.Size = int64(v)
}
if v, ok := dataField["status"].(map[string]interface{}); ok {
if seeders, ok := v["seeders"].(float64); ok {
detail.Seeders = int(seeders)
}
if leechers, ok := v["leechers"].(float64); ok {
detail.Leechers = int(leechers)
}
if snatched, ok := v["completed"].(float64); ok {
detail.Snatched = int(snatched)
}
}
if v, ok := dataField["free"].(bool); ok {
detail.Free = v
}
if v, ok := dataField["download"].(string); ok {
detail.DownloadURL = v
}
if v, ok := dataField["description"].(string); ok {
detail.Description = stripHTML(v)
}
return detail, nil
}
// GetDownloadURL 解析 M-Team 种子的真实下载链接。
//
// M-Team v3 流程:
//
// POST /api/torrent/genDlToken?id={tid} (带 x-api-key)
// → {"code":"0","data":"https://api.m-team.cc/api/rss/dlv2?sign=..."}
//
// 拿到的 sign URL 可被任何下载客户端无认证地直接 GET。这是参考项目
// (ShukeBta/MediaStation) 的 _download_torrent_file 方法的子集。
func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
u := cfg.URL + "/api/torrent/genDlToken?id=" + id
// genDlToken 是 POST 但参数走 query string;body 留空。
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte("{}"))
if err != nil {
return "", fmt.Errorf("genDlToken: %w", err)
}
if status >= 300 {
return "", fmt.Errorf("genDlToken: HTTP %d", status)
}
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err != nil {
return "", fmt.Errorf("genDlToken parse: %w", err)
}
codeStr := ""
switch v := resp["code"].(type) {
case string:
codeStr = v
case float64:
codeStr = strconv.Itoa(int(v))
}
if codeStr != "0" && codeStr != "200" {
msg, _ := resp["message"].(string)
if msg == "" {
msg = "unknown error"
}
return "", fmt.Errorf("genDlToken: %s", msg)
}
dl, _ := resp["data"].(string)
if dl == "" {
return "", fmt.Errorf("genDlToken: empty data field")
}
return dl, nil
}
// parseMTeamJSON 解析 MTeam v3 JSON 响应。
//
// 响应结构(与 ShukeBta/MediaStation 参考项目一致):
//
// {
// "code": "0", // 字符串 "0" 表示成功
// "message": "SUCCESS",
// "data": {
// "total": "123",
// "data": [ ... ] // 旧字段名 "lists" 已被替换为 "data"
// }
// }
func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
// 用 map 反序列化以兼容 code/total 既可能是字符串又可能是数字。
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parse JSON: %w", err)
}
// code 兼容字符串与数字。
codeStr := ""
switch v := raw["code"].(type) {
case string:
codeStr = v
case float64:
codeStr = strconv.Itoa(int(v))
}
if codeStr != "" && codeStr != "0" && codeStr != "200" {
msg, _ := raw["message"].(string)
if msg == "" {
msg = fmt.Sprintf("code=%s", codeStr)
}
return nil, fmt.Errorf("mteam: %s", msg)
}
dataField, _ := raw["data"].(map[string]interface{})
if dataField == nil {
return &SiteSearchResult{SiteName: siteName, Items: []TorrentItem{}}, nil
}
// total 兼容字符串与数字。
total := 0
switch v := dataField["total"].(type) {
case string:
total, _ = strconv.Atoi(v)
case float64:
total = int(v)
}
// data.data(v3)优先;兜底兼容旧的 data.lists。
var rows []interface{}
switch v := dataField["data"].(type) {
case []interface{}:
rows = v
}
if rows == nil {
if v, ok := dataField["lists"].([]interface{}); ok {
rows = v
}
}
result := &SiteSearchResult{
SiteName: siteName,
Items: []TorrentItem{},
Total: total,
}
for _, rawT := range rows {
t, ok := rawT.(map[string]interface{})
if !ok {
continue
}
item := TorrentItem{}
if v, ok := t["id"].(string); ok {
item.ID = v
} else if v, ok := t["id"].(float64); ok {
item.ID = strconv.Itoa(int(v))
}
if v, ok := t["name"].(string); ok {
item.Title = v
}
if v, ok := t["subtitle"].(string); ok {
item.Subtitle = v
}
if v, ok := t["category"].(map[string]interface{}); ok {
if name, ok := v["name"].(string); ok {
item.Category = name
}
}
if v, ok := t["size"].(float64); ok {
item.Size = int64(v)
} else if v, ok := t["size"].(string); ok {
// v3 API 把 size 序列化成字符串。
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
item.Size = n
}
}
if v, ok := t["status"].(map[string]interface{}); ok {
if seeders, ok := v["seeders"].(float64); ok {
item.Seeders = int(seeders)
}
if leechers, ok := v["leechers"].(float64); ok {
item.Leechers = int(leechers)
}
if snatched, ok := v["completed"].(float64); ok {
item.Snatched = int(snatched)
}
}
if v, ok := t["free"].(bool); ok {
item.Free = v
}
if v, ok := t["uploadTime"].(float64); ok {
item.UploadTime = time.Unix(int64(v), 0)
}
item.DetailURL = baseURL + "/detail/" + item.ID
// 标记 download_url 指向 genDlToken;真正的下载链接由 handler 层
// 在用户点"下载"时通过 MTeamAdapter.GetDownloadURL 解析。
// 这样前端 SiteSearchPage 才知道这一行有可用的下载入口。
item.DownloadURL = baseURL + "/api/torrent/genDlToken?id=" + item.ID
result.Items = append(result.Items, item)
}
return result, nil
}
// ─── Discuz 适配器 ───────────────────────────────────────────────────────────
// DiscuzAdapter 基于 Discuz! X 的站点适配器。
+345
View File
@@ -0,0 +1,345 @@
// Package service — M-Team site adapter.
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// ─── MTeam 适配器 ────────────────────────────────────────────────────────────
// MTeamAdapter MTeam.cc 独立站适配器。
type MTeamAdapter struct {
client *http.Client
}
// NewMTeamAdapter 创建 MTeam 适配器。
func NewMTeamAdapter() *MTeamAdapter {
return &MTeamAdapter{
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
if strings.TrimSpace(cfg.APIKey) == "" {
return fmt.Errorf("M-Team 需要填写 API Access Token(控制台 → 实验室 → 存取令牌),不能使用 Cookie 访问开放 API")
}
// 与 ShukeBta/MediaStation 参考实现对齐:
// 用 camelCase 参数(pageNumber / pageSize),同时接受 code 为字符串 "0"
// 或数值 0;兼容 M-Team v3 API 不同版本的返回。
u := cfg.URL + "/api/torrent/search"
payload := `{"pageNumber":1,"pageSize":1,"mode":"all"}`
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte(payload))
if err != nil {
return fmt.Errorf("authenticate: %w", err)
}
preview := string(data)
if len(preview) > 400 {
preview = preview[:400] + "..."
}
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return fmt.Errorf("authentication failed: status %d, body=%s", status, preview)
}
if status >= 300 && status < 400 {
return fmt.Errorf("authentication failed: HTTP %d (API Key 无效或未登录), body=%s", status, preview)
}
if status != http.StatusOK {
return fmt.Errorf("authenticate failed: status %d, body=%s", status, preview)
}
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("parse response: %w (body=%s)", err, preview)
}
if mteamCodeOK(resp["code"]) {
return nil
}
msg, _ := resp["message"].(string)
if msg == "" {
msg = fmt.Sprintf("code=%s", mteamCodeString(resp["code"]))
}
return fmt.Errorf("authentication failed: %s (body=%s)", msg, preview)
}
func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
// 与参考项目对齐:使用 camelCase 字段名,page 从 1 开始。
if page <= 0 {
page = 1
}
payload := map[string]interface{}{
"keyword": keyword,
"pageNumber": page,
"pageSize": 50,
}
body, _ := json.Marshal(payload)
u := cfg.URL + "/api/torrent/search"
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, body)
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
if status != http.StatusOK {
return nil, fmt.Errorf("search failed: status %d", status)
}
return parseMTeamJSON(data, cfg.Name, cfg.URL)
}
func (a *MTeamAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
if page <= 0 {
page = 1
}
payload := map[string]interface{}{
"keyword": "",
"pageNumber": page,
"pageSize": 50,
}
if category != "" {
payload["categories"] = []string{category}
}
body, _ := json.Marshal(payload)
u := cfg.URL + "/api/torrent/search"
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, body)
if err != nil {
return nil, fmt.Errorf("browse: %w", err)
}
if status != http.StatusOK {
return nil, fmt.Errorf("browse failed: status %d", status)
}
return parseMTeamJSON(data, cfg.Name, cfg.URL)
}
func (a *MTeamAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
u := cfg.URL + "/api/torrent/detail?id=" + url.QueryEscape(id)
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, nil)
if err != nil {
return nil, fmt.Errorf("detail: %w", err)
}
if status != http.StatusOK {
return nil, fmt.Errorf("detail failed: status %d", status)
}
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
dataField, ok := resp["data"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("detail not found")
}
detail := &TorrentDetail{
ID: id,
DetailURL: cfg.URL + "/detail/" + id,
}
if v, ok := dataField["name"].(string); ok {
detail.Title = v
}
if v, ok := dataField["subtitle"].(string); ok {
detail.Subtitle = v
}
if v, ok := dataField["size"].(float64); ok {
detail.Size = int64(v)
}
if v, ok := dataField["status"].(map[string]interface{}); ok {
if seeders, ok := v["seeders"].(float64); ok {
detail.Seeders = int(seeders)
}
if leechers, ok := v["leechers"].(float64); ok {
detail.Leechers = int(leechers)
}
if snatched, ok := v["completed"].(float64); ok {
detail.Snatched = int(snatched)
}
}
if v, ok := dataField["free"].(bool); ok {
detail.Free = v
}
if v, ok := dataField["download"].(string); ok {
detail.DownloadURL = v
}
if v, ok := dataField["description"].(string); ok {
detail.Description = stripHTML(v)
}
return detail, nil
}
// GetDownloadURL 解析 M-Team 种子的真实下载链接。
//
// M-Team v3 流程:
//
// POST /api/torrent/genDlToken?id={tid} (带 x-api-key)
// → {"code":"0","data":"https://api.m-team.cc/api/rss/dlv2?sign=..."}
//
// 拿到的 sign URL 可被任何下载客户端无认证地直接 GET。这是参考项目
// (ShukeBta/MediaStation) 的 _download_torrent_file 方法的子集。
func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
u := cfg.URL + "/api/torrent/genDlToken?id=" + id
// genDlToken 是 POST 但参数走 query string;body 留空。
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte("{}"))
if err != nil {
return "", fmt.Errorf("genDlToken: %w", err)
}
if status >= 300 {
return "", fmt.Errorf("genDlToken: HTTP %d", status)
}
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err != nil {
return "", fmt.Errorf("genDlToken parse: %w", err)
}
codeStr := ""
switch v := resp["code"].(type) {
case string:
codeStr = v
case float64:
codeStr = strconv.Itoa(int(v))
}
if codeStr != "0" && codeStr != "200" {
msg, _ := resp["message"].(string)
if msg == "" {
msg = "unknown error"
}
return "", fmt.Errorf("genDlToken: %s", msg)
}
dl, _ := resp["data"].(string)
if dl == "" {
return "", fmt.Errorf("genDlToken: empty data field")
}
return dl, nil
}
// parseMTeamJSON 解析 MTeam v3 JSON 响应。
//
// 响应结构(与 ShukeBta/MediaStation 参考项目一致):
//
// {
// "code": "0", // 字符串 "0" 表示成功
// "message": "SUCCESS",
// "data": {
// "total": "123",
// "data": [ ... ] // 旧字段名 "lists" 已被替换为 "data"
// }
// }
func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
// 用 map 反序列化以兼容 code/total 既可能是字符串又可能是数字。
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parse JSON: %w", err)
}
// code 兼容字符串与数字。
codeStr := ""
switch v := raw["code"].(type) {
case string:
codeStr = v
case float64:
codeStr = strconv.Itoa(int(v))
}
if codeStr != "" && codeStr != "0" && codeStr != "200" {
msg, _ := raw["message"].(string)
if msg == "" {
msg = fmt.Sprintf("code=%s", codeStr)
}
return nil, fmt.Errorf("mteam: %s", msg)
}
dataField, _ := raw["data"].(map[string]interface{})
if dataField == nil {
return &SiteSearchResult{SiteName: siteName, Items: []TorrentItem{}}, nil
}
// total 兼容字符串与数字。
total := 0
switch v := dataField["total"].(type) {
case string:
total, _ = strconv.Atoi(v)
case float64:
total = int(v)
}
// data.data(v3)优先;兜底兼容旧的 data.lists。
var rows []interface{}
switch v := dataField["data"].(type) {
case []interface{}:
rows = v
}
if rows == nil {
if v, ok := dataField["lists"].([]interface{}); ok {
rows = v
}
}
result := &SiteSearchResult{
SiteName: siteName,
Items: []TorrentItem{},
Total: total,
}
for _, rawT := range rows {
t, ok := rawT.(map[string]interface{})
if !ok {
continue
}
item := TorrentItem{}
if v, ok := t["id"].(string); ok {
item.ID = v
} else if v, ok := t["id"].(float64); ok {
item.ID = strconv.Itoa(int(v))
}
if v, ok := t["name"].(string); ok {
item.Title = v
}
if v, ok := t["subtitle"].(string); ok {
item.Subtitle = v
}
if v, ok := t["category"].(map[string]interface{}); ok {
if name, ok := v["name"].(string); ok {
item.Category = name
}
}
if v, ok := t["size"].(float64); ok {
item.Size = int64(v)
} else if v, ok := t["size"].(string); ok {
// v3 API 把 size 序列化成字符串。
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
item.Size = n
}
}
if v, ok := t["status"].(map[string]interface{}); ok {
if seeders, ok := v["seeders"].(float64); ok {
item.Seeders = int(seeders)
}
if leechers, ok := v["leechers"].(float64); ok {
item.Leechers = int(leechers)
}
if snatched, ok := v["completed"].(float64); ok {
item.Snatched = int(snatched)
}
}
if v, ok := t["free"].(bool); ok {
item.Free = v
}
if v, ok := t["uploadTime"].(float64); ok {
item.UploadTime = time.Unix(int64(v), 0)
}
item.DetailURL = baseURL + "/detail/" + item.ID
// 标记 download_url 指向 genDlToken;真正的下载链接由 handler 层
// 在用户点"下载"时通过 MTeamAdapter.GetDownloadURL 解析。
// 这样前端 SiteSearchPage 才知道这一行有可用的下载入口。
item.DownloadURL = baseURL + "/api/torrent/genDlToken?id=" + item.ID
result.Items = append(result.Items, item)
}
return result, nil
}
-734
View File
@@ -14,13 +14,9 @@ import (
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"unicode"
"go.uber.org/zap"
@@ -29,21 +25,6 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
var (
seriesPackRE = regexp.MustCompile(`(?i)(complete|batch|合集|全集|全\s*\d+\s*[集话話期]|整季|全季|s\d{1,2}\s*(?:complete|batch|pack)|season\s*\d{1,2}\s*(?:complete|batch|pack)|s\d{1,2}e\d{1,3}\s*[-~–—]\s*(?:e)?\d{1,3}|第\s*\d+\s*[-~–—]\s*\d+\s*[集话話期])`)
seasonOnlyRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\s*\d{1,2}(?:[\s._-]|$)|第\s*\d+\s*季`)
)
type siteSearchCandidate struct {
Item SearchResult
Download string
GUID string
Season int
Episode int
Pack bool
Score int
}
// SubscriptionService runs the polling loop.
type SubscriptionService struct {
cfg *config.Config
@@ -395,418 +376,6 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
return 0, nil
}
func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, availability ...LocalAvailability) []siteSearchCandidate {
candidates := make([]siteSearchCandidate, 0, len(results))
for _, item := range results {
if !matchesSubscriptionRules(sub, item.Title) {
continue
}
download := strings.TrimSpace(item.DownloadURL)
if download == "" {
download = strings.TrimSpace(item.TorrentURL)
}
if download == "" {
continue
}
guid := stableSiteSearchGUID(item, download)
if _, ok := seenSet[guid]; ok {
continue
}
season, episode := ParseEpisode(item.Title)
score := subscriptionCandidateScore(sub, item)
candidates = append(candidates, siteSearchCandidate{
Item: item,
Download: download,
GUID: guid,
Season: season,
Episode: episode,
Pack: isSeriesPackTitle(item.Title),
Score: score,
})
}
if len(candidates) > 1 {
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].Score != candidates[j].Score {
return candidates[i].Score > candidates[j].Score
}
if candidates[i].Item.Seeders != candidates[j].Item.Seeders {
return candidates[i].Item.Seeders > candidates[j].Item.Seeders
}
return candidates[i].Item.Size > candidates[j].Item.Size
})
}
if len(candidates) == 0 {
return nil
}
var local LocalAvailability
if len(availability) > 0 {
local = availability[0]
}
mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "")
if !isSubscriptionSeriesType(mediaType) {
// 对齐 MoviePilot:非洗版订阅成功下载一次即满足,媒体库/下载中已存在则不再重复下载。
if (sub == nil || !sub.WashEnabled) && local.LocalMediaCount > 0 {
return nil
}
return candidates[:1]
}
if local.HasSeriesPack {
return nil
}
if local.LocalMediaCount > 0 {
if local.TotalEpisodes > 0 && len(local.MissingEpisodes) == 0 {
return nil
}
missingSet := missingEpisodeSet(local)
onlyMissing := make([]siteSearchCandidate, 0, len(candidates))
for _, candidate := range candidates {
if candidate.Episode <= 0 {
continue
}
season := candidate.Season
if season <= 0 {
season = 1
}
if _, exists := local.ExistingEpisodeKeys[episodeKey(season, candidate.Episode)]; exists {
continue
}
if local.TotalEpisodes > 0 {
if _, missing := missingSet[candidate.Episode]; !missing {
continue
}
}
onlyMissing = append(onlyMissing, candidate)
}
return sortedEpisodeCandidates(onlyMissing)
}
for _, candidate := range candidates {
if candidate.Pack {
return []siteSearchCandidate{candidate}
}
}
selected := sortedEpisodeCandidates(candidates)
if len(selected) == 0 {
return candidates[:1]
}
return selected
}
func stableRSSItemGUID(title, guid, link, enclosureURL string) string {
parts := []string{"rss", strings.ToLower(strings.TrimSpace(title))}
for _, raw := range []string{guid, enclosureURL, link} {
if key := stableDownloadURLKey(raw); key != "" {
parts = append(parts, key)
return strings.Join(parts, "|")
}
if raw = strings.TrimSpace(raw); raw != "" {
parts = append(parts, strings.ToLower(raw))
return strings.Join(parts, "|")
}
}
return strings.Join(parts, "|")
}
func stableSiteSearchGUID(item SearchResult, download string) string {
parts := []string{
"site",
strings.ToLower(strings.TrimSpace(firstNonEmpty(item.SiteID, item.SiteName))),
strings.ToLower(strings.TrimSpace(item.Category)),
strings.ToLower(strings.TrimSpace(item.Title)),
fmt.Sprintf("%d", item.Size),
}
if key := stableDownloadURLKey(download); key != "" {
parts = append(parts, key)
}
return strings.Join(parts, "|")
}
func stableDownloadURLKey(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil {
return strings.ToLower(raw)
}
if strings.EqualFold(u.Scheme, "magnet") {
xt := strings.ToLower(strings.TrimSpace(u.Query().Get("xt")))
if xt != "" {
return "magnet:" + xt
}
return strings.ToLower(raw)
}
if u.Host == "" {
return strings.ToLower(raw)
}
q := u.Query()
kept := make([]string, 0, 4)
for _, key := range []string{"id", "tid", "torrent", "torrent_id", "torrentid", "hash", "info_hash"} {
if value := strings.TrimSpace(q.Get(key)); value != "" {
kept = append(kept, key+"="+strings.ToLower(value))
}
}
base := strings.ToLower(strings.TrimRight(u.Host, "/") + "/" + strings.TrimLeft(u.Path, "/"))
if len(kept) > 0 {
return base + "?" + strings.Join(kept, "&")
}
return base
}
// defaultExcludeWords 是参考 MoviePilot 默认过滤的「垃圾版本」排除清单,对所有订阅生效,
// 与用户自定义排除词合并。拉丁词在 containsAnyExcludeToken 里按词边界匹配以避免子串误伤。
const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample"
func matchesSubscriptionRules(sub *model.Subscription, title string) bool {
titleFold := strings.ToLower(title)
if containsAnyExcludeToken(titleFold, defaultExcludeWords) {
return false
}
if sub == nil {
return true
}
if sub.ExcludeWords != "" && containsAnyExcludeToken(titleFold, sub.ExcludeWords) {
return false
}
if sub.ReleaseGroups != "" && !containsAnyToken(titleFold, sub.ReleaseGroups) {
return false
}
if sub.Resolution != "" && sub.Resolution != "best" && !titleMatchesResolution(titleFold, sub.Resolution) {
return false
}
if sub.Quality != "" && sub.Quality != "best" && !titleMatchesQuality(titleFold, sub.Quality) {
return false
}
if sub.Effects != "" && !containsAnyEffect(titleFold, sub.Effects) {
return false
}
return true
}
func subscriptionCandidateScore(sub *model.Subscription, item SearchResult) int {
title := strings.ToLower(item.Title)
score := item.Seeders
if sub == nil || !sub.WashEnabled {
if item.Free {
score += 25
}
return score
}
resolutionScore := detectResolutionScore(title)
qualityScore := detectQualityScore(title)
effectScore := detectEffectScore(title)
priority := "balanced"
if sub != nil && strings.TrimSpace(sub.WashPriority) != "" {
priority = strings.ToLower(strings.TrimSpace(sub.WashPriority))
}
switch priority {
case "resolution":
score += resolutionScore*1000 + qualityScore*100 + effectScore*50
case "quality":
score += qualityScore*1000 + resolutionScore*200 + effectScore*50
case "effects":
score += effectScore*1000 + resolutionScore*200 + qualityScore*100
case "seeders":
score += qualityScore*3 + resolutionScore*2 + effectScore
default:
score += resolutionScore*500 + qualityScore*300 + effectScore*150
}
if item.Free {
score += 25
}
return score
}
func containsAnyToken(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token != "" && strings.Contains(titleFold, token) {
return true
}
}
return false
}
// containsAnyExcludeToken 用于排除词匹配:纯 ASCII 字母数字的词按词边界匹配(避免 "ts"
// 误伤 "tsukihime"、"cam" 误伤 "camp" 之类的子串误判),含 CJK/符号的词仍按子串匹配。
func containsAnyExcludeToken(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token == "" {
continue
}
if isASCIIWordToken(token) {
if matchesWordBoundary(titleFold, token) {
return true
}
continue
}
if strings.Contains(titleFold, token) {
return true
}
}
return false
}
func isASCIIWordToken(token string) bool {
for _, r := range token {
if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r)) {
return false
}
}
return token != ""
}
// matchesWordBoundary 判断 token 是否作为独立词出现在 title 中,词边界为「非字母数字」。
func matchesWordBoundary(titleFold, token string) bool {
isWordRune := func(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
from := 0
for {
idx := strings.Index(titleFold[from:], token)
if idx < 0 {
return false
}
start := from + idx
end := start + len(token)
leftOK := start == 0 || !isWordRune(rune(titleFold[start-1]))
rightOK := end >= len(titleFold) || !isWordRune(rune(titleFold[end]))
if leftOK && rightOK {
return true
}
from = start + 1
if from >= len(titleFold) {
return false
}
}
}
func containsAnyEffect(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token == "" {
continue
}
switch token {
case "dolby-vision", "dolby vision", "dv":
if strings.Contains(titleFold, "dolby vision") || strings.Contains(titleFold, "dovi") || regexp.MustCompile(`\bdv\b`).MatchString(titleFold) {
return true
}
default:
if strings.Contains(titleFold, token) {
return true
}
}
}
return false
}
func titleMatchesResolution(titleFold, resolution string) bool {
switch strings.ToLower(strings.TrimSpace(resolution)) {
case "2160p", "4k", "uhd":
return strings.Contains(titleFold, "2160p") || strings.Contains(titleFold, "4k") || strings.Contains(titleFold, "uhd")
case "1080p":
return strings.Contains(titleFold, "1080p") || strings.Contains(titleFold, "fhd")
case "720p":
return strings.Contains(titleFold, "720p")
default:
return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(resolution)))
}
}
func titleMatchesQuality(titleFold, quality string) bool {
switch strings.ToLower(strings.TrimSpace(quality)) {
case "webdl", "web-dl":
return strings.Contains(titleFold, "web-dl") || strings.Contains(titleFold, "webdl")
case "bluray", "blu-ray":
return strings.Contains(titleFold, "bluray") || strings.Contains(titleFold, "blu-ray") || strings.Contains(titleFold, "bdrip")
case "remux":
return strings.Contains(titleFold, "remux")
case "hdtv":
return strings.Contains(titleFold, "hdtv")
default:
return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(quality)))
}
}
func detectResolutionScore(titleFold string) int {
switch {
case titleMatchesResolution(titleFold, "2160p"):
return 4
case titleMatchesResolution(titleFold, "1080p"):
return 3
case titleMatchesResolution(titleFold, "720p"):
return 2
default:
return 1
}
}
func detectQualityScore(titleFold string) int {
switch {
case titleMatchesQuality(titleFold, "remux"):
return 5
case titleMatchesQuality(titleFold, "bluray"):
return 4
case titleMatchesQuality(titleFold, "web-dl"):
return 3
case titleMatchesQuality(titleFold, "hdtv"):
return 2
default:
return 1
}
}
func detectEffectScore(titleFold string) int {
score := 0
if containsAnyEffect(titleFold, "dolby-vision") {
score += 4
}
if strings.Contains(titleFold, "hdr10+") {
score += 3
} else if strings.Contains(titleFold, "hdr") {
score += 2
}
if strings.Contains(titleFold, "atmos") {
score += 2
}
return score
}
func isSubscriptionSeriesType(mediaType string) bool {
switch normalizeMediaType(mediaType, "", "") {
case "tv", "anime", "variety":
return true
default:
return false
}
}
func isSeriesPackTitle(title string) bool {
title = strings.TrimSpace(title)
if title == "" {
return false
}
if seriesPackRE.MatchString(title) {
return true
}
_, episode := ParseEpisode(title)
return episode == 0 && seasonOnlyRE.MatchString(title)
}
func (s *SubscriptionService) shouldSkipExistingTorrent(ctx context.Context, mediaType string, candidate siteSearchCandidate) bool {
if s == nil || s.downloads == nil {
return false
@@ -817,309 +386,6 @@ func (s *SubscriptionService) shouldSkipExistingTorrent(ctx context.Context, med
return s.downloads.TorrentExistsByName(ctx, candidate.Item.Title)
}
func (s *SubscriptionService) pendingDownloadAvailability(ctx context.Context, sub *model.Subscription) LocalAvailability {
out := LocalAvailability{
ExistingEpisodeKeys: map[string]struct{}{},
MissingEpisodeKeys: map[string]struct{}{},
}
if sub != nil {
out.TotalEpisodes = sub.TotalEpisodes
}
query := availabilityQuery(subscriptionName(sub), subscriptionFilter(sub))
if query == "" {
return s.finalizePendingAvailability(sub, out)
}
root := s.subscriptionBaseSavePath(ctx, sub)
if root != "" {
_ = scanDownloadPath(ctx, root, query, func(_ string, season, episode int) bool {
out.LocalMediaCount++
out.InLibrary = true
if episode > 0 {
out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{}
}
return true
})
}
s.addDownloadTaskAvailability(ctx, sub, query, &out)
s.addLiveTorrentAvailability(ctx, query, &out)
return s.finalizePendingAvailability(sub, out)
}
func (s *SubscriptionService) addDownloadTaskAvailability(ctx context.Context, sub *model.Subscription, query string, out *LocalAvailability) {
if s == nil || s.repo == nil || s.repo.Download == nil || out == nil {
return
}
rows, err := s.repo.Download.List(ctx)
if err != nil {
return
}
baseSavePath := s.subscriptionBaseSavePath(ctx, sub)
for _, row := range rows {
if !downloadTaskBlocksReadd(row.Status) {
continue
}
if baseSavePath != "" && row.SavePath != "" && !sameOrChildPath(row.SavePath, baseSavePath) && !sameOrChildPath(baseSavePath, row.SavePath) {
continue
}
addAvailabilityTitle(row.Title, query, out)
}
}
func (s *SubscriptionService) addLiveTorrentAvailability(ctx context.Context, query string, out *LocalAvailability) {
if s == nil || s.downloads == nil || s.downloads.qb == nil || out == nil {
return
}
live, err := s.downloads.qb.List(ctx, "")
if err != nil {
return
}
for _, torrent := range live {
addAvailabilityTitle(torrent.Name, query, out)
}
}
func addAvailabilityTitle(title, query string, out *LocalAvailability) {
if out == nil || strings.TrimSpace(title) == "" || strings.TrimSpace(query) == "" {
return
}
if !strings.Contains(normalizeAvailabilityComparable(title), normalizeAvailabilityComparable(query)) {
return
}
out.LocalMediaCount++
out.InLibrary = true
season, episode := ParseEpisode(title)
if episode > 0 {
out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{}
return
}
if isSeriesPackTitle(title) {
out.HasSeriesPack = true
}
}
func sameOrChildPath(pathValue, root string) bool {
pathValue = filepath.Clean(strings.TrimSpace(pathValue))
root = filepath.Clean(strings.TrimSpace(root))
if pathValue == "" || root == "" || pathValue == "." || root == "." {
return false
}
if strings.EqualFold(pathValue, root) {
return true
}
rel, err := filepath.Rel(root, pathValue)
if err != nil {
return false
}
return rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel)
}
func (s *SubscriptionService) finalizePendingAvailability(sub *model.Subscription, out LocalAvailability) LocalAvailability {
mediaType := ""
if sub != nil {
mediaType = sub.MediaType
}
if isSubscriptionSeriesType(mediaType) || len(out.ExistingEpisodeKeys) > 0 {
out.DownloadedEpisodes = len(out.ExistingEpisodeKeys)
out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes)
for _, episode := range out.MissingEpisodes {
out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{}
}
} else if out.LocalMediaCount > 0 {
out.DownloadedEpisodes = 1
if out.TotalEpisodes == 0 {
out.TotalEpisodes = 1
}
}
return out
}
func (s *SubscriptionService) subscriptionBaseSavePath(ctx context.Context, sub *model.Subscription) string {
if sub == nil {
return ""
}
base := strings.TrimSpace(sub.SavePath)
if base == "" && s != nil && s.repo != nil && s.repo.Setting != nil {
base, _ = s.repo.Setting.Get(ctx, "qbittorrent.savepath")
}
return base
}
func subscriptionName(sub *model.Subscription) string {
if sub == nil {
return ""
}
return sub.Name
}
func subscriptionFilter(sub *model.Subscription) string {
if sub == nil {
return ""
}
return sub.Filter
}
func mergeLocalAvailability(values ...LocalAvailability) LocalAvailability {
out := LocalAvailability{
ExistingEpisodeKeys: map[string]struct{}{},
MissingEpisodeKeys: map[string]struct{}{},
}
for _, value := range values {
if out.TotalEpisodes == 0 {
out.TotalEpisodes = value.TotalEpisodes
}
out.LocalMediaCount += value.LocalMediaCount
out.InLibrary = out.InLibrary || value.InLibrary
out.HasSeriesPack = out.HasSeriesPack || value.HasSeriesPack
for key := range value.ExistingEpisodeKeys {
out.ExistingEpisodeKeys[key] = struct{}{}
}
}
out.DownloadedEpisodes = len(out.ExistingEpisodeKeys)
if out.TotalEpisodes > 0 {
out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes)
for _, episode := range out.MissingEpisodes {
out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{}
}
}
if out.DownloadedEpisodes == 0 && out.LocalMediaCount > 0 {
out.DownloadedEpisodes = out.LocalMediaCount
if out.TotalEpisodes == 0 {
out.TotalEpisodes = 1
}
}
return out
}
// subscriptionItemAlreadyAvailable 判断某个订阅条目(按其标题解析出的季/集)是否已在媒体库存在。
// 电影/无集号条目:媒体库已有该片即视为已存在;剧集条目:对应季集已入库即视为已存在。
func subscriptionItemAlreadyAvailable(sub *model.Subscription, avail LocalAvailability, title string) bool {
if avail.LocalMediaCount == 0 && !avail.HasSeriesPack {
return false
}
if !isSubscriptionSeriesType(subscriptionMediaType(sub)) {
return true
}
if avail.HasSeriesPack {
return true
}
wantSeason, wantEpisode := ParseEpisode(title)
if wantEpisode <= 0 {
// 整季合集 / 无法解析集号:库里已有内容时保守跳过,避免重复整季下载。
return true
}
if wantSeason <= 0 {
wantSeason = 1
}
_, exists := avail.ExistingEpisodeKeys[episodeKey(wantSeason, wantEpisode)]
return exists
}
func subscriptionMediaType(sub *model.Subscription) string {
if sub == nil {
return ""
}
return sub.MediaType
}
func (s *SubscriptionService) downloadPathHasCandidate(ctx context.Context, sub *model.Subscription, title, savePath string) bool {
savePath = strings.TrimSpace(savePath)
if savePath == "" {
savePath = s.subscriptionBaseSavePath(ctx, sub)
}
query := availabilityQuery(title, subscriptionFilter(sub))
if savePath == "" || query == "" {
return false
}
wantSeason, wantEpisode := ParseEpisode(title)
if wantSeason <= 0 {
wantSeason = 1
}
found := false
_ = scanDownloadPath(ctx, savePath, query, func(path string, season, episode int) bool {
if wantEpisode <= 0 {
found = true
return false
}
if episode <= 0 {
return true
}
if season <= 0 {
season = 1
}
if episodeKey(season, episode) == episodeKey(wantSeason, wantEpisode) {
found = true
return false
}
return true
})
return found
}
func scanDownloadPath(ctx context.Context, root, query string, visit func(path string, season, episode int) bool) error {
if strings.TrimSpace(root) == "" {
return nil
}
info, err := os.Stat(root)
if err != nil || !info.IsDir() {
return nil
}
normalizedQuery := normalizeAvailabilityComparable(query)
if normalizedQuery == "" {
return nil
}
visited := 0
return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if ctx.Err() != nil {
return ctx.Err()
}
if d.IsDir() {
if path != root && strings.HasPrefix(filepath.Base(path), ".") {
return filepath.SkipDir
}
return nil
}
if !isDownloadMediaPath(path) {
return nil
}
visited++
if visited > 10000 {
return filepath.SkipAll
}
if !strings.Contains(normalizeAvailabilityComparable(path), normalizedQuery) {
return nil
}
season, episode := ParseEpisode(path)
if !visit(path, season, episode) {
return filepath.SkipAll
}
return nil
})
}
func isDownloadMediaPath(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".!qb", ".part", ".aria2", ".crdownload":
path = strings.TrimSuffix(path, filepath.Ext(path))
ext = strings.ToLower(filepath.Ext(path))
}
_, ok := videoExtensions[ext]
return ok
}
func normalizeAvailabilityComparable(value string) string {
var b strings.Builder
for _, r := range strings.ToLower(value) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
func siteSearchKeyword(sub *model.Subscription) string {
if sub == nil {
return ""
@@ -0,0 +1,315 @@
// Package service — subscription local and pending-download availability helpers.
package service
import (
"context"
"os"
"path/filepath"
"strings"
"unicode"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func (s *SubscriptionService) pendingDownloadAvailability(ctx context.Context, sub *model.Subscription) LocalAvailability {
out := LocalAvailability{
ExistingEpisodeKeys: map[string]struct{}{},
MissingEpisodeKeys: map[string]struct{}{},
}
if sub != nil {
out.TotalEpisodes = sub.TotalEpisodes
}
query := availabilityQuery(subscriptionName(sub), subscriptionFilter(sub))
if query == "" {
return s.finalizePendingAvailability(sub, out)
}
root := s.subscriptionBaseSavePath(ctx, sub)
if root != "" {
_ = scanDownloadPath(ctx, root, query, func(_ string, season, episode int) bool {
out.LocalMediaCount++
out.InLibrary = true
if episode > 0 {
out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{}
}
return true
})
}
s.addDownloadTaskAvailability(ctx, sub, query, &out)
s.addLiveTorrentAvailability(ctx, query, &out)
return s.finalizePendingAvailability(sub, out)
}
func (s *SubscriptionService) addDownloadTaskAvailability(ctx context.Context, sub *model.Subscription, query string, out *LocalAvailability) {
if s == nil || s.repo == nil || s.repo.Download == nil || out == nil {
return
}
rows, err := s.repo.Download.List(ctx)
if err != nil {
return
}
baseSavePath := s.subscriptionBaseSavePath(ctx, sub)
for _, row := range rows {
if !downloadTaskBlocksReadd(row.Status) {
continue
}
if baseSavePath != "" && row.SavePath != "" && !sameOrChildPath(row.SavePath, baseSavePath) && !sameOrChildPath(baseSavePath, row.SavePath) {
continue
}
addAvailabilityTitle(row.Title, query, out)
}
}
func (s *SubscriptionService) addLiveTorrentAvailability(ctx context.Context, query string, out *LocalAvailability) {
if s == nil || s.downloads == nil || s.downloads.qb == nil || out == nil {
return
}
live, err := s.downloads.qb.List(ctx, "")
if err != nil {
return
}
for _, torrent := range live {
addAvailabilityTitle(torrent.Name, query, out)
}
}
func addAvailabilityTitle(title, query string, out *LocalAvailability) {
if out == nil || strings.TrimSpace(title) == "" || strings.TrimSpace(query) == "" {
return
}
if !strings.Contains(normalizeAvailabilityComparable(title), normalizeAvailabilityComparable(query)) {
return
}
out.LocalMediaCount++
out.InLibrary = true
season, episode := ParseEpisode(title)
if episode > 0 {
out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{}
return
}
if isSeriesPackTitle(title) {
out.HasSeriesPack = true
}
}
func sameOrChildPath(pathValue, root string) bool {
pathValue = filepath.Clean(strings.TrimSpace(pathValue))
root = filepath.Clean(strings.TrimSpace(root))
if pathValue == "" || root == "" || pathValue == "." || root == "." {
return false
}
if strings.EqualFold(pathValue, root) {
return true
}
rel, err := filepath.Rel(root, pathValue)
if err != nil {
return false
}
return rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel)
}
func (s *SubscriptionService) finalizePendingAvailability(sub *model.Subscription, out LocalAvailability) LocalAvailability {
mediaType := ""
if sub != nil {
mediaType = sub.MediaType
}
if isSubscriptionSeriesType(mediaType) || len(out.ExistingEpisodeKeys) > 0 {
out.DownloadedEpisodes = len(out.ExistingEpisodeKeys)
out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes)
for _, episode := range out.MissingEpisodes {
out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{}
}
} else if out.LocalMediaCount > 0 {
out.DownloadedEpisodes = 1
if out.TotalEpisodes == 0 {
out.TotalEpisodes = 1
}
}
return out
}
func (s *SubscriptionService) subscriptionBaseSavePath(ctx context.Context, sub *model.Subscription) string {
if sub == nil {
return ""
}
base := strings.TrimSpace(sub.SavePath)
if base == "" && s != nil && s.repo != nil && s.repo.Setting != nil {
base, _ = s.repo.Setting.Get(ctx, "qbittorrent.savepath")
}
return base
}
func subscriptionName(sub *model.Subscription) string {
if sub == nil {
return ""
}
return sub.Name
}
func subscriptionFilter(sub *model.Subscription) string {
if sub == nil {
return ""
}
return sub.Filter
}
func mergeLocalAvailability(values ...LocalAvailability) LocalAvailability {
out := LocalAvailability{
ExistingEpisodeKeys: map[string]struct{}{},
MissingEpisodeKeys: map[string]struct{}{},
}
for _, value := range values {
if out.TotalEpisodes == 0 {
out.TotalEpisodes = value.TotalEpisodes
}
out.LocalMediaCount += value.LocalMediaCount
out.InLibrary = out.InLibrary || value.InLibrary
out.HasSeriesPack = out.HasSeriesPack || value.HasSeriesPack
for key := range value.ExistingEpisodeKeys {
out.ExistingEpisodeKeys[key] = struct{}{}
}
}
out.DownloadedEpisodes = len(out.ExistingEpisodeKeys)
if out.TotalEpisodes > 0 {
out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes)
for _, episode := range out.MissingEpisodes {
out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{}
}
}
if out.DownloadedEpisodes == 0 && out.LocalMediaCount > 0 {
out.DownloadedEpisodes = out.LocalMediaCount
if out.TotalEpisodes == 0 {
out.TotalEpisodes = 1
}
}
return out
}
// subscriptionItemAlreadyAvailable 判断某个订阅条目(按其标题解析出的季/集)是否已在媒体库存在。
// 电影/无集号条目:媒体库已有该片即视为已存在;剧集条目:对应季集已入库即视为已存在。
func subscriptionItemAlreadyAvailable(sub *model.Subscription, avail LocalAvailability, title string) bool {
if avail.LocalMediaCount == 0 && !avail.HasSeriesPack {
return false
}
if !isSubscriptionSeriesType(subscriptionMediaType(sub)) {
return true
}
if avail.HasSeriesPack {
return true
}
wantSeason, wantEpisode := ParseEpisode(title)
if wantEpisode <= 0 {
// 整季合集 / 无法解析集号:库里已有内容时保守跳过,避免重复整季下载。
return true
}
if wantSeason <= 0 {
wantSeason = 1
}
_, exists := avail.ExistingEpisodeKeys[episodeKey(wantSeason, wantEpisode)]
return exists
}
func subscriptionMediaType(sub *model.Subscription) string {
if sub == nil {
return ""
}
return sub.MediaType
}
func (s *SubscriptionService) downloadPathHasCandidate(ctx context.Context, sub *model.Subscription, title, savePath string) bool {
savePath = strings.TrimSpace(savePath)
if savePath == "" {
savePath = s.subscriptionBaseSavePath(ctx, sub)
}
query := availabilityQuery(title, subscriptionFilter(sub))
if savePath == "" || query == "" {
return false
}
wantSeason, wantEpisode := ParseEpisode(title)
if wantSeason <= 0 {
wantSeason = 1
}
found := false
_ = scanDownloadPath(ctx, savePath, query, func(path string, season, episode int) bool {
if wantEpisode <= 0 {
found = true
return false
}
if episode <= 0 {
return true
}
if season <= 0 {
season = 1
}
if episodeKey(season, episode) == episodeKey(wantSeason, wantEpisode) {
found = true
return false
}
return true
})
return found
}
func scanDownloadPath(ctx context.Context, root, query string, visit func(path string, season, episode int) bool) error {
if strings.TrimSpace(root) == "" {
return nil
}
info, err := os.Stat(root)
if err != nil || !info.IsDir() {
return nil
}
normalizedQuery := normalizeAvailabilityComparable(query)
if normalizedQuery == "" {
return nil
}
visited := 0
return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if ctx.Err() != nil {
return ctx.Err()
}
if d.IsDir() {
if path != root && strings.HasPrefix(filepath.Base(path), ".") {
return filepath.SkipDir
}
return nil
}
if !isDownloadMediaPath(path) {
return nil
}
visited++
if visited > 10000 {
return filepath.SkipAll
}
if !strings.Contains(normalizeAvailabilityComparable(path), normalizedQuery) {
return nil
}
season, episode := ParseEpisode(path)
if !visit(path, season, episode) {
return filepath.SkipAll
}
return nil
})
}
func isDownloadMediaPath(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".!qb", ".part", ".aria2", ".crdownload":
path = strings.TrimSuffix(path, filepath.Ext(path))
ext = strings.ToLower(filepath.Ext(path))
}
_, ok := videoExtensions[ext]
return ok
}
func normalizeAvailabilityComparable(value string) string {
var b strings.Builder
for _, r := range strings.ToLower(value) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
+457
View File
@@ -0,0 +1,457 @@
// Package service — subscription planning and release candidate selection.
package service
import (
"fmt"
"net/url"
"regexp"
"sort"
"strings"
"unicode"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
var (
seriesPackRE = regexp.MustCompile(`(?i)(complete|batch|合集|全集|全\s*\d+\s*[集话話期]|整季|全季|s\d{1,2}\s*(?:complete|batch|pack)|season\s*\d{1,2}\s*(?:complete|batch|pack)|s\d{1,2}e\d{1,3}\s*[-~–—]\s*(?:e)?\d{1,3}|第\s*\d+\s*[-~–—]\s*\d+\s*[集话話期])`)
seasonOnlyRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\s*\d{1,2}(?:[\s._-]|$)|第\s*\d+\s*季`)
)
type siteSearchCandidate struct {
Item SearchResult
Download string
GUID string
Season int
Episode int
Pack bool
Score int
}
// SubscriptionPlanner owns release selection decisions for subscriptions:
// rule matching, candidate scoring, and filtering against known availability.
type SubscriptionPlanner struct{}
func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, availability ...LocalAvailability) []siteSearchCandidate {
return SubscriptionPlanner{}.SelectSiteSearchCandidates(results, sub, seenSet, availability...)
}
func (SubscriptionPlanner) SelectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, availability ...LocalAvailability) []siteSearchCandidate {
if sub == nil {
return nil
}
if seenSet == nil {
seenSet = map[string]struct{}{}
}
local := LocalAvailability{}
if len(availability) > 0 {
local = availability[0]
}
return selectSiteSearchCandidatesWithAvailability(results, sub, seenSet, local)
}
func selectSiteSearchCandidatesWithAvailability(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, local LocalAvailability) []siteSearchCandidate {
candidates := make([]siteSearchCandidate, 0, len(results))
for _, item := range results {
if !matchesSubscriptionRules(sub, item.Title) {
continue
}
download := strings.TrimSpace(item.DownloadURL)
if download == "" {
download = strings.TrimSpace(item.TorrentURL)
}
if download == "" {
continue
}
guid := stableSiteSearchGUID(item, download)
if _, ok := seenSet[guid]; ok {
continue
}
season, episode := ParseEpisode(item.Title)
score := subscriptionCandidateScore(sub, item)
candidates = append(candidates, siteSearchCandidate{
Item: item,
Download: download,
GUID: guid,
Season: season,
Episode: episode,
Pack: isSeriesPackTitle(item.Title),
Score: score,
})
}
if len(candidates) > 1 {
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].Score != candidates[j].Score {
return candidates[i].Score > candidates[j].Score
}
if candidates[i].Item.Seeders != candidates[j].Item.Seeders {
return candidates[i].Item.Seeders > candidates[j].Item.Seeders
}
return candidates[i].Item.Size > candidates[j].Item.Size
})
}
if len(candidates) == 0 {
return nil
}
mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "")
if !isSubscriptionSeriesType(mediaType) {
// 对齐 MoviePilot:非洗版订阅成功下载一次即满足,媒体库/下载中已存在则不再重复下载。
if (sub == nil || !sub.WashEnabled) && local.LocalMediaCount > 0 {
return nil
}
return candidates[:1]
}
if local.HasSeriesPack {
return nil
}
if local.LocalMediaCount > 0 {
if local.TotalEpisodes > 0 && len(local.MissingEpisodes) == 0 {
return nil
}
missingSet := missingEpisodeSet(local)
onlyMissing := make([]siteSearchCandidate, 0, len(candidates))
for _, candidate := range candidates {
if candidate.Episode <= 0 {
continue
}
season := candidate.Season
if season <= 0 {
season = 1
}
if _, exists := local.ExistingEpisodeKeys[episodeKey(season, candidate.Episode)]; exists {
continue
}
if local.TotalEpisodes > 0 {
if _, missing := missingSet[candidate.Episode]; !missing {
continue
}
}
onlyMissing = append(onlyMissing, candidate)
}
return sortedEpisodeCandidates(onlyMissing)
}
for _, candidate := range candidates {
if candidate.Pack {
return []siteSearchCandidate{candidate}
}
}
selected := sortedEpisodeCandidates(candidates)
if len(selected) == 0 {
return candidates[:1]
}
return selected
}
func stableRSSItemGUID(title, guid, link, enclosureURL string) string {
parts := []string{"rss", strings.ToLower(strings.TrimSpace(title))}
for _, raw := range []string{guid, enclosureURL, link} {
if key := stableDownloadURLKey(raw); key != "" {
parts = append(parts, key)
return strings.Join(parts, "|")
}
if raw = strings.TrimSpace(raw); raw != "" {
parts = append(parts, strings.ToLower(raw))
return strings.Join(parts, "|")
}
}
return strings.Join(parts, "|")
}
func stableSiteSearchGUID(item SearchResult, download string) string {
parts := []string{
"site",
strings.ToLower(strings.TrimSpace(firstNonEmpty(item.SiteID, item.SiteName))),
strings.ToLower(strings.TrimSpace(item.Category)),
strings.ToLower(strings.TrimSpace(item.Title)),
fmt.Sprintf("%d", item.Size),
}
if key := stableDownloadURLKey(download); key != "" {
parts = append(parts, key)
}
return strings.Join(parts, "|")
}
func stableDownloadURLKey(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil {
return strings.ToLower(raw)
}
if strings.EqualFold(u.Scheme, "magnet") {
xt := strings.ToLower(strings.TrimSpace(u.Query().Get("xt")))
if xt != "" {
return "magnet:" + xt
}
return strings.ToLower(raw)
}
if u.Host == "" {
return strings.ToLower(raw)
}
q := u.Query()
kept := make([]string, 0, 4)
for _, key := range []string{"id", "tid", "torrent", "torrent_id", "torrentid", "hash", "info_hash"} {
if value := strings.TrimSpace(q.Get(key)); value != "" {
kept = append(kept, key+"="+strings.ToLower(value))
}
}
base := strings.ToLower(strings.TrimRight(u.Host, "/") + "/" + strings.TrimLeft(u.Path, "/"))
if len(kept) > 0 {
return base + "?" + strings.Join(kept, "&")
}
return base
}
// defaultExcludeWords 是参考 MoviePilot 默认过滤的「垃圾版本」排除清单,对所有订阅生效,
// 与用户自定义排除词合并。拉丁词在 containsAnyExcludeToken 里按词边界匹配以避免子串误伤。
const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample"
func matchesSubscriptionRules(sub *model.Subscription, title string) bool {
titleFold := strings.ToLower(title)
if containsAnyExcludeToken(titleFold, defaultExcludeWords) {
return false
}
if sub == nil {
return true
}
if sub.ExcludeWords != "" && containsAnyExcludeToken(titleFold, sub.ExcludeWords) {
return false
}
if sub.ReleaseGroups != "" && !containsAnyToken(titleFold, sub.ReleaseGroups) {
return false
}
if sub.Resolution != "" && sub.Resolution != "best" && !titleMatchesResolution(titleFold, sub.Resolution) {
return false
}
if sub.Quality != "" && sub.Quality != "best" && !titleMatchesQuality(titleFold, sub.Quality) {
return false
}
if sub.Effects != "" && !containsAnyEffect(titleFold, sub.Effects) {
return false
}
return true
}
func subscriptionCandidateScore(sub *model.Subscription, item SearchResult) int {
title := strings.ToLower(item.Title)
score := item.Seeders
if sub == nil || !sub.WashEnabled {
if item.Free {
score += 25
}
return score
}
resolutionScore := detectResolutionScore(title)
qualityScore := detectQualityScore(title)
effectScore := detectEffectScore(title)
priority := "balanced"
if sub != nil && strings.TrimSpace(sub.WashPriority) != "" {
priority = strings.ToLower(strings.TrimSpace(sub.WashPriority))
}
switch priority {
case "resolution":
score += resolutionScore*1000 + qualityScore*100 + effectScore*50
case "quality":
score += qualityScore*1000 + resolutionScore*200 + effectScore*50
case "effects":
score += effectScore*1000 + resolutionScore*200 + qualityScore*100
case "seeders":
score += qualityScore*3 + resolutionScore*2 + effectScore
default:
score += resolutionScore*500 + qualityScore*300 + effectScore*150
}
if item.Free {
score += 25
}
return score
}
func containsAnyToken(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token != "" && strings.Contains(titleFold, token) {
return true
}
}
return false
}
// containsAnyExcludeToken 用于排除词匹配:纯 ASCII 字母数字的词按词边界匹配(避免 "ts"
// 误伤 "tsukihime"、"cam" 误伤 "camp" 之类的子串误判),含 CJK/符号的词仍按子串匹配。
func containsAnyExcludeToken(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token == "" {
continue
}
if isASCIIWordToken(token) {
if matchesWordBoundary(titleFold, token) {
return true
}
continue
}
if strings.Contains(titleFold, token) {
return true
}
}
return false
}
func isASCIIWordToken(token string) bool {
for _, r := range token {
if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r)) {
return false
}
}
return token != ""
}
// matchesWordBoundary 判断 token 是否作为独立词出现在 title 中,词边界为「非字母数字」。
func matchesWordBoundary(titleFold, token string) bool {
isWordRune := func(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
from := 0
for {
idx := strings.Index(titleFold[from:], token)
if idx < 0 {
return false
}
start := from + idx
end := start + len(token)
leftOK := start == 0 || !isWordRune(rune(titleFold[start-1]))
rightOK := end >= len(titleFold) || !isWordRune(rune(titleFold[end]))
if leftOK && rightOK {
return true
}
from = start + 1
if from >= len(titleFold) {
return false
}
}
}
func containsAnyEffect(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token == "" {
continue
}
switch token {
case "dolby-vision", "dolby vision", "dv":
if strings.Contains(titleFold, "dolby vision") || strings.Contains(titleFold, "dovi") || regexp.MustCompile(`\bdv\b`).MatchString(titleFold) {
return true
}
default:
if strings.Contains(titleFold, token) {
return true
}
}
}
return false
}
func titleMatchesResolution(titleFold, resolution string) bool {
switch strings.ToLower(strings.TrimSpace(resolution)) {
case "2160p", "4k", "uhd":
return strings.Contains(titleFold, "2160p") || strings.Contains(titleFold, "4k") || strings.Contains(titleFold, "uhd")
case "1080p":
return strings.Contains(titleFold, "1080p") || strings.Contains(titleFold, "fhd")
case "720p":
return strings.Contains(titleFold, "720p")
default:
return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(resolution)))
}
}
func titleMatchesQuality(titleFold, quality string) bool {
switch strings.ToLower(strings.TrimSpace(quality)) {
case "webdl", "web-dl":
return strings.Contains(titleFold, "web-dl") || strings.Contains(titleFold, "webdl")
case "bluray", "blu-ray":
return strings.Contains(titleFold, "bluray") || strings.Contains(titleFold, "blu-ray") || strings.Contains(titleFold, "bdrip")
case "remux":
return strings.Contains(titleFold, "remux")
case "hdtv":
return strings.Contains(titleFold, "hdtv")
default:
return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(quality)))
}
}
func detectResolutionScore(titleFold string) int {
switch {
case titleMatchesResolution(titleFold, "2160p"):
return 4
case titleMatchesResolution(titleFold, "1080p"):
return 3
case titleMatchesResolution(titleFold, "720p"):
return 2
default:
return 1
}
}
func detectQualityScore(titleFold string) int {
switch {
case titleMatchesQuality(titleFold, "remux"):
return 5
case titleMatchesQuality(titleFold, "bluray"):
return 4
case titleMatchesQuality(titleFold, "web-dl"):
return 3
case titleMatchesQuality(titleFold, "hdtv"):
return 2
default:
return 1
}
}
func detectEffectScore(titleFold string) int {
score := 0
if containsAnyEffect(titleFold, "dolby-vision") {
score += 4
}
if strings.Contains(titleFold, "hdr10+") {
score += 3
} else if strings.Contains(titleFold, "hdr") {
score += 2
}
if strings.Contains(titleFold, "atmos") {
score += 2
}
return score
}
func isSubscriptionSeriesType(mediaType string) bool {
switch normalizeMediaType(mediaType, "", "") {
case "tv", "anime", "variety":
return true
default:
return false
}
}
func isSeriesPackTitle(title string) bool {
title = strings.TrimSpace(title)
if title == "" {
return false
}
if seriesPackRE.MatchString(title) {
return true
}
_, episode := ParseEpisode(title)
return episode == 0 && seasonOnlyRE.MatchString(title)
}
-153
View File
@@ -225,144 +225,6 @@ func (s *TelegramBotService) HandleWebhook(ctx context.Context, body []byte) err
return nil
}
// executeCommand 解析命令并执行。
func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, text string) (telegramCommandReply, error) {
parts := strings.Fields(text)
if len(parts) == 0 {
return telegramCommandReply{}, nil
}
cmd := telegramCommandName(parts[0])
args := parts[1:]
if msg.Chat.Type != "" && msg.Chat.Type != "private" && !s.telegramChatAllowed(channel, msg.Chat.ID) {
return telegramCommandReply{Text: "此群组/频道未绑定到 Bot 管理入口,请在通知渠道里填写「绑定群组 ID」或「绑定频道 ID」。"}, nil
}
switch cmd {
case "/start":
if len(args) == 0 {
return s.mainMenu(ctx, channel, msg), nil
}
return s.cmdStart(ctx, msg, args), nil
case "/menu":
return s.mainMenu(ctx, channel, msg), nil
case "/cancel":
s.takePending(int64(msg.From.ID))
return telegramCommandReply{Text: "已取消当前操作。"}, nil
case "/help":
return telegramCommandReply{Text: s.cmdHelp(ctx, msg)}, nil
case "/hideadult", "/hide_adult", "/adult":
return s.cmdHideAdult(ctx, msg, args), nil
case "/account", "/me":
return s.replyAccount(ctx, msg), nil
case "/signin", "/checkin":
return s.replySignIn(ctx, msg), nil
case "/devices":
return s.replyDevices(ctx, msg), nil
case "/kick":
return s.cmdKick(ctx, msg, args), nil
case "/setname", "/rename":
return s.cmdSetName(ctx, msg, args), nil
case "/setpass", "/passwd", "/password":
return s.cmdSetPass(ctx, msg, args), nil
case "/redeem":
return s.cmdRedeem(ctx, channel, msg, args), nil
case "/redeem_register":
return s.cmdRedeemRegister(ctx, channel, msg, args), nil
case "/redeem_renew":
return s.cmdRedeemRenew(ctx, msg, args), nil
case "/register", "/reg", "/signup":
return s.cmdRegister(ctx, channel, msg, args), nil
case "/registration", "/reg_switch", "/openreg":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdRegistrationToggle(ctx, args), nil
case "/capacity":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.replyCapacity(ctx), nil
case "/users":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.replyUserList(ctx), nil
case "/gencode":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdGenCode(ctx, msg, args), nil
case "/renew_user":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdUserRenew(ctx, args), nil
case "/delete_user":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdUserDelete(ctx, args), nil
case "/devicepolicy", "/policy":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdDevicePolicy(ctx, args), nil
case "/antishare":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdAntiShare(ctx, args), nil
case "/cleanup":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdCleanup(ctx, args), nil
case "/cleanup_mode":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdCleanupMode(ctx, args), nil
case "/cleanup_rule":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdCleanupRule(ctx, args), nil
case "/ban":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdUserBan(ctx, args, false), nil
case "/unban":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdUserBan(ctx, args, true), nil
case "/status":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。普通用户只能使用 /start 绑定账号,并通过按钮隐藏成人目录。"}, nil
}
return s.cmdStatus(ctx)
case "/search":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdSearch(ctx, args)
case "/downloads":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdDownloads(ctx)
case "/stats":
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil
}
return s.cmdStats(ctx)
default:
return telegramCommandReply{Text: fmt.Sprintf("未知命令: %s\n\n输入 /help 查看可用命令列表。", cmd)}, nil
}
}
func telegramIsCommandText(text string) bool {
return strings.HasPrefix(strings.TrimSpace(text), "/") && telegramCommandName(text) != ""
}
@@ -382,21 +244,6 @@ func telegramCommandName(text string) string {
return cmd
}
func telegramSupportedCommand(cmd string) bool {
switch cmd {
case "/start", "/menu", "/cancel", "/help", "/hideadult", "/hide_adult", "/adult",
"/account", "/me", "/signin", "/checkin", "/devices", "/kick", "/setname", "/rename", "/setpass", "/passwd", "/password",
"/redeem", "/redeem_register", "/redeem_renew",
"/register", "/reg", "/signup", "/registration", "/reg_switch", "/openreg",
"/capacity", "/users", "/gencode", "/renew_user", "/delete_user",
"/devicepolicy", "/policy", "/antishare", "/cleanup", "/cleanup_mode", "/cleanup_rule",
"/ban", "/unban", "/status", "/search", "/downloads", "/stats":
return true
default:
return false
}
}
// cmdStart 处理 /start 命令。
func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
name := msg.From.FirstName
+124
View File
@@ -0,0 +1,124 @@
// Package service — Telegram command registry and dispatch.
package service
import (
"context"
"fmt"
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
type telegramCommandHandler func(args []string) (telegramCommandReply, error)
type telegramCommandDefinition struct {
Aliases []string
AdminOnly bool
AdminOnlyText string
Handle telegramCommandHandler
}
func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) []telegramCommandDefinition {
adminOnly := "此命令仅管理员可用。"
return []telegramCommandDefinition{
{Aliases: []string{"/start"}, Handle: func(args []string) (telegramCommandReply, error) {
if len(args) == 0 {
return s.mainMenu(ctx, channel, msg), nil
}
return s.cmdStart(ctx, msg, args), nil
}},
{Aliases: []string{"/menu"}, Handle: func(args []string) (telegramCommandReply, error) { return s.mainMenu(ctx, channel, msg), nil }},
{Aliases: []string{"/cancel"}, Handle: func(args []string) (telegramCommandReply, error) {
s.takePending(int64(msg.From.ID))
return telegramCommandReply{Text: "已取消当前操作。"}, nil
}},
{Aliases: []string{"/help"}, Handle: func(args []string) (telegramCommandReply, error) {
return telegramCommandReply{Text: s.cmdHelp(ctx, msg)}, nil
}},
{Aliases: []string{"/hideadult", "/hide_adult", "/adult"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdHideAdult(ctx, msg, args), nil }},
{Aliases: []string{"/account", "/me"}, Handle: func(args []string) (telegramCommandReply, error) { return s.replyAccount(ctx, msg), nil }},
{Aliases: []string{"/signin", "/checkin"}, Handle: func(args []string) (telegramCommandReply, error) { return s.replySignIn(ctx, msg), nil }},
{Aliases: []string{"/devices"}, Handle: func(args []string) (telegramCommandReply, error) { return s.replyDevices(ctx, msg), nil }},
{Aliases: []string{"/kick"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdKick(ctx, msg, args), nil }},
{Aliases: []string{"/setname", "/rename"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSetName(ctx, msg, args), nil }},
{Aliases: []string{"/setpass", "/passwd", "/password"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSetPass(ctx, msg, args), nil }},
{Aliases: []string{"/redeem"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRedeem(ctx, channel, msg, args), nil }},
{Aliases: []string{"/redeem_register"}, Handle: func(args []string) (telegramCommandReply, error) {
return s.cmdRedeemRegister(ctx, channel, msg, args), nil
}},
{Aliases: []string{"/redeem_renew"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRedeemRenew(ctx, msg, args), nil }},
{Aliases: []string{"/register", "/reg", "/signup"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRegister(ctx, channel, msg, args), nil }},
{Aliases: []string{"/registration", "/reg_switch", "/openreg"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRegistrationToggle(ctx, args), nil }},
{Aliases: []string{"/capacity"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.replyCapacity(ctx), nil }},
{Aliases: []string{"/users"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.replyUserList(ctx), nil }},
{Aliases: []string{"/gencode"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdGenCode(ctx, msg, args), nil }},
{Aliases: []string{"/renew_user"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserRenew(ctx, args), nil }},
{Aliases: []string{"/delete_user"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserDelete(ctx, args), nil }},
{Aliases: []string{"/devicepolicy", "/policy"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdDevicePolicy(ctx, args), nil }},
{Aliases: []string{"/antishare"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdAntiShare(ctx, args), nil }},
{Aliases: []string{"/cleanup"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanup(ctx, args), nil }},
{Aliases: []string{"/cleanup_mode"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanupMode(ctx, args), nil }},
{Aliases: []string{"/cleanup_rule"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanupRule(ctx, args), nil }},
{Aliases: []string{"/ban"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserBan(ctx, args, false), nil }},
{Aliases: []string{"/unban"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserBan(ctx, args, true), nil }},
{Aliases: []string{"/status"}, AdminOnly: true, AdminOnlyText: "此命令仅管理员可用。普通用户只能使用 /start 绑定账号,并通过按钮隐藏成人目录。", Handle: func(args []string) (telegramCommandReply, error) { return s.cmdStatus(ctx) }},
{Aliases: []string{"/search"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSearch(ctx, args) }},
{Aliases: []string{"/downloads"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdDownloads(ctx) }},
{Aliases: []string{"/stats"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdStats(ctx) }},
}
}
func (s *TelegramBotService) telegramCommandRegistry(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) map[string]telegramCommandDefinition {
defs := s.telegramCommandDefinitions(ctx, channel, msg)
registry := make(map[string]telegramCommandDefinition, len(defs)*2)
for _, def := range defs {
for _, alias := range def.Aliases {
registry[alias] = def
}
}
return registry
}
// executeCommand parses and dispatches Telegram commands through a registry so
// adding a command does not grow a monolithic switch.
func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, text string) (telegramCommandReply, error) {
parts := strings.Fields(text)
if len(parts) == 0 {
return telegramCommandReply{}, nil
}
cmd := telegramCommandName(parts[0])
args := parts[1:]
if msg.Chat.Type != "" && msg.Chat.Type != "private" && !s.telegramChatAllowed(channel, msg.Chat.ID) {
return telegramCommandReply{Text: "此群组/频道未绑定到 Bot 管理入口,请在通知渠道里填写「绑定群组 ID」或「绑定频道 ID」。"}, nil
}
def, ok := s.telegramCommandRegistry(ctx, channel, msg)[cmd]
if !ok {
return telegramCommandReply{Text: fmt.Sprintf("未知命令: %s\n\n输入 /help 查看可用命令列表。", cmd)}, nil
}
if def.AdminOnly && !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: def.AdminOnlyText}, nil
}
return def.Handle(args)
}
func telegramSupportedCommand(cmd string) bool {
cmd = telegramCommandName(cmd)
if cmd == "" {
return false
}
_, ok := telegramSupportedCommandSet[cmd]
return ok
}
var telegramSupportedCommandSet = map[string]struct{}{
"/start": {}, "/menu": {}, "/cancel": {}, "/help": {}, "/hideadult": {}, "/hide_adult": {}, "/adult": {},
"/account": {}, "/me": {}, "/signin": {}, "/checkin": {}, "/devices": {}, "/kick": {}, "/setname": {}, "/rename": {}, "/setpass": {}, "/passwd": {}, "/password": {},
"/redeem": {}, "/redeem_register": {}, "/redeem_renew": {},
"/register": {}, "/reg": {}, "/signup": {}, "/registration": {}, "/reg_switch": {}, "/openreg": {},
"/capacity": {}, "/users": {}, "/gencode": {}, "/renew_user": {}, "/delete_user": {},
"/devicepolicy": {}, "/policy": {}, "/antishare": {}, "/cleanup": {}, "/cleanup_mode": {}, "/cleanup_rule": {},
"/ban": {}, "/unban": {}, "/status": {}, "/search": {}, "/downloads": {}, "/stats": {},
}