Fix scraping workflows subscriptions bot and recycle bin

This commit is contained in:
ShukeBta
2026-06-17 19:48:17 +08:00
parent 91afbe54a2
commit 850d3db811
43 changed files with 2782 additions and 76 deletions
+99
View File
@@ -0,0 +1,99 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
type manualScrapeApplyReq struct {
MediaIDs []string `json:"media_ids"`
Match service.ManualScrapeRequest `json:"match"`
}
func manualScrapeSearchHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
results, err := svc.Scraper.ManualSearch(
c.Request.Context(),
m,
c.Query("query"),
c.DefaultQuery("provider", "all"),
c.Query("media_type"),
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": results})
}
}
func manualScrapeApplyOneHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req service.ManualScrapeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
media, err := svc.Scraper.ApplyManualMatch(c.Request.Context(), c.Param("id"), req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, media)
}
}
func manualScrapeApplyBatchHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req manualScrapeApplyReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ids := compactManualScrapeIDs(req.MediaIDs)
if len(ids) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "media_ids required"})
return
}
applied := 0
errorsOut := make([]string, 0)
for _, id := range ids {
if _, err := svc.Scraper.ApplyManualMatch(c.Request.Context(), id, req.Match); err != nil {
errorsOut = append(errorsOut, id+": "+err.Error())
continue
}
applied++
}
if applied == 0 && len(errorsOut) > 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": strings.Join(errorsOut, "\n")})
return
}
c.JSON(http.StatusOK, gin.H{"applied": applied, "errors": errorsOut})
}
}
func compactManualScrapeIDs(values []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
+54
View File
@@ -2,13 +2,19 @@
package handler
import (
"context"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
type recycleBatchReq struct {
MediaIDs []string `json:"media_ids"`
}
func deleteMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Media.SoftDelete(c.Request.Context(), c.Param("id")); err != nil {
@@ -40,6 +46,22 @@ func restoreMediaHandler(svc *service.Container) gin.HandlerFunc {
}
}
func restoreMediaBatchHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req recycleBatchReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
applied, errorsOut := runRecycleBatch(c, compactManualScrapeIDs(req.MediaIDs), svc.Media.RestoreDeleted)
if applied == 0 && len(errorsOut) > 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": strings.Join(errorsOut, "\n")})
return
}
c.JSON(http.StatusOK, gin.H{"applied": applied, "errors": errorsOut})
}
}
func purgeMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Media.PurgeDeleted(c.Request.Context(), c.Param("id")); err != nil {
@@ -49,3 +71,35 @@ func purgeMediaHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusNoContent)
}
}
func purgeMediaBatchHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req recycleBatchReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
applied, errorsOut := runRecycleBatch(c, compactManualScrapeIDs(req.MediaIDs), svc.Media.PurgeDeleted)
if applied == 0 && len(errorsOut) > 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": strings.Join(errorsOut, "\n")})
return
}
c.JSON(http.StatusOK, gin.H{"applied": applied, "errors": errorsOut})
}
}
func runRecycleBatch(c *gin.Context, ids []string, action func(context.Context, string) error) (int, []string) {
if len(ids) == 0 {
return 0, []string{"media_ids required"}
}
applied := 0
errorsOut := make([]string, 0)
for _, id := range ids {
if err := action(c.Request.Context(), id); err != nil {
errorsOut = append(errorsOut, id+": "+err.Error())
continue
}
applied++
}
return applied, errorsOut
}
+5
View File
@@ -42,6 +42,9 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *
authed.GET("/media/:id", getMediaHandler(svc))
authed.GET("/media", searchMediaHandler(svc))
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
authed.GET("/media/:id/scrape/search", middleware.AdminRequired(), manualScrapeSearchHandler(svc))
authed.POST("/media/:id/scrape/apply", middleware.AdminRequired(), manualScrapeApplyOneHandler(svc))
authed.POST("/media/scrape/apply", middleware.AdminRequired(), manualScrapeApplyBatchHandler(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))
@@ -145,6 +148,8 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *
// Recycle bin.
authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
authed.POST("/recycle/restore", middleware.AdminRequired(), restoreMediaBatchHandler(svc))
authed.POST("/recycle/purge", middleware.AdminRequired(), purgeMediaBatchHandler(svc))
authed.GET("/ws", wsHandler(svc))
+4 -4
View File
@@ -83,8 +83,8 @@ func telegramGetWebhookHandler(svc *service.Container) gin.HandlerFunc {
// 路由:POST /api/admin/telegram/polling/start (需 admin 认证)
func telegramStartPollingHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
svc.TelegramBot.StartPolling(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"message": "polling started"})
result := svc.TelegramBot.StartPolling(c.Request.Context())
c.JSON(http.StatusOK, result)
}
}
@@ -93,7 +93,7 @@ func telegramStartPollingHandler(svc *service.Container) gin.HandlerFunc {
// 路由:POST /api/admin/telegram/polling/stop (需 admin 认证)
func telegramStopPollingHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
svc.TelegramBot.StopPolling()
c.JSON(http.StatusOK, gin.H{"message": "polling stopped"})
stopped := svc.TelegramBot.StopPolling()
c.JSON(http.StatusOK, gin.H{"message": "polling stopped", "stopped": stopped})
}
}
+13 -12
View File
@@ -193,18 +193,19 @@ type PlaylistItem struct {
// DownloadTask 是待处理(或已完成)的 torrent / HTTP 下载。
type DownloadTask struct {
Base
UserID string `gorm:"index;size:36" json:"user_id"`
Source string `gorm:"size:32;not null" json:"source"` // qbittorrent / transmission / http
URL string `gorm:"size:2048;not null" json:"-"`
Title string `gorm:"size:512" json:"title,omitempty"`
PosterURL string `gorm:"size:2048" json:"poster_url,omitempty"`
BackdropURL string `gorm:"size:2048" json:"backdrop_url,omitempty"`
Overview string `gorm:"type:text" json:"overview,omitempty"`
SavePath string `gorm:"size:1024" json:"save_path"`
MediaType string `gorm:"size:16" json:"media_type,omitempty"`
MediaCategory string `gorm:"size:128" json:"media_category,omitempty"`
Status string `gorm:"size:32;default:queued" json:"status"`
Progress float32 `json:"progress"`
UserID string `gorm:"index;size:36" json:"user_id"`
SubscriptionID string `gorm:"index;size:36" json:"subscription_id,omitempty"`
Source string `gorm:"size:32;not null" json:"source"` // qbittorrent / transmission / http
URL string `gorm:"size:2048;not null" json:"-"`
Title string `gorm:"size:512" json:"title,omitempty"`
PosterURL string `gorm:"size:2048" json:"poster_url,omitempty"`
BackdropURL string `gorm:"size:2048" json:"backdrop_url,omitempty"`
Overview string `gorm:"type:text" json:"overview,omitempty"`
SavePath string `gorm:"size:1024" json:"save_path"`
MediaType string `gorm:"size:16" json:"media_type,omitempty"`
MediaCategory string `gorm:"size:128" json:"media_category,omitempty"`
Status string `gorm:"size:32;default:queued" json:"status"`
Progress float32 `json:"progress"`
// AllowExistingLibrary is true for subscription wash/upgrade tasks that are
// allowed to replace an existing library item after download completion.
+5
View File
@@ -959,6 +959,11 @@ func (r *SettingRepository) Set(ctx context.Context, key, value string) error {
return r.db.WithContext(ctx).Save(&s).Error
}
// Delete removes a setting key.
func (r *SettingRepository) Delete(ctx context.Context, key string) error {
return r.db.WithContext(ctx).Where("key = ?", key).Delete(&model.Setting{}).Error
}
// All returns every key/value pair (used by the admin UI).
func (r *SettingRepository) All(ctx context.Context) ([]model.Setting, error) {
var rows []model.Setting
+68
View File
@@ -105,6 +105,74 @@ func (b *BangumiProvider) Search(ctx context.Context, query string) (*Match, err
return m, nil
}
func (b *BangumiProvider) GetSubject(ctx context.Context, bangumiID int) (*Match, error) {
if bangumiID <= 0 {
return nil, nil
}
u := fmt.Sprintf("%s/v0/subjects/%d", b.base, bangumiID)
type subject struct {
ID int `json:"id"`
Name string `json:"name"`
NameCN string `json:"name_cn"`
Summary string `json:"summary"`
Air string `json:"date"`
Eps int `json:"eps"`
Rating struct {
Score float32 `json:"score"`
} `json:"rating"`
Images struct {
Large string `json:"large"`
Common string `json:"common"`
} `json:"images"`
Tags []struct {
Name string `json:"name"`
} `json:"tags"`
}
var r subject
if err := b.getJSON(ctx, u, &r); err != nil {
return nil, err
}
title := r.NameCN
if title == "" {
title = r.Name
}
m := &Match{
BangumiID: r.ID,
Title: title,
OriginalName: r.Name,
Overview: r.Summary,
PosterURL: firstText(r.Images.Large, r.Images.Common),
Rating: r.Rating.Score,
}
if len(r.Air) >= 4 {
_, _ = fmt.Sscanf(r.Air[:4], "%d", &m.Year)
}
for _, tag := range r.Tags {
if strings.TrimSpace(tag.Name) != "" {
m.Genres = append(m.Genres, tag.Name)
}
}
return m, nil
}
func (b *BangumiProvider) GetEpisodeCount(ctx context.Context, bangumiID int) (int, error) {
if bangumiID <= 0 {
return 0, nil
}
u := fmt.Sprintf("%s/v0/subjects/%d", b.base, bangumiID)
var subject struct {
Eps int `json:"eps"`
TotalEpisodes int `json:"total_episodes"`
}
if err := b.getJSON(ctx, u, &subject); err != nil {
return 0, err
}
if subject.Eps > 0 {
return subject.Eps, nil
}
return subject.TotalEpisodes, nil
}
func (b *BangumiProvider) getJSON(ctx context.Context, u string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
+155
View File
@@ -132,6 +132,161 @@ func (d *DoubanProvider) SearchMatch(ctx context.Context, query string) (*Match,
return match, nil
}
func (d *DoubanProvider) GetMatchByID(ctx context.Context, doubanID string) (*Match, error) {
doubanID = strings.TrimSpace(doubanID)
if doubanID == "" {
return nil, nil
}
u := "https://movie.douban.com/j/subject_abstract?subject_id=" + url.QueryEscape(doubanID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
d.setHeaders(req)
resp, err := d.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("douban detail: %d", resp.StatusCode)
}
var raw map[string]any
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, err
}
subject := raw
if nested, ok := raw["subject"].(map[string]any); ok {
subject = nested
} else if nested, ok := raw["data"].(map[string]any); ok {
subject = nested
}
title := firstStringFromMap(subject, "title", "name")
year := 0
if y := firstStringFromMap(subject, "year"); len(y) >= 4 {
_, _ = fmt.Sscanf(y[:4], "%d", &year)
}
m := &Match{
DoubanID: doubanID,
Title: title,
Overview: firstStringFromMap(subject, "short_comment", "intro", "summary", "abstract"),
PosterURL: firstStringFromMap(subject, "pic", "img", "cover", "cover_url"),
Year: year,
Rating: float32FromMap(subject, "rate", "rating"),
}
if m.Title == "" {
m.Title = firstStringFromMap(raw, "title")
}
return m, nil
}
func (d *DoubanProvider) GetEpisodeCount(ctx context.Context, query string) (int, error) {
match, err := d.Search(ctx, query)
if err != nil || match == nil || strings.TrimSpace(match.DoubanID) == "" {
return 0, err
}
return d.GetEpisodeCountByID(ctx, match.DoubanID)
}
func (d *DoubanProvider) GetEpisodeCountByID(ctx context.Context, doubanID string) (int, error) {
doubanID = strings.TrimSpace(doubanID)
if doubanID == "" {
return 0, nil
}
u := "https://movie.douban.com/j/subject_abstract?subject_id=" + url.QueryEscape(doubanID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return 0, err
}
d.setHeaders(req)
resp, err := d.client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return 0, fmt.Errorf("douban detail: %d", resp.StatusCode)
}
var raw map[string]any
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return 0, err
}
for _, key := range []string{"episode_count", "episodes_count", "episodes", "eps"} {
if count := doubanEpisodeCountFromValue(raw[key]); count > 0 {
return count, nil
}
}
for _, key := range []string{"subject", "data"} {
if nested, ok := raw[key].(map[string]any); ok {
for _, field := range []string{"episode_count", "episodes_count", "episodes", "eps"} {
if count := doubanEpisodeCountFromValue(nested[field]); count > 0 {
return count, nil
}
}
}
}
return 0, nil
}
func firstStringFromMap(values map[string]any, keys ...string) string {
for _, key := range keys {
switch v := values[key].(type) {
case string:
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
case map[string]any:
if s := firstStringFromMap(v, "normal", "large", "small", "url"); s != "" {
return s
}
}
}
return ""
}
func float32FromMap(values map[string]any, keys ...string) float32 {
for _, key := range keys {
switch v := values[key].(type) {
case float64:
return float32(v)
case string:
var out float32
if _, err := fmt.Sscanf(strings.TrimSpace(v), "%f", &out); err == nil {
return out
}
case map[string]any:
if out := float32FromMap(v, "value", "score"); out > 0 {
return out
}
}
}
return 0
}
func doubanEpisodeCountFromValue(value any) int {
switch v := value.(type) {
case float64:
if v > 0 {
return int(v)
}
case int:
if v > 0 {
return v
}
case string:
var n int
if _, err := fmt.Sscanf(strings.TrimSpace(v), "%d", &n); err == nil && n > 0 {
return n
}
case []any:
if len(v) > 0 {
return len(v)
}
}
return 0
}
func (d *DoubanProvider) setHeaders(req *http.Request) {
req.Header.Set("User-Agent", userAgents[secureRandomIntn(len(userAgents))])
req.Header.Set("Referer", "https://movie.douban.com/")
+19 -4
View File
@@ -102,6 +102,7 @@ func IsDownloadDedupError(err error) bool {
// deliberately separate from the private torrent URL so API responses never
// need to expose tracker tokens.
type DownloadTaskMeta struct {
SubscriptionID string
Title string
PosterURL string
BackdropURL string
@@ -288,7 +289,7 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt
if !meta.AllowExistingLibrary && d.localMediaAlreadyExists(ctx, title) {
return nil, ErrMediaAlreadyInLibrary
}
if existing, ok := d.findExistingDownloadTask(ctx, title); ok {
if existing, ok := d.findExistingDownloadTask(ctx, title, strings.TrimSpace(meta.SubscriptionID) != ""); ok {
return existing, ErrDownloadAlreadyExists
}
_ = d.ReloadConfig(ctx)
@@ -443,7 +444,7 @@ func localAvailabilityTitleCandidates(title string) []string {
return out
}
func (d *DownloadService) findExistingDownloadTask(ctx context.Context, title string) (*model.DownloadTask, bool) {
func (d *DownloadService) findExistingDownloadTask(ctx context.Context, title string, allowDeletedReadd bool) (*model.DownloadTask, bool) {
key := downloadTaskIdentityKey(title)
if key == "" || d == nil || d.repo == nil || d.repo.Download == nil {
return nil, false
@@ -453,7 +454,11 @@ func (d *DownloadService) findExistingDownloadTask(ctx context.Context, title st
return nil, false
}
for i := range rows {
if !downloadTaskBlocksReadd(rows[i].Status) {
if allowDeletedReadd {
if !downloadTaskBlocksReadd(rows[i].Status) {
continue
}
} else if !downloadTaskBlocksDuplicate(rows[i].Status) {
continue
}
current := downloadTaskIdentityKey(rows[i].Title)
@@ -464,9 +469,18 @@ func (d *DownloadService) findExistingDownloadTask(ctx context.Context, title st
return nil, false
}
func downloadTaskBlocksDuplicate(status string) bool {
switch strings.ToLower(strings.TrimSpace(status)) {
case "failed", "error", "removed", "cancelled", "canceled":
return false
default:
return true
}
}
func downloadTaskBlocksReadd(status string) bool {
switch strings.ToLower(strings.TrimSpace(status)) {
case "failed", "error":
case "failed", "error", "deleted", "removed", "cancelled", "canceled":
return false
default:
return true
@@ -546,6 +560,7 @@ func (d *DownloadService) createTask(ctx context.Context, userID, urlStr, savePa
}
t := &model.DownloadTask{
UserID: userID,
SubscriptionID: strings.TrimSpace(meta.SubscriptionID),
Source: "qbittorrent",
URL: urlStr,
Title: title,
+1
View File
@@ -22,6 +22,7 @@ type ExternalMediaResult struct {
TMDbID int `json:"tmdb_id,omitempty"`
BangumiID int `json:"bangumi_id,omitempty"`
DoubanID string `json:"douban_id,omitempty"`
TheTVDBID string `json:"thetvdb_id,omitempty"`
SubscribeKeyword string `json:"subscribe_keyword"`
TotalEpisodes int `json:"total_episodes,omitempty"`
DownloadedEpisodes int `json:"downloaded_episodes,omitempty"`
+331
View File
@@ -0,0 +1,331 @@
package service
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
type ManualScrapeRequest struct {
Source string `json:"source"`
MediaType string `json:"media_type"`
Title string `json:"title"`
OriginalName string `json:"original_name"`
Overview string `json:"overview"`
PosterURL string `json:"poster_url"`
BackdropURL string `json:"backdrop_url"`
Year int `json:"year"`
Rating float32 `json:"rating"`
TMDbID int `json:"tmdb_id"`
BangumiID int `json:"bangumi_id"`
DoubanID string `json:"douban_id"`
TheTVDBID string `json:"thetvdb_id"`
Languages []string `json:"languages"`
Countries []string `json:"countries"`
Genres []string `json:"genres"`
NSFW bool `json:"nsfw"`
}
func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, query, provider, mediaType string) ([]ExternalMediaResult, error) {
if s == nil || media == nil {
return nil, errors.New("media required")
}
lib, _ := s.repo.Library.FindByID(ctx, media.LibraryID)
query = strings.TrimSpace(query)
if query == "" {
query = firstText(media.Title, media.OriginalName)
}
if query == "" {
query, _ = CleanQuery(media.Path)
}
if query == "" {
return nil, errors.New("search query required")
}
if mediaType == "" && lib != nil {
mediaType = lib.Type
}
mediaType = normalizeMediaType(mediaType, query, "")
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" || provider == "all" {
provider = "all"
}
year := mediaYearHint(media)
if year <= 0 {
_, year = CleanQuery(query)
}
out := make([]ExternalMediaResult, 0, 6)
add := func(source, typ string, match *Match) {
if match == nil || strings.TrimSpace(match.Title) == "" {
return
}
out = append(out, ExternalMediaResult{
Source: source,
MediaType: typ,
Title: match.Title,
Overview: match.Overview,
PosterURL: match.PosterURL,
BackdropURL: match.BackdropURL,
Year: match.Year,
Rating: match.Rating,
TMDbID: match.TMDbID,
BangumiID: match.BangumiID,
DoubanID: match.DoubanID,
TheTVDBID: match.TheTVDBID,
SubscribeKeyword: buildSubscribeKeyword(match.Title, match.Year),
})
}
if provider == "all" || provider == "tmdb" {
for _, match := range s.manualTMDbMatches(ctx, query, year, mediaType) {
typ := mediaType
if typ == "" {
typ = "movie"
}
if match.TMDbID > 0 && isTVLikeTMDbMatch(match, mediaType) {
typ = "tv"
}
add("tmdb", typ, match)
}
}
if provider == "all" || provider == "douban" {
if match := s.manualDoubanMatch(ctx, query); match != nil {
add("douban", normalizeMediaType(mediaType, query, ""), match)
}
}
if provider == "all" || provider == "bangumi" {
if match := s.manualBangumiMatch(ctx, query); match != nil {
add("bangumi", "anime", match)
}
}
if provider == "all" || provider == "thetvdb" {
if match := s.manualTheTVDBMatch(ctx, query); match != nil {
add("thetvdb", "tv", match)
}
}
return dedupeExternalMedia(out), nil
}
func (s *ScraperService) ApplyManualMatch(ctx context.Context, mediaID string, req ManualScrapeRequest) (*model.Media, error) {
media, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil || media == nil {
return nil, errors.New("media not found")
}
lib, _ := s.repo.Library.FindByID(ctx, media.LibraryID)
match, err := s.manualRequestMatch(ctx, req)
if err != nil {
return nil, err
}
if strings.TrimSpace(match.Title) == "" {
return nil, errors.New("manual match title required")
}
if err := s.applyProviderMatch(ctx, media, lib, match); err != nil {
return nil, err
}
return s.repo.Media.FindByID(ctx, mediaID)
}
func (s *ScraperService) manualRequestMatch(ctx context.Context, req ManualScrapeRequest) (*Match, error) {
source := strings.ToLower(strings.TrimSpace(req.Source))
mediaType := normalizeMediaType(req.MediaType, req.Title, "")
fallback := func() (*Match, error) {
match := mergeManualRequestIntoMatch(&Match{}, req)
if strings.TrimSpace(match.Title) == "" {
return nil, errors.New("manual match title required")
}
return match, nil
}
switch {
case req.TMDbID > 0 && (source == "" || source == "tmdb"):
if match := s.manualTMDbMatchByID(ctx, req.TMDbID, mediaType); match != nil {
return mergeManualRequestIntoMatch(match, req), nil
}
case req.BangumiID > 0 && (source == "" || source == "bangumi"):
if s.bangumi != nil {
match, err := s.bangumi.GetSubject(ctx, req.BangumiID)
if err == nil && match != nil {
return mergeManualRequestIntoMatch(match, req), nil
}
}
case strings.TrimSpace(req.TheTVDBID) != "" && (source == "" || source == "thetvdb"):
if s.thetvdb != nil {
match, err := s.thetvdb.GetSeriesMatchByID(ctx, req.TheTVDBID)
if err == nil && match != nil {
return mergeManualRequestIntoMatch(match, req), nil
}
}
case strings.TrimSpace(req.DoubanID) != "" && (source == "" || source == "douban"):
if s.douban != nil {
match, err := s.douban.GetMatchByID(ctx, req.DoubanID)
if err == nil && match != nil {
return mergeManualRequestIntoMatch(match, req), nil
}
}
}
return fallback()
}
func (s *ScraperService) manualTMDbMatches(ctx context.Context, query string, year int, mediaType string) []*Match {
if s.tmdb == nil || !s.tmdb.Enabled() {
return nil
}
if id, ok := parsePositiveInt(query); ok {
if match := s.manualTMDbMatchByID(ctx, id, mediaType); match != nil {
return []*Match{match}
}
}
out := make([]*Match, 0, 2)
if mediaType == "" || mediaType == "movie" {
if match, err := s.tmdb.SearchMovie(ctx, query, year); err == nil && match != nil {
out = append(out, match)
}
}
if mediaType == "" || mediaType == "tv" || mediaType == "anime" || mediaType == "variety" {
if match, err := s.tmdb.SearchTV(ctx, query, year); err == nil && match != nil {
out = append(out, match)
}
}
return out
}
func (s *ScraperService) manualTMDbMatchByID(ctx context.Context, id int, mediaType string) *Match {
if s.tmdb == nil || !s.tmdb.Enabled() || id <= 0 {
return nil
}
if mediaType == "tv" || mediaType == "anime" || mediaType == "variety" {
if match, err := s.tmdb.GetTVMatch(ctx, id); err == nil && match != nil {
return match
}
}
if match, err := s.tmdb.GetMovieMatch(ctx, id); err == nil && match != nil {
return match
}
if match, err := s.tmdb.GetTVMatch(ctx, id); err == nil && match != nil {
return match
}
return nil
}
func (s *ScraperService) manualDoubanMatch(ctx context.Context, query string) *Match {
if s.douban == nil || !s.douban.Enabled() {
return nil
}
if _, ok := parsePositiveInt(query); ok {
if match, err := s.douban.GetMatchByID(ctx, query); err == nil && match != nil {
return match
}
}
match, err := s.douban.SearchMatch(ctx, query)
if err != nil {
return nil
}
return match
}
func (s *ScraperService) manualBangumiMatch(ctx context.Context, query string) *Match {
if s.bangumi == nil || !s.bangumi.Enabled() {
return nil
}
if id, ok := parsePositiveInt(query); ok {
if match, err := s.bangumi.GetSubject(ctx, id); err == nil && match != nil {
return match
}
}
match, err := s.bangumi.Search(ctx, query)
if err != nil {
return nil
}
return match
}
func (s *ScraperService) manualTheTVDBMatch(ctx context.Context, query string) *Match {
if s.thetvdb == nil || !s.thetvdb.Enabled() {
return nil
}
if _, ok := parsePositiveInt(normalizeTheTVDBSeriesID(query)); ok {
if match, err := s.thetvdb.GetSeriesMatchByID(ctx, query); err == nil && match != nil {
return match
}
}
match, err := s.thetvdb.SearchSeries(ctx, query)
if err != nil {
return nil
}
return match
}
func mergeManualRequestIntoMatch(match *Match, req ManualScrapeRequest) *Match {
if match == nil {
match = &Match{}
}
if req.Title != "" {
match.Title = req.Title
}
if req.OriginalName != "" {
match.OriginalName = req.OriginalName
}
if req.Overview != "" {
match.Overview = req.Overview
}
if req.PosterURL != "" {
match.PosterURL = req.PosterURL
}
if req.BackdropURL != "" {
match.BackdropURL = req.BackdropURL
}
if req.Year > 0 {
match.Year = req.Year
}
if req.Rating > 0 {
match.Rating = req.Rating
}
if req.TMDbID > 0 {
match.TMDbID = req.TMDbID
}
if req.BangumiID > 0 {
match.BangumiID = req.BangumiID
}
if req.DoubanID != "" {
match.DoubanID = req.DoubanID
}
if req.TheTVDBID != "" {
match.TheTVDBID = req.TheTVDBID
}
if len(req.Genres) > 0 {
match.Genres = req.Genres
}
if len(req.Countries) > 0 {
match.Countries = req.Countries
}
if len(req.Languages) > 0 {
match.Languages = req.Languages
}
if req.NSFW {
match.NSFW = true
}
return match
}
func isTVLikeTMDbMatch(match *Match, mediaType string) bool {
return mediaType == "tv" || mediaType == "anime" || mediaType == "variety"
}
func parsePositiveInt(value string) (int, bool) {
value = strings.TrimSpace(value)
if strings.Contains(value, ":") {
value = value[strings.LastIndex(value, ":")+1:]
}
id, err := strconv.Atoi(strings.TrimSpace(value))
return id, err == nil && id > 0
}
func manualScrapeBatchName(ids []string) string {
if len(ids) == 1 {
return ids[0]
}
return fmt.Sprintf("%d 个媒体", len(ids))
}
+57 -2
View File
@@ -14,6 +14,7 @@ import (
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
@@ -239,6 +240,17 @@ func dockerVolumePathCandidates(path string) []string {
if strings.HasPrefix(normalized, host+"/") {
addCandidate(mapping.container + strings.TrimPrefix(normalized, host))
}
container := cleanPathForVolumeMapping(mapping.container)
if container == "." || container == "" || strings.HasPrefix(container, ".") {
continue
}
if normalized == container {
addCandidate(host)
continue
}
if strings.HasPrefix(normalized, container+"/") {
addCandidate(host + strings.TrimPrefix(normalized, container))
}
}
for _, marker := range []struct {
@@ -610,6 +622,8 @@ func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, e
return s.repo.Media.FindByID(ctx, id)
}
const maxRecycleBinRecords = 200
// SoftDelete moves a media row to the recycle bin (gorm soft delete).
// The on-disk file is kept; admins can purge it later.
func (s *MediaService) SoftDelete(ctx context.Context, id string) error {
@@ -626,6 +640,9 @@ func (s *MediaService) SoftDelete(ctx context.Context, id string) error {
}
err = s.repo.DB.WithContext(ctx).Where("id = ?", id).Delete(&model.Media{}).Error
if err == nil {
if pruneErr := pruneRecycleBinRows(ctx, s.repo.DB, maxRecycleBinRecords); pruneErr != nil {
return pruneErr
}
s.invalidateMediaCache(ctx)
}
return err
@@ -643,8 +660,11 @@ func (s *MediaService) RestoreDeleted(ctx context.Context, id string) error {
// ListRecycleBin returns every soft-deleted row, newest first.
func (s *MediaService) ListRecycleBin(ctx context.Context, limit int) ([]model.Media, error) {
if limit <= 0 || limit > 500 {
limit = 100
if err := pruneRecycleBinRows(ctx, s.repo.DB, maxRecycleBinRecords); err != nil {
return nil, err
}
if limit <= 0 || limit > maxRecycleBinRecords {
limit = maxRecycleBinRecords
}
var rows []model.Media
err := s.repo.DB.Unscoped().
@@ -655,6 +675,41 @@ func (s *MediaService) ListRecycleBin(ctx context.Context, limit int) ([]model.M
return rows, err
}
func pruneRecycleBinRows(ctx context.Context, db *gorm.DB, keep int) error {
if db == nil {
return nil
}
if keep <= 0 {
keep = maxRecycleBinRecords
}
var rows []struct {
ID string
}
if err := db.WithContext(ctx).Unscoped().
Model(&model.Media{}).
Select("id").
Where("deleted_at IS NOT NULL").
Order("deleted_at desc").
Limit(100000).
Offset(keep).
Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
ids := make([]string, 0, len(rows))
for _, row := range rows {
if row.ID != "" {
ids = append(ids, row.ID)
}
}
if len(ids) == 0 {
return nil
}
return db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.Media{}).Error
}
// PurgeDeleted permanently removes a soft-deleted row from the database.
func (s *MediaService) PurgeDeleted(ctx context.Context, id string) error {
err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.Media{}).Error
+12
View File
@@ -136,6 +136,18 @@ func normalizeMediaType(mediaType, title, category string) string {
case "adult", "nsfw":
return "adult"
}
switch {
case containsAnyText(raw, "成人", "番号", "jav", "nsfw"):
return "adult"
case containsAnyText(raw, "综艺", "真人秀"):
return "variety"
case containsAnyText(raw, "国漫", "日漫", "日番", "动漫", "动画", "anime", "bangumi") && !containsAnyText(raw, "动画电影"):
return "anime"
case containsAnyText(raw, "电视剧", "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "剧集", "tv", "series"):
return "tv"
case containsAnyText(raw, "电影", "movie", "film"):
return "movie"
}
text := strings.ToLower(title + " " + category)
switch {
case strings.Contains(text, "adult") || strings.Contains(text, "nsfw") || strings.Contains(text, "成人") || strings.Contains(text, "番号") || strings.Contains(text, "jav") || strings.Contains(text, "9kg") || classifierJAVCodeRE.MatchString(strings.ToUpper(title+" "+category)):
+17
View File
@@ -163,6 +163,23 @@ func TestClassifyMediaCategoryMatchesMoviePilotStyleRules(t *testing.T) {
}
}
func TestNormalizeMediaTypeAcceptsChineseLibraryTypes(t *testing.T) {
tests := map[string]string{
"华语电影": "movie",
"欧美剧": "tv",
"国产剧": "tv",
"日漫": "anime",
"国漫": "anime",
"综艺": "variety",
"成人": "adult",
}
for input, want := range tests {
if got := normalizeMediaType(input, "测试标题", ""); got != want {
t.Fatalf("normalizeMediaType(%q) = %q, want %q", input, got, want)
}
}
}
func TestSubscriptionResolveClassifiedSavePath(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
+50
View File
@@ -1,6 +1,7 @@
package service
import (
"fmt"
"os"
"path/filepath"
"testing"
@@ -266,6 +267,55 @@ func TestSoftDeleteCloudMediaPurgesRecordWithoutRecycleBin(t *testing.T) {
}
}
func TestListRecycleBinPrunesOldRowsOverLimit(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Media{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
now := time.Now()
for i := 0; i < maxRecycleBinRecords+5; i++ {
deletedAt := now.Add(time.Duration(i) * time.Second)
media := model.Media{
Base: model.Base{
ID: fmt.Sprintf("media-%03d", i),
DeletedAt: gorm.DeletedAt{Time: deletedAt, Valid: true},
},
Title: fmt.Sprintf("Movie %03d", i),
Path: filepath.Join(t.TempDir(), fmt.Sprintf("Movie %03d.mkv", i)),
}
if err := db.Unscoped().Create(&media).Error; err != nil {
t.Fatal(err)
}
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
rows, err := svc.ListRecycleBin(t.Context(), 500)
if err != nil {
t.Fatal(err)
}
if len(rows) != maxRecycleBinRecords {
t.Fatalf("recycle rows = %d, want %d", len(rows), maxRecycleBinRecords)
}
var count int64
if err := db.Unscoped().Model(&model.Media{}).Where("deleted_at IS NOT NULL").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != maxRecycleBinRecords {
t.Fatalf("stored recycle rows = %d, want %d", count, maxRecycleBinRecords)
}
var oldCount int64
if err := db.Unscoped().Model(&model.Media{}).Where("id IN ?", []string{"media-000", "media-001", "media-002", "media-003", "media-004"}).Count(&oldCount).Error; err != nil {
t.Fatal(err)
}
if oldCount != 0 {
t.Fatalf("oldest recycle rows were not pruned, count=%d", oldCount)
}
}
func TestSoftDeleteInvalidatesMediaAndStatsCache(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
+1 -1
View File
@@ -167,7 +167,7 @@ func WriteMediaNFO(m *model.Media) (string, error) {
if err != nil {
return "", err
}
dst := nfoPath(m.Path)
dst := nfoPath(resolveMappedDestinationPath(m.Path))
if err := os.WriteFile(dst, []byte(xml.Header+string(out)+"\n"), 0o644); err != nil { // #nosec G306 -- NFO sidecars must remain readable by media players.
return "", err
}
+38
View File
@@ -0,0 +1,38 @@
package service
import (
"os"
"path/filepath"
"testing"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func TestWriteMediaNFOUsesMappedDestinationPath(t *testing.T) {
root := t.TempDir()
t.Setenv("MEDIASTATION_MEDIA_DIR", root)
t.Setenv("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")
mediaPath := filepath.Join(root, "电影", "测试电影.mkv")
if err := os.MkdirAll(filepath.Dir(mediaPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(mediaPath, []byte("media"), 0o644); err != nil {
t.Fatal(err)
}
got, err := WriteMediaNFO(&model.Media{
Title: "测试电影",
Path: "/media/电影/测试电影.mkv",
Year: 2026,
})
if err != nil {
t.Fatal(err)
}
want := filepath.Join(root, "电影", "测试电影.nfo")
if got != want {
t.Fatalf("nfo path = %q, want %q", got, want)
}
if _, err := os.Stat(want); err != nil {
t.Fatal(err)
}
}
+4
View File
@@ -74,6 +74,10 @@ func qbitLoginOnce(ctx context.Context, client *http.Client, baseURL, username,
switch {
case resp.StatusCode == http.StatusOK && text == "Ok.":
return nil
case resp.StatusCode == http.StatusOK && text == "Ok":
return nil
case resp.StatusCode == http.StatusNoContent:
return nil
case resp.StatusCode == http.StatusOK && text == "Fails.":
return errQbitBadCredentials
case resp.StatusCode == http.StatusForbidden:
+27
View File
@@ -103,6 +103,33 @@ func TestQBitLoginRetriesWithRefererWhenRequired(t *testing.T) {
}
}
func TestQBitLoginAcceptsNoContentFromNewerWebUI(t *testing.T) {
var loginAttempts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/auth/login":
loginAttempts.Add(1)
if r.Header.Get("Referer") == "" || r.Header.Get("Origin") == "" {
http.Error(w, "csrf headers required", http.StatusForbidden)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
jar, _ := cookiejar.New(nil)
httpClient := &http.Client{Jar: jar}
if err := qbitLogin(context.Background(), httpClient, server.URL, "admin", "adminadmin"); err != nil {
t.Fatalf("expected 204 login response to succeed: %v", err)
}
if loginAttempts.Load() != 3 {
t.Fatalf("login attempts = %d, want 3", loginAttempts.Load())
}
}
func TestQBitAddTorrentRequiresVisibleNewTask(t *testing.T) {
oldAttempts := qbitAddVerifyAttempts
oldInterval := qbitAddVerifyInterval
+1 -1
View File
@@ -662,7 +662,7 @@ func (s *SchedulerService) jobPurgeRecycleBin(ctx context.Context) error {
if res.Error != nil && !isMissingTableErr(res.Error) {
return res.Error
}
return nil
return pruneRecycleBinRows(ctx, s.repo.DB, maxRecycleBinRecords)
}
// isMissingTableErr lets the test harness ignore "no such table" errors
+16
View File
@@ -45,6 +45,22 @@ func TestCleanQuery(t *testing.T) {
}
}
func TestManualRequestMatchFallsBackToCandidatePayload(t *testing.T) {
scraper := &ScraperService{}
match, err := scraper.manualRequestMatch(t.Context(), ManualScrapeRequest{
Source: "douban",
Title: "手动选择的电影",
DoubanID: "1234567",
Year: 2026,
})
if err != nil {
t.Fatal(err)
}
if match.Title != "手动选择的电影" || match.DoubanID != "1234567" || match.Year != 2026 {
t.Fatalf("fallback match = %#v", match)
}
}
func TestScrapeQueryCandidatesPreferSeriesFolderAndCJKTitle(t *testing.T) {
lib := &model.Library{
Path: `F:\downloads\国产剧`,
+297
View File
@@ -19,6 +19,7 @@ import (
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
@@ -131,6 +132,19 @@ func (s *SubscriptionService) History(ctx context.Context) ([]model.Subscription
// Delete removes a subscription.
func (s *SubscriptionService) Delete(ctx context.Context, id string) error {
var sub model.Subscription
if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&sub).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return s.repo.DB.WithContext(ctx).Where("id = ?", id).Delete(&model.Subscription{}).Error
}
if err := s.deleteSubscriptionDownloads(ctx, &sub); err != nil {
return err
}
if s.repo.Setting != nil {
_ = s.repo.Setting.Delete(ctx, fmt.Sprintf("subscription.%s.seen", id))
}
return s.repo.DB.Where("id = ?", id).Delete(&model.Subscription{}).Error
}
@@ -207,6 +221,7 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
}
washOff := !sub.WashEnabled
s.updateSubscriptionTotalEpisodes(ctx, sub, s.resolveSubscriptionTotalEpisodes(ctx, sub, inferRSSTotalEpisodes(feed.Channel.Items, sub, filter)))
availQuery := availabilityQuery(subscriptionName(sub), subscriptionFilter(sub))
// RSS 和站点搜索统一使用候选规划:先按订阅规则过滤,再按洗版优先级/集数去重择优。
// 非洗版订阅成功下载一次即满足,媒体库与下载中任务会作为可用性输入避免重复下载。
@@ -230,6 +245,7 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
continue
}
if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, download, savePath, DownloadTaskMeta{
SubscriptionID: sub.ID,
Title: firstNonEmpty(item.Title, sub.Name),
PosterURL: sub.PosterURL,
BackdropURL: sub.BackdropURL,
@@ -300,6 +316,7 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
return 0, nil
}
s.updateSubscriptionTotalEpisodes(ctx, sub, s.resolveSubscriptionTotalEpisodes(ctx, sub, inferSearchTotalEpisodes(results, sub)))
guidKey := fmt.Sprintf("subscription.%s.seen", sub.ID)
seenRaw, _ := s.repo.Setting.Get(ctx, guidKey)
@@ -335,6 +352,7 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
continue
}
if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, realURL, savePath, DownloadTaskMeta{
SubscriptionID: sub.ID,
Title: firstNonEmpty(item.Title, sub.Name),
PosterURL: sub.PosterURL,
BackdropURL: sub.BackdropURL,
@@ -476,6 +494,285 @@ func subscriptionArchiveReason(sub *model.Subscription, availability LocalAvaila
return "订阅媒体已加入下载/入库"
}
func (s *SubscriptionService) updateSubscriptionTotalEpisodes(ctx context.Context, sub *model.Subscription, total int) {
if s == nil || s.repo == nil || s.repo.DB == nil || sub == nil || total <= sub.TotalEpisodes {
return
}
sub.TotalEpisodes = total
_ = s.repo.DB.WithContext(ctx).Model(sub).Update("total_episodes", total).Error
}
func inferRSSTotalEpisodes(items []rssItem, sub *model.Subscription, filter *regexp.Regexp) int {
if !subscriptionShouldInferTotal(sub) {
return 0
}
maxEpisode := 0
for _, item := range items {
title := strings.TrimSpace(item.Title)
if title == "" {
continue
}
if filter != nil && !filter.MatchString(title) {
continue
}
if !subscriptionTitleMatchesQuery(sub, title) {
continue
}
if !matchesSubscriptionRules(sub, title) {
continue
}
_, episode := ParseEpisode(title)
if episode > maxEpisode {
maxEpisode = episode
}
}
return maxEpisode
}
func inferSearchTotalEpisodes(results []SearchResult, sub *model.Subscription) int {
if !subscriptionShouldInferTotal(sub) {
return 0
}
maxEpisode := 0
for _, item := range results {
if !subscriptionTitleMatchesQuery(sub, item.Title) {
continue
}
if !matchesSubscriptionRules(sub, item.Title) {
continue
}
_, episode := ParseEpisode(item.Title)
if episode > maxEpisode {
maxEpisode = episode
}
}
return maxEpisode
}
func subscriptionShouldInferTotal(sub *model.Subscription) bool {
if sub == nil {
return false
}
mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "")
return isSubscriptionSeriesType(mediaType)
}
func (s *SubscriptionService) resolveSubscriptionTotalEpisodes(ctx context.Context, sub *model.Subscription, fallback int) int {
if !subscriptionShouldInferTotal(sub) {
return 0
}
if sub.TotalEpisodes > 0 {
return sub.TotalEpisodes
}
if total := s.resolveSubscriptionMetadataTotalEpisodes(ctx, sub); total > 0 {
return total
}
return fallback
}
func (s *SubscriptionService) resolveSubscriptionMetadataTotalEpisodes(ctx context.Context, sub *model.Subscription) int {
if s == nil || s.scraper == nil || sub == nil {
return 0
}
queries := subscriptionEpisodeMetadataQueries(sub)
// Priority: TMDb -> Douban -> Bangumi -> TheTVDB -> Fanart -> title fallback.
// Fanart.tv is artwork-only in MediaStationGo, so it intentionally does not
// claim episode counts and lets the title fallback handle the final layer.
if s.scraper.tmdb != nil {
if id := subscriptionExplicitTMDbID(sub); id > 0 {
if total, err := s.scraper.tmdb.GetTVEpisodeCount(ctx, id); err == nil && total > 0 {
return total
} else if err != nil && s.log != nil {
s.log.Debug("subscription tmdb episode count failed", zap.Int("tmdb_id", id), zap.Error(err))
}
}
for _, query := range queries {
match, err := s.scraper.tmdb.SearchTV(ctx, query, 0)
if err != nil {
if s.log != nil {
s.log.Debug("subscription tmdb search failed", zap.String("query", query), zap.Error(err))
}
continue
}
if match == nil || match.TMDbID <= 0 {
continue
}
total, err := s.scraper.tmdb.GetTVEpisodeCount(ctx, match.TMDbID)
if err != nil {
if s.log != nil {
s.log.Debug("subscription tmdb episode count failed", zap.Int("tmdb_id", match.TMDbID), zap.Error(err))
}
continue
}
if total > 0 {
return total
}
}
}
if s.scraper.douban != nil {
for _, query := range queries {
total, err := s.scraper.douban.GetEpisodeCount(ctx, query)
if err != nil {
if s.log != nil {
s.log.Debug("subscription douban episode count failed", zap.String("query", query), zap.Error(err))
}
continue
}
if total > 0 {
return total
}
}
}
if s.scraper.bangumi != nil {
for _, query := range queries {
match, err := s.scraper.bangumi.Search(ctx, query)
if err != nil {
if s.log != nil {
s.log.Debug("subscription bangumi search failed", zap.String("query", query), zap.Error(err))
}
continue
}
if match == nil || match.BangumiID <= 0 {
continue
}
total, err := s.scraper.bangumi.GetEpisodeCount(ctx, match.BangumiID)
if err != nil {
if s.log != nil {
s.log.Debug("subscription bangumi episode count failed", zap.Int("bangumi_id", match.BangumiID), zap.Error(err))
}
continue
}
if total > 0 {
return total
}
}
}
if s.scraper.thetvdb != nil {
for _, query := range queries {
match, err := s.scraper.thetvdb.SearchSeries(ctx, query)
if err != nil {
if s.log != nil {
s.log.Debug("subscription thetvdb search failed", zap.String("query", query), zap.Error(err))
}
continue
}
if match == nil || strings.TrimSpace(match.TheTVDBID) == "" {
continue
}
total, err := s.scraper.thetvdb.GetSeriesEpisodeCount(ctx, match.TheTVDBID)
if err != nil {
if s.log != nil {
s.log.Debug("subscription thetvdb episode count failed", zap.String("thetvdb_id", match.TheTVDBID), zap.Error(err))
}
continue
}
if total > 0 {
return total
}
}
}
return 0
}
func subscriptionTitleMatchesQuery(sub *model.Subscription, title string) bool {
if strings.TrimSpace(title) == "" {
return false
}
for _, query := range subscriptionTitleMatchQueries(sub) {
if strings.Contains(normalizeAvailabilityComparable(title), normalizeAvailabilityComparable(query)) {
return true
}
}
return len(subscriptionTitleMatchQueries(sub)) == 0
}
func subscriptionTitleMatchQueries(sub *model.Subscription) []string {
if sub == nil {
return nil
}
return compactUniqueStrings(
availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)),
cleanAvailabilityTitle(subscriptionFilter(sub)),
cleanAvailabilityTitle(subscriptionName(sub)),
)
}
func subscriptionEpisodeMetadataQueries(sub *model.Subscription) []string {
if sub == nil {
return nil
}
raw := []string{
siteSearchKeyword(sub),
sub.Filter,
sub.Name,
availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)),
}
out := make([]string, 0, len(raw)*2)
for _, value := range raw {
value = cleanAvailabilityTitle(value)
if value == "" {
continue
}
if cleaned, _ := CleanQuery(value); cleaned != "" {
out = append(out, cleaned)
}
out = append(out, value)
}
return compactUniqueStrings(out...)
}
func subscriptionExplicitTMDbID(sub *model.Subscription) int {
if sub == nil {
return 0
}
values := []string{sub.Name, sub.Filter, sub.FeedURL}
for _, raw := range values {
for _, pattern := range []string{`(?i)\btmdb[_:\-\s=]+(\d{2,})`, `(?i)\btmdbid[_:\-\s=]+(\d{2,})`} {
if m := regexp.MustCompile(pattern).FindStringSubmatch(raw); len(m) >= 2 {
var id int
if _, err := fmt.Sscanf(m[1], "%d", &id); err == nil && id > 0 {
return id
}
}
}
if u, err := url.Parse(raw); err == nil {
for _, key := range []string{"tmdb_id", "tmdb", "tmdbid"} {
var id int
if _, err := fmt.Sscanf(u.Query().Get(key), "%d", &id); err == nil && id > 0 {
return id
}
}
}
}
return 0
}
func compactUniqueStrings(values ...string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
key := normalizeAvailabilityComparable(value)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, value)
}
return out
}
func subscriptionLooksSingleEpisode(sub *model.Subscription) bool {
if sub == nil {
return false
+131
View File
@@ -0,0 +1,131 @@
package service
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
"gorm.io/gorm"
)
func (s *SubscriptionService) deleteSubscriptionDownloads(ctx context.Context, sub *model.Subscription) error {
if s == nil || s.repo == nil || s.repo.Download == nil || sub == nil {
return nil
}
rows, err := s.repo.Download.List(ctx)
if err != nil {
return err
}
candidates := make([]model.DownloadTask, 0)
for _, row := range rows {
if subscriptionDeleteMatchesTask(ctx, s, sub, row) {
candidates = append(candidates, row)
}
}
if len(candidates) == 0 {
return nil
}
var live []QBitTorrent
if s.downloads != nil && s.downloads.qb != nil && s.downloads.qb.IsConfigured() {
live, _ = s.downloads.qb.List(ctx, "")
}
deletedHashes := map[string]struct{}{}
for _, task := range candidates {
hash := downloadTaskInfoHash(task)
if hash == "" {
hash = matchingLiveTorrentHash(task, live)
}
if hash != "" && s.downloads != nil && s.downloads.qb != nil && s.downloads.qb.IsConfigured() {
key := strings.ToLower(hash)
if _, ok := deletedHashes[key]; !ok {
if err := s.downloads.Delete(ctx, hash, false); err != nil {
return fmt.Errorf("删除订阅关联下载任务 %q 失败: %w", task.Title, err)
}
deletedHashes[key] = struct{}{}
}
continue
}
markDownloadTaskDeletedByID(ctx, s.repo.DB, task)
}
return nil
}
func subscriptionDeleteMatchesTask(ctx context.Context, s *SubscriptionService, sub *model.Subscription, task model.DownloadTask) bool {
if strings.TrimSpace(task.Status) != "" && !downloadTaskBlocksReadd(task.Status) {
return false
}
if strings.TrimSpace(task.SubscriptionID) != "" {
return task.SubscriptionID == sub.ID
}
if strings.TrimSpace(sub.UserID) != "" && strings.TrimSpace(task.UserID) != "" && sub.UserID != task.UserID {
return false
}
baseSavePath := s.subscriptionBaseSavePath(ctx, sub)
if baseSavePath != "" && task.SavePath != "" && !sameOrChildPath(task.SavePath, baseSavePath) && !sameOrChildPath(baseSavePath, task.SavePath) {
return false
}
query := normalizeAvailabilityComparable(availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)))
if query == "" {
return false
}
title := normalizeAvailabilityComparable(task.Title)
if title == "" {
title = normalizeAvailabilityComparable(publicDownloadTitle(task.URL))
}
return title != "" && (strings.Contains(title, query) || strings.Contains(query, title))
}
func downloadTaskInfoHash(task model.DownloadTask) string {
raw := strings.TrimSpace(task.URL)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil {
return ""
}
if strings.EqualFold(parsed.Scheme, "magnet") {
for _, xt := range parsed.Query()["xt"] {
const prefix = "urn:btih:"
if strings.HasPrefix(strings.ToLower(xt), prefix) {
return strings.TrimSpace(xt[len(prefix):])
}
}
}
return ""
}
func matchingLiveTorrentHash(task model.DownloadTask, live []QBitTorrent) string {
key := downloadTaskIdentityKey(task.Title)
if key == "" {
key = downloadTaskIdentityKey(publicDownloadTitle(task.URL))
}
if key == "" {
return ""
}
for _, torrent := range live {
current := downloadTaskIdentityKey(torrent.Name)
if current == "" {
continue
}
if current == key || strings.Contains(current, key) || strings.Contains(key, current) {
return strings.TrimSpace(torrent.Hash)
}
}
return ""
}
func markDownloadTaskDeletedByID(ctx context.Context, db *gorm.DB, task model.DownloadTask) {
if db == nil || strings.TrimSpace(task.ID) == "" {
return
}
_ = db.WithContext(ctx).Model(&model.DownloadTask{}).
Where("id = ?", task.ID).
Updates(map[string]any{
"status": "deleted",
"progress": task.Progress,
}).Error
}
+165 -5
View File
@@ -14,6 +14,7 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
@@ -128,6 +129,101 @@ func TestStableSiteSearchGUIDIgnoresPrivateTokenChanges(t *testing.T) {
}
}
func TestDeleteSubscriptionRemovesDownloaderTaskAndSeenState(t *testing.T) {
const title = "Delete Subscription Show S01E01 1080p"
const hash = "abcdef1234567890abcdef1234567890abcdef12"
var deleteCalls atomic.Int32
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/auth/login":
_, _ = w.Write([]byte("Ok."))
case "/api/v2/torrents/info":
_, _ = w.Write([]byte(`[{"hash":"` + hash + `","name":"` + title + `","state":"downloading","progress":0.2}]`))
case "/api/v2/torrents/delete":
deleteCalls.Add(1)
if got := r.FormValue("deleteFiles"); got != "false" {
t.Fatalf("deleteFiles = %q, want false", got)
}
_, _ = w.Write([]byte("Ok."))
default:
http.NotFound(w, r)
}
}))
defer qb.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Subscription{}, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
configureTestDefaultQB(t, repos, qb.URL)
downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
if err := downloads.ReloadConfig(t.Context()); err != nil {
t.Fatal(err)
}
svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop()))
sub := &model.Subscription{Name: "Delete Subscription Show 自动订阅", Filter: "Delete Subscription Show", FeedURL: "https://rss.example/feed", UserID: "u1", SavePath: "/downloads/tv"}
if err := repos.Subscription.Create(t.Context(), sub); err != nil {
t.Fatal(err)
}
task := &model.DownloadTask{
UserID: "u1",
SubscriptionID: sub.ID,
Source: "qbittorrent",
URL: "https://pt.example/download?id=1",
Title: title,
SavePath: "/downloads/tv",
Status: "downloading",
Progress: 0.2,
}
if err := repos.Download.Create(t.Context(), task); err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(t.Context(), "subscription."+sub.ID+".seen", "guid-1"); err != nil {
t.Fatal(err)
}
if err := svc.Delete(t.Context(), sub.ID); err != nil {
t.Fatalf("delete subscription: %v", err)
}
if got := deleteCalls.Load(); got != 1 {
t.Fatalf("qb delete calls = %d, want 1", got)
}
var updated model.DownloadTask
if err := db.Where("id = ?", task.ID).First(&updated).Error; err != nil {
t.Fatal(err)
}
if updated.Status != "deleted" {
t.Fatalf("download task status = %q, want deleted", updated.Status)
}
seen, err := repos.Setting.Get(t.Context(), "subscription."+sub.ID+".seen")
if err != nil {
t.Fatal(err)
}
if seen != "" {
t.Fatalf("seen state = %q, want cleared", seen)
}
var count int64
if err := db.Model(&model.Subscription{}).Where("id = ?", sub.ID).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("active subscription count = %d, want 0", count)
}
}
func TestDeletedDownloadTaskDoesNotBlockSubscriptionReadd(t *testing.T) {
if downloadTaskBlocksReadd("deleted") {
t.Fatal("deleted download task must not block subscription re-add")
}
if downloadTaskBlocksReadd("removed") {
t.Fatal("removed download task must not block subscription re-add")
}
}
func TestSelectSiteSearchCandidatesOnlyQueuesMissingLocalEpisodes(t *testing.T) {
sub := &model.Subscription{Name: "间谍过家家 自动订阅", Filter: "间谍过家家", MediaType: "tv", TotalEpisodes: 3}
results := []SearchResult{
@@ -390,7 +486,7 @@ func TestSubscriptionLocalAvailabilityMatchesMediaPath(t *testing.T) {
}
}
func TestSubscriptionPendingDownloadAvailabilityIncludesUserDeletedTasks(t *testing.T) {
func TestSubscriptionPendingDownloadAvailabilityIgnoresDeletedTasks(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
@@ -418,16 +514,16 @@ func TestSubscriptionPendingDownloadAvailabilityIncludesUserDeletedTasks(t *test
}
availability := svc.pendingDownloadAvailability(t.Context(), sub)
if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 2)]; !ok {
t.Fatalf("missing user-deleted E02 key: %#v", availability.ExistingEpisodeKeys)
if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, 2)]; ok {
t.Fatalf("deleted E02 task should not count as available: %#v", availability.ExistingEpisodeKeys)
}
results := []SearchResult{
{Title: "间谍过家家 S01E02 1080p WEB-DL", DownloadURL: "https://pt/download/2", Seeders: 80},
{Title: "间谍过家家 S01E03 1080p WEB-DL", DownloadURL: "https://pt/download/3", Seeders: 70},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability)
if len(got) != 1 || got[0].Episode != 3 {
t.Fatalf("selected %#v, want only not-yet-deleted episode 3", got)
if len(got) != 2 || got[0].Episode != 2 || got[1].Episode != 3 {
t.Fatalf("selected %#v, want deleted episode 2 and new episode 3", got)
}
}
@@ -604,6 +700,70 @@ func TestSubscriptionArchiveCompletedSingleEpisodeTV(t *testing.T) {
}
}
func TestSubscriptionArchiveKeepsGenericUnknownTotalSeriesActive(t *testing.T) {
sub := &model.Subscription{
Name: "Some Show 自动订阅",
Filter: "Some Show",
MediaType: "tv",
}
availability := LocalAvailability{
DownloadedEpisodes: 1,
LocalMediaCount: 1,
InLibrary: true,
ExistingEpisodeKeys: map[string]struct{}{
episodeKey(1, 1): {},
},
}
if subscriptionShouldArchive(sub, availability) {
t.Fatal("generic series with unknown total should stay active for incremental episodes")
}
}
func TestInferSubscriptionTotalEpisodesFromSearchAndRSS(t *testing.T) {
sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"}
results := []SearchResult{
{Title: "Some Show S01E01 1080p"},
{Title: "Some Show S01E12 1080p"},
{Title: "Other Show S01E99 1080p"},
}
if got := inferSearchTotalEpisodes(results, sub); got != 12 {
t.Fatalf("search inferred total = %d, want 12", got)
}
items := []rssItem{
{Title: "Some Show S01E02 WEB-DL"},
{Title: "Some Show S01E10 WEB-DL"},
}
if got := inferRSSTotalEpisodes(items, sub, compileFilter("Some Show")); got != 10 {
t.Fatalf("rss inferred total = %d, want 10", got)
}
}
func TestResolveSubscriptionTotalEpisodesPrefersTMDbOverTitleFallback(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/search/tv":
_, _ = w.Write([]byte(`{"results":[{"id":42,"name":"Some Show","first_air_date":"2026-01-01"}]}`))
case "/tv/42":
_, _ = w.Write([]byte(`{"number_of_episodes":13}`))
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
cfg := &config.Config{}
cfg.Secrets.TMDbAPIKey = "test"
cfg.Secrets.TMDbAPIProxy = upstream.URL
tmdb := NewTMDbProvider(cfg, zap.NewNop(), nil)
svc := NewSubscriptionService(cfg, zap.NewNop(), nil, nil, nil, NewHub(zap.NewNop()))
svc.SetScraper(NewScraperService(cfg, zap.NewNop(), nil, tmdb, nil, nil, nil, NewHub(zap.NewNop())))
sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"}
if got := svc.resolveSubscriptionTotalEpisodes(t.Context(), sub, 10); got != 13 {
t.Fatalf("resolved total = %d, want TMDb total 13", got)
}
}
func TestSubscriptionArchiveKeepsWashSubscriptionActive(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
+7
View File
@@ -167,6 +167,13 @@ func telegramPostJSON(ctx context.Context, cfg map[string]string, method string,
return telegramDoWithFallback(ctx, cfg, http.MethodPost, apiURL, string(body), "application/json", timeout)
}
func deleteTelegramWebhook(ctx context.Context, cfg map[string]string) error {
payload := map[string]any{
"drop_pending_updates": false,
}
return telegramPostJSON(ctx, cfg, "deleteWebhook", payload, 15*time.Second)
}
func telegramDo(client *http.Client, req *http.Request) error {
resp, err := client.Do(req)
if err != nil {
+43 -2
View File
@@ -149,8 +149,40 @@ func TestRegisterTelegramBotCommands(t *testing.T) {
}
}
func TestDeleteTelegramWebhookBeforePolling(t *testing.T) {
var gotPath string
var payload map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
err := deleteTelegramWebhook(t.Context(), map[string]string{
"bot_token": "123456:ABC",
"api_base_url": server.URL,
})
if err != nil {
t.Fatal(err)
}
if gotPath != "/bot123456:ABC/deleteWebhook" {
t.Fatalf("path = %q", gotPath)
}
if got := payload["drop_pending_updates"]; got != false {
t.Fatalf("drop_pending_updates = %#v, want false", got)
}
}
func TestTelegramCommandMenusSeparateGroupAndAdminCommands(t *testing.T) {
privateNames := telegramCommandNames(telegramPrivateBotCommandMenu())
for _, required := range []string{"setname", "setpass"} {
if !privateNames[required] {
t.Fatalf("private menu should include %s", required)
}
}
for _, hiddenAlias := range []string{"myinfo", "count"} {
if privateNames[hiddenAlias] {
t.Fatalf("private menu should hide compatibility alias %s", hiddenAlias)
@@ -173,12 +205,12 @@ func TestTelegramCommandMenusSeparateGroupAndAdminCommands(t *testing.T) {
}
adminCommands := telegramAdminBotCommandMenu()
adminNames := telegramCommandNames(adminCommands)
for _, required := range []string{"users", "status", "cleanup_mode", "cleanup_rule"} {
for _, required := range []string{"users", "status", "cleanup_mode", "cleanup_rule", "ucr", "uinfo", "rmemby", "only_rm_record", "renewall", "userip", "auditip", "auditdevice", "auditclient", "udeviceid", "syncunbound", "syncgroupm", "check_ex", "deleted", "embyadmin", "banall", "unbanall", "prouser", "revuser", "embylibs_blockall", "embylibs_unblockall", "proadmin", "revadmin", "backup_db", "restore_from_db"} {
if !adminNames[required] {
t.Fatalf("admin menu should include %s", required)
}
}
for _, hiddenAlias := range []string{"myinfo", "count"} {
for _, hiddenAlias := range []string{"myinfo", "count", "low_activity", "urm", "only_rm_emby", "extraembylibs_blockall", "extraembylibs_unblockall"} {
if adminNames[hiddenAlias] {
t.Fatalf("admin menu should hide compatibility alias %s", hiddenAlias)
}
@@ -188,6 +220,15 @@ func TestTelegramCommandMenusSeparateGroupAndAdminCommands(t *testing.T) {
t.Fatalf("admin menu command %s should use native Mgo wording: %q", command.Command, command.Description)
}
}
help := telegramMgoAdminCommandHelp()
for _, want := range []string{"用户:", "审计:", "清理:", "权限:", "运维:"} {
if !strings.Contains(help, want) {
t.Fatalf("mgo admin help should include category %q in %q", want, help)
}
}
if strings.Contains(help, "/setpass") {
t.Fatalf("mgo admin help should not include user self-service command /setpass")
}
}
func telegramCommandNames(commands []telegramBotCommand) map[string]bool {
+67 -14
View File
@@ -86,6 +86,17 @@ type TelegramBotService struct {
pending map[int64]pendingInput // telegram_user_id -> awaited text input
}
// TelegramPollingStartResult describes what happened when local long polling
// was requested. The admin UI uses it to avoid a silent "started" toast when
// no Telegram channel can actually poll.
type TelegramPollingStartResult struct {
Message string `json:"message"`
Started int `json:"started"`
AlreadyRunning int `json:"already_running"`
Skipped int `json:"skipped"`
Errors []string `json:"errors,omitempty"`
}
// pendingInput tracks a button-initiated action that awaits the user's next
// text message (e.g. tapping「注册」then sending "用户名 密码").
type pendingInput struct {
@@ -503,8 +514,8 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage)
"<b>/signin</b> — 签到\n" +
"<b>/devices</b> — 查看登录设备\n" +
"<b>/kick all|编号</b> — 踢下线设备\n" +
"<b>/setname 新用户名</b> — 修改用户名\n" +
"<b>/setpass 新密码</b> — 修改密码\n" +
"<b>/setname 当前密码 新用户名</b> — 修改用户名\n" +
"<b>/setpass 当前密码 新密码</b> — 修改密码\n" +
"<b>/redeem 兑换码</b> — 注册或续期兑换\n" +
"<b>/hideadult on|off</b> — 隐藏或显示成人目录\n\n" +
"系统状态、搜索、下载列表与统计命令仅管理员可用。"
@@ -514,7 +525,7 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage)
"<b>/help</b> — 帮助信息\n" +
"<b>/account</b> / <b>/devices</b> / <b>/kick all|编号</b> — 用户自助设备管理\n" +
"<b>/signin</b> / <b>/redeem 兑换码</b> — 签到与兑换\n" +
"<b>/setname 新用户名</b> / <b>/setpass 新密码</b> — 用户自助改名改密\n" +
"<b>/setname 当前密码 新用户名</b> / <b>/setpass 当前密码 新密码</b> — 用户自助改名改密\n" +
"<b>/register 用户名 密码</b> — 注册新账号(需管理员开启)\n" +
"<b>/registration on [名额]|off</b> — 开启/关闭普通用户注册(管理员)\n" +
"<b>/capacity</b> / <b>/users</b> — 容量与用户管理(管理员)\n" +
@@ -533,12 +544,7 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage)
"<b>/search 关键词</b> — 搜索媒体库\n" +
"<b>/downloads</b> — 下载列表\n" +
"<b>/stats</b> — 媒体库统计\n\n" +
"<b>Mgo 管理命令:</b>\n" +
"用户:<code>/ucr</code> <code>/uinfo</code> <code>/rmemby</code> <code>/only_rm_record</code> <code>/renewall</code>\n" +
"审计:<code>/userip</code> <code>/auditip</code> <code>/auditdevice</code> <code>/auditclient</code> <code>/udeviceid</code>\n" +
"清理:<code>/syncunbound</code> <code>/syncgroupm</code> <code>/check_ex</code> <code>/deleted</code> <code>/low_activity</code>\n" +
"权限:<code>/embyadmin</code> <code>/banall</code> <code>/unbanall</code> <code>/prouser</code> <code>/revuser</code> <code>/embylibs_blockall</code> <code>/embylibs_unblockall</code>\n" +
"运维:<code>/proadmin</code> <code>/revadmin</code> <code>/backup_db</code> <code>/restore_from_db</code>\n\n" +
telegramMgoAdminCommandHelp() + "\n\n" +
"<b>自动推送事件:</b>\n" +
"• 订阅命中新资源\n" +
"• 下载任务完成\n" +
@@ -546,6 +552,16 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage)
"• 系统异常通知"
}
func telegramMgoAdminCommandHelp() string {
return "<b>Mgo 管理命令(管理员可用,已注册到命令栏):</b>\n" +
"用户:<code>/ucr 用户名 密码 [天数]</code> 创建账号;<code>/uinfo 用户名</code> 查询账号;<code>/rmemby 用户名 confirm</code> 删除账号;<code>/only_rm_record tg:ID|用户名</code> 仅删 Bot 绑定;<code>/renewall 天数 confirm</code> 批量续期。\n" +
"审计:<code>/userip 用户名</code> 查用户 IP;<code>/auditip IP</code> 按 IP 审计;<code>/auditdevice 关键词</code> 按终端设备审计;<code>/auditclient 关键词</code> 按客户端审计;<code>/udeviceid 设备ID</code> 按设备指纹审计。\n" +
"清理:<code>/syncunbound</code> 检查未绑定账号;<code>/syncgroupm</code> 校验群成员;<code>/check_ex</code> 检查过期账号;<code>/deleted</code> 按保号规则预览清理候选。\n" +
"权限:<code>/embyadmin 用户名 on|off</code> 设置管理员;<code>/banall confirm</code>/<code>/unbanall confirm</code> 批量禁用/解禁;<code>/prouser 用户名</code>/<code>/revuser 用户名</code> 管理保护名单;<code>/embylibs_blockall</code>/<code>/embylibs_unblockall</code> 批量禁用/开放媒体库权限。\n" +
"运维:<code>/proadmin TelegramID</code>/<code>/revadmin TelegramID</code> 管理 Bot 管理员;<code>/backup_db</code> 备份数据库;<code>/restore_from_db 文件名 confirm</code> 恢复数据库。\n" +
"说明:重复别名如 <code>/low_activity</code>、<code>/urm</code> 仍可兼容识别,但不显示在命令栏。"
}
// cmdStatus 处理 /status 命令。
func (s *TelegramBotService) cmdHideAdult(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
channel := s.findChannelForMessage(ctx, msg)
@@ -784,36 +800,65 @@ func (s *TelegramBotService) mediaStatsQuery(libraryIDs []string) *gorm.DB {
// ── Polling ──
// StartPolling 为所有已启用的 Telegram 通知渠道启动长轮询。
func (s *TelegramBotService) StartPolling(ctx context.Context) {
func (s *TelegramBotService) StartPolling(ctx context.Context) TelegramPollingStartResult {
result := TelegramPollingStartResult{Message: "telegram polling started"}
channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram")
if err != nil {
s.log.Error("failed to list telegram channels for polling", zap.Error(err))
return
result.Message = "failed to list telegram channels"
result.Errors = append(result.Errors, err.Error())
return result
}
if len(channels) == 0 {
result.Message = "no telegram channels configured"
result.Errors = append(result.Errors, "没有配置 Telegram 通知渠道")
return result
}
for _, ch := range channels {
if !ch.Enabled {
result.Skipped++
result.Errors = append(result.Errors, ch.Name+": 通知渠道未启用")
continue
}
configStr := ch.Config
if s.crypto != nil && configStr != "" {
configStr = s.crypto.Decrypt(configStr)
}
var cfg map[string]string
if err := json.Unmarshal([]byte(configStr), &cfg); err != nil {
var rawCfg map[string]any
if err := json.Unmarshal([]byte(configStr), &rawCfg); err != nil {
result.Skipped++
result.Errors = append(result.Errors, ch.Name+": Telegram 配置解析失败: "+err.Error())
continue
}
cfg := telegramStringConfigFromAny(rawCfg)
botToken := cfg["bot_token"]
if botToken == "" {
result.Skipped++
result.Errors = append(result.Errors, ch.Name+": Telegram Bot Token 为空")
continue
}
s.pollingMu.Lock()
if _, running := s.pollingCancel[botToken]; running {
s.pollingMu.Unlock()
result.AlreadyRunning++
continue
}
s.pollingMu.Unlock()
if err := registerTelegramBotCommands(ctx, cfg); err != nil && s.log != nil {
s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err)))
}
if err := deleteTelegramWebhook(ctx, cfg); err != nil {
result.Skipped++
result.Errors = append(result.Errors, ch.Name+": "+sanitizeTelegramError(err).Error())
continue
}
s.pollingMu.Lock()
if _, running := s.pollingCancel[botToken]; running {
s.pollingMu.Unlock()
result.AlreadyRunning++
continue
}
pollCtx, cancel := context.WithCancel(context.Background())
@@ -822,19 +867,27 @@ func (s *TelegramBotService) StartPolling(ctx context.Context) {
channel := ch
go s.pollLoop(pollCtx, cfg, &channel)
result.Started++
s.log.Info("started telegram polling", zap.String("channel", ch.Name))
}
if result.Started == 0 && result.AlreadyRunning == 0 {
result.Message = "no enabled telegram channels started"
}
return result
}
// StopPolling 停止所有 Telegram 长轮询。
func (s *TelegramBotService) StopPolling() {
func (s *TelegramBotService) StopPolling() int {
s.pollingMu.Lock()
defer s.pollingMu.Unlock()
stopped := 0
for token, cancel := range s.pollingCancel {
cancel()
delete(s.pollingCancel, token)
stopped++
}
s.log.Info("telegram polling stopped")
return stopped
}
// pollLoop 对单个 Bot Token 执行长轮询。
+42
View File
@@ -227,6 +227,8 @@ func telegramPrivateBotCommandMenu() []telegramBotCommand {
{Command: "signin", Description: "签到"},
{Command: "devices", Description: "查看登录设备"},
{Command: "kick", Description: "踢下线设备"},
{Command: "setname", Description: "修改用户名(需当前密码)"},
{Command: "setpass", Description: "修改密码(需当前密码)"},
{Command: "hideadult", Description: "隐藏/显示成人媒体库"},
{Command: "redeem", Description: "兑换注册码或续期码"},
{Command: "register", Description: "注册新账号"},
@@ -258,9 +260,49 @@ func telegramAdminBotCommandMenu() []telegramBotCommand {
telegramBotCommand{Command: "cleanup_mode", Description: "查看固定保号模式(管理员)"},
telegramBotCommand{Command: "cleanup_rule", Description: "Mgo保号规则管理(管理员)"},
)
commands = append(commands, telegramMgoAdminBotCommandMenu()...)
return commands
}
func telegramMgoAdminBotCommandMenu() []telegramBotCommand {
return []telegramBotCommand{
// 用户管理:保留 Sakura/Mgo 常用命令名,剔除 /urm、/only_rm_emby 等重复别名。
{Command: "ucr", Description: "Mgo用户: 创建账号"},
{Command: "uinfo", Description: "Mgo用户: 查询账号"},
{Command: "rmemby", Description: "Mgo用户: 删除账号"},
{Command: "only_rm_record", Description: "Mgo用户: 仅删Bot绑定"},
{Command: "renewall", Description: "Mgo用户: 批量续期"},
// 审计:按 IP、设备指纹、客户端和 Telegram 绑定信息排查共享。
{Command: "userip", Description: "Mgo审计: 查询用户IP"},
{Command: "auditip", Description: "Mgo审计: 按IP审计"},
{Command: "auditdevice", Description: "Mgo审计: 按设备审计"},
{Command: "auditclient", Description: "Mgo审计: 按客户端审计"},
{Command: "udeviceid", Description: "Mgo审计: 按设备ID审计"},
// 清理:/low_activity 是 /deleted 的兼容别名,不显示在命令栏。
{Command: "syncunbound", Description: "Mgo清理: 未绑定账号"},
{Command: "syncgroupm", Description: "Mgo清理: 校验群成员"},
{Command: "check_ex", Description: "Mgo清理: 检查过期账号"},
{Command: "deleted", Description: "Mgo清理: 保号清理预览"},
// 权限:批量禁用、保护用户、媒体库播放权限。
{Command: "embyadmin", Description: "Mgo权限: 设置管理员"},
{Command: "banall", Description: "Mgo权限: 批量禁用用户"},
{Command: "unbanall", Description: "Mgo权限: 批量解禁用户"},
{Command: "prouser", Description: "Mgo权限: 加入保护名单"},
{Command: "revuser", Description: "Mgo权限: 移出保护名单"},
{Command: "embylibs_blockall", Description: "Mgo权限: 批量禁用媒体库"},
{Command: "embylibs_unblockall", Description: "Mgo权限: 批量开放媒体库"},
// 运维:Bot 管理员与数据库备份恢复。
{Command: "proadmin", Description: "Mgo运维: 添加Bot管理员"},
{Command: "revadmin", Description: "Mgo运维: 移除Bot管理员"},
{Command: "backup_db", Description: "Mgo运维: 备份数据库"},
{Command: "restore_from_db", Description: "Mgo运维: 恢复数据库"},
}
}
func registerTelegramBotCommands(ctx context.Context, cfg map[string]string) error {
if strings.TrimSpace(cfg["bot_token"]) == "" {
return nil
+10 -2
View File
@@ -91,7 +91,10 @@ func (s *TelegramBotService) mainMenu(ctx context.Context, channel *model.Notify
{Text: "🔓 开注设置", Data: "adm_openreg"},
{Text: "🎟 生成兑换码", Data: "adm_gencode"},
},
[]telegramInlineButton{{Text: "⚙️ 设备策略", Data: "adm_devicepolicy"}},
[]telegramInlineButton{
{Text: "⚙️ 设备策略", Data: "adm_devicepolicy"},
{Text: "🛠 管理命令", Data: "adm_mgo_commands"},
},
)
}
return telegramCommandReply{Text: header, Buttons: rows}
@@ -136,7 +139,10 @@ func (s *TelegramBotService) mainMenu(ctx context.Context, channel *model.Notify
{Text: "🔓 开注设置", Data: "adm_openreg"},
{Text: "🎟 生成兑换码", Data: "adm_gencode"},
},
[]telegramInlineButton{{Text: "⚙️ 设备策略", Data: "adm_devicepolicy"}},
[]telegramInlineButton{
{Text: "⚙️ 设备策略", Data: "adm_devicepolicy"},
{Text: "🛠 管理命令", Data: "adm_mgo_commands"},
},
)
}
@@ -244,6 +250,8 @@ func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *mo
return s.replyUserRenew(ctx, strings.TrimPrefix(data, "urenew:")), true
case data == "adm_devicepolicy":
return s.replyDevicePolicy(ctx), true
case data == "adm_mgo_commands":
return telegramCommandReply{Text: telegramMgoAdminCommandHelp(), Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}}, true
case strings.HasPrefix(data, "dp_toggle:"):
return s.replyDevicePolicyToggle(ctx, strings.TrimPrefix(data, "dp_toggle:")), true
}
+130
View File
@@ -18,6 +18,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
@@ -139,6 +140,135 @@ func (t *TheTVDBProvider) SearchSeries(ctx context.Context, query string) (*Matc
return m, nil
}
func (t *TheTVDBProvider) GetSeriesEpisodeCount(ctx context.Context, seriesID string) (int, error) {
if !t.Enabled() || strings.TrimSpace(seriesID) == "" {
return 0, nil
}
tok, err := t.Login(ctx)
if err != nil {
return 0, err
}
id := normalizeTheTVDBSeriesID(seriesID)
u := fmt.Sprintf("https://api4.thetvdb.com/v4/series/%s/extended?meta=episodes", urlEscape(id))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return 0, err
}
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := t.client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return 0, fmt.Errorf("thetvdb series detail: %d", resp.StatusCode)
}
var out struct {
Data struct {
Episodes []struct {
ID int `json:"id"`
Name string `json:"name"`
Number int `json:"number"`
SeasonNumber int `json:"seasonNumber"`
Type string `json:"type"`
} `json:"episodes"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return 0, err
}
count := 0
for _, episode := range out.Data.Episodes {
if episode.SeasonNumber == 0 || strings.EqualFold(episode.Type, "special") {
continue
}
count++
}
if count > 0 {
return count, nil
}
return len(out.Data.Episodes), nil
}
func (t *TheTVDBProvider) GetSeriesMatchByID(ctx context.Context, seriesID string) (*Match, error) {
if !t.Enabled() || strings.TrimSpace(seriesID) == "" {
return nil, nil
}
tok, err := t.Login(ctx)
if err != nil {
return nil, err
}
id := normalizeTheTVDBSeriesID(seriesID)
u := fmt.Sprintf("https://api4.thetvdb.com/v4/series/%s/extended", urlEscape(id))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := t.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("thetvdb series detail: %d", resp.StatusCode)
}
var out struct {
Data struct {
ID int `json:"id"`
Name string `json:"name"`
Overview string `json:"overview"`
Image string `json:"image"`
ImageURL string `json:"image_url"`
FirstAired string `json:"firstAired"`
Year string `json:"year"`
Score int `json:"score"`
Average string `json:"averageRuntime"`
Genres []struct {
Name string `json:"name"`
} `json:"genres"`
OriginalCountry string `json:"originalCountry"`
OriginalLanguage string `json:"originalLanguage"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
idValue := id
if out.Data.ID > 0 {
idValue = fmt.Sprint(out.Data.ID)
}
m := &Match{
TheTVDBID: idValue,
Title: out.Data.Name,
Overview: out.Data.Overview,
PosterURL: firstNonEmpty(out.Data.Image, out.Data.ImageURL),
Languages: nonEmptyStrings(out.Data.OriginalLanguage),
Countries: nonEmptyStrings(out.Data.OriginalCountry),
}
if out.Data.Score > 0 {
m.Rating = float32(out.Data.Score) / 10
}
yearText := firstNonEmpty(out.Data.Year, out.Data.FirstAired)
if len(yearText) >= 4 {
_, _ = fmt.Sscanf(yearText[:4], "%d", &m.Year)
}
for _, genre := range out.Data.Genres {
if strings.TrimSpace(genre.Name) != "" {
m.Genres = append(m.Genres, genre.Name)
}
}
return m, nil
}
func normalizeTheTVDBSeriesID(seriesID string) string {
seriesID = strings.TrimSpace(seriesID)
if idx := strings.LastIndex(seriesID, "-"); idx >= 0 && idx+1 < len(seriesID) {
return seriesID[idx+1:]
}
return seriesID
}
// urlEscape is a tiny replacement for net/url.QueryEscape kept inline so
// the file does not pull a second import for one call.
func urlEscape(s string) string {
+137
View File
@@ -300,6 +300,143 @@ func (t *TMDbProvider) getJSON(ctx context.Context, url string, out any) error {
return json.NewDecoder(resp.Body).Decode(out)
}
func (t *TMDbProvider) GetMovieMatch(ctx context.Context, tmdbID int) (*Match, error) {
if tmdbID <= 0 {
return nil, nil
}
apiKey := t.resolveAPIKey(ctx)
if apiKey == "" {
return nil, nil
}
base := t.resolveBaseURL(ctx)
q := url.Values{}
q.Set("api_key", apiKey)
q.Set("language", "zh-CN")
u := base + "/movie/" + fmt.Sprint(tmdbID) + "?" + q.Encode()
var r struct {
ID int `json:"id"`
Title string `json:"title"`
OriginalTitle string `json:"original_title"`
OriginalLanguage string `json:"original_language"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date"`
VoteAverage float32 `json:"vote_average"`
Genres []struct {
Name string `json:"name"`
} `json:"genres"`
ProductionCountries []struct {
Iso3166_1 string `json:"iso_3166_1"`
} `json:"production_countries"`
SpokenLanguages []struct {
Iso639_1 string `json:"iso_639_1"`
} `json:"spoken_languages"`
}
if err := t.getJSON(ctx, u, &r); err != nil {
return nil, err
}
m := &Match{
TMDbID: r.ID,
Title: r.Title,
OriginalName: r.OriginalTitle,
Overview: r.Overview,
Rating: r.VoteAverage,
Languages: nonEmptyStrings(r.OriginalLanguage),
}
if m.Title == "" {
m.Title = r.OriginalTitle
}
if r.PosterPath != "" {
m.PosterURL = t.imgCDN + "/w500" + r.PosterPath
}
if r.BackdropPath != "" {
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
}
if len(r.ReleaseDate) >= 4 {
_, _ = fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year)
}
for _, g := range r.Genres {
m.Genres = append(m.Genres, g.Name)
}
for _, c := range r.ProductionCountries {
m.Countries = append(m.Countries, c.Iso3166_1)
}
for _, l := range r.SpokenLanguages {
m.Languages = append(m.Languages, l.Iso639_1)
}
m.Genres = deduplicate(m.Genres)
m.Countries = deduplicate(m.Countries)
m.Languages = deduplicate(m.Languages)
return m, nil
}
func (t *TMDbProvider) GetTVMatch(ctx context.Context, tmdbID int) (*Match, error) {
if tmdbID <= 0 {
return nil, nil
}
apiKey := t.resolveAPIKey(ctx)
if apiKey == "" {
return nil, nil
}
base := t.resolveBaseURL(ctx)
q := url.Values{}
q.Set("api_key", apiKey)
q.Set("language", "zh-CN")
u := base + "/tv/" + fmt.Sprint(tmdbID) + "?" + q.Encode()
var r struct {
ID int `json:"id"`
Name string `json:"name"`
OriginalName string `json:"original_name"`
OriginalLanguage string `json:"original_language"`
OriginCountry []string `json:"origin_country"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
FirstAirDate string `json:"first_air_date"`
VoteAverage float32 `json:"vote_average"`
Genres []struct {
Name string `json:"name"`
} `json:"genres"`
SpokenLanguages []struct {
Iso639_1 string `json:"iso_639_1"`
} `json:"spoken_languages"`
}
if err := t.getJSON(ctx, u, &r); err != nil {
return nil, err
}
m := &Match{
TMDbID: r.ID,
Title: r.Name,
OriginalName: r.OriginalName,
Overview: r.Overview,
Rating: r.VoteAverage,
Languages: nonEmptyStrings(r.OriginalLanguage),
Countries: deduplicate(r.OriginCountry),
}
if m.Title == "" {
m.Title = r.OriginalName
}
if r.PosterPath != "" {
m.PosterURL = t.imgCDN + "/w500" + r.PosterPath
}
if r.BackdropPath != "" {
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
}
if len(r.FirstAirDate) >= 4 {
_, _ = fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year)
}
for _, g := range r.Genres {
m.Genres = append(m.Genres, g.Name)
}
for _, l := range r.SpokenLanguages {
m.Languages = append(m.Languages, l.Iso639_1)
}
m.Genres = deduplicate(m.Genres)
m.Languages = deduplicate(m.Languages)
return m, nil
}
// TMDbDetails holds extended metadata from the /movie/{id} or /tv/{id} endpoints.
type TMDbDetails struct {
Languages []string `json:"languages"`