diff --git a/internal/handler/manual_scrape.go b/internal/handler/manual_scrape.go
new file mode 100644
index 0000000..813a882
--- /dev/null
+++ b/internal/handler/manual_scrape.go
@@ -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
+}
diff --git a/internal/handler/recycle.go b/internal/handler/recycle.go
index 4ddc6cc..e88e369 100644
--- a/internal/handler/recycle.go
+++ b/internal/handler/recycle.go
@@ -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
+}
diff --git a/internal/handler/routes_authenticated.go b/internal/handler/routes_authenticated.go
index 79b0885..d4886d7 100644
--- a/internal/handler/routes_authenticated.go
+++ b/internal/handler/routes_authenticated.go
@@ -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))
diff --git a/internal/handler/telegram_webhook.go b/internal/handler/telegram_webhook.go
index 2762384..fef3785 100644
--- a/internal/handler/telegram_webhook.go
+++ b/internal/handler/telegram_webhook.go
@@ -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})
}
}
diff --git a/internal/model/model.go b/internal/model/model.go
index 1b332b3..e0ff62c 100644
--- a/internal/model/model.go
+++ b/internal/model/model.go
@@ -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.
diff --git a/internal/repository/repository.go b/internal/repository/repository.go
index 875d664..c37e5be 100644
--- a/internal/repository/repository.go
+++ b/internal/repository/repository.go
@@ -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
diff --git a/internal/service/bangumi.go b/internal/service/bangumi.go
index f19dfa7..1aaa392 100644
--- a/internal/service/bangumi.go
+++ b/internal/service/bangumi.go
@@ -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 {
diff --git a/internal/service/douban.go b/internal/service/douban.go
index 25d222e..a29d310 100644
--- a/internal/service/douban.go
+++ b/internal/service/douban.go
@@ -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/")
diff --git a/internal/service/downloads.go b/internal/service/downloads.go
index 237f75f..a7eeb59 100644
--- a/internal/service/downloads.go
+++ b/internal/service/downloads.go
@@ -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,
diff --git a/internal/service/external_search.go b/internal/service/external_search.go
index 3be6d62..33ea605 100644
--- a/internal/service/external_search.go
+++ b/internal/service/external_search.go
@@ -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"`
diff --git a/internal/service/manual_scrape.go b/internal/service/manual_scrape.go
new file mode 100644
index 0000000..2d91b97
--- /dev/null
+++ b/internal/service/manual_scrape.go
@@ -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))
+}
diff --git a/internal/service/media.go b/internal/service/media.go
index ed1ba23..12d3875 100644
--- a/internal/service/media.go
+++ b/internal/service/media.go
@@ -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
diff --git a/internal/service/media_classifier.go b/internal/service/media_classifier.go
index 8c3daef..d09cab3 100644
--- a/internal/service/media_classifier.go
+++ b/internal/service/media_classifier.go
@@ -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)):
diff --git a/internal/service/media_classifier_test.go b/internal/service/media_classifier_test.go
index 0485abd..3d39016 100644
--- a/internal/service/media_classifier_test.go
+++ b/internal/service/media_classifier_test.go
@@ -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 {
diff --git a/internal/service/media_test.go b/internal/service/media_test.go
index f88cd89..b0ff217 100644
--- a/internal/service/media_test.go
+++ b/internal/service/media_test.go
@@ -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 {
diff --git a/internal/service/nfo.go b/internal/service/nfo.go
index 13feda1..07d06cd 100644
--- a/internal/service/nfo.go
+++ b/internal/service/nfo.go
@@ -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
}
diff --git a/internal/service/nfo_test.go b/internal/service/nfo_test.go
new file mode 100644
index 0000000..a58cc1c
--- /dev/null
+++ b/internal/service/nfo_test.go
@@ -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)
+ }
+}
diff --git a/internal/service/qbittorrent_login.go b/internal/service/qbittorrent_login.go
index f02d3fc..a55d378 100644
--- a/internal/service/qbittorrent_login.go
+++ b/internal/service/qbittorrent_login.go
@@ -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:
diff --git a/internal/service/qbittorrent_test.go b/internal/service/qbittorrent_test.go
index 49e0f3a..d9081cb 100644
--- a/internal/service/qbittorrent_test.go
+++ b/internal/service/qbittorrent_test.go
@@ -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
diff --git a/internal/service/scheduler.go b/internal/service/scheduler.go
index 32acc7f..e6d292e 100644
--- a/internal/service/scheduler.go
+++ b/internal/service/scheduler.go
@@ -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
diff --git a/internal/service/scraper_test.go b/internal/service/scraper_test.go
index 478869d..c36a539 100644
--- a/internal/service/scraper_test.go
+++ b/internal/service/scraper_test.go
@@ -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\国产剧`,
diff --git a/internal/service/subscription.go b/internal/service/subscription.go
index 0923d90..09aeaa9 100644
--- a/internal/service/subscription.go
+++ b/internal/service/subscription.go
@@ -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
diff --git a/internal/service/subscription_delete.go b/internal/service/subscription_delete.go
new file mode 100644
index 0000000..22af697
--- /dev/null
+++ b/internal/service/subscription_delete.go
@@ -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
+}
diff --git a/internal/service/subscription_test.go b/internal/service/subscription_test.go
index 62aadea..6f3f7c7 100644
--- a/internal/service/subscription_test.go
+++ b/internal/service/subscription_test.go
@@ -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 {
diff --git a/internal/service/telegram_api.go b/internal/service/telegram_api.go
index 3915e4a..e7570fe 100644
--- a/internal/service/telegram_api.go
+++ b/internal/service/telegram_api.go
@@ -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 {
diff --git a/internal/service/telegram_api_test.go b/internal/service/telegram_api_test.go
index 9fd52cf..10f8647 100644
--- a/internal/service/telegram_api_test.go
+++ b/internal/service/telegram_api_test.go
@@ -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 {
diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go
index 17cd4a3..510b023 100644
--- a/internal/service/telegram_bot.go
+++ b/internal/service/telegram_bot.go
@@ -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)
"/signin — 签到\n" +
"/devices — 查看登录设备\n" +
"/kick all|编号 — 踢下线设备\n" +
- "/setname 新用户名 — 修改用户名\n" +
- "/setpass 新密码 — 修改密码\n" +
+ "/setname 当前密码 新用户名 — 修改用户名\n" +
+ "/setpass 当前密码 新密码 — 修改密码\n" +
"/redeem 兑换码 — 注册或续期兑换\n" +
"/hideadult on|off — 隐藏或显示成人目录\n\n" +
"系统状态、搜索、下载列表与统计命令仅管理员可用。"
@@ -514,7 +525,7 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage)
"/help — 帮助信息\n" +
"/account / /devices / /kick all|编号 — 用户自助设备管理\n" +
"/signin / /redeem 兑换码 — 签到与兑换\n" +
- "/setname 新用户名 / /setpass 新密码 — 用户自助改名改密\n" +
+ "/setname 当前密码 新用户名 / /setpass 当前密码 新密码 — 用户自助改名改密\n" +
"/register 用户名 密码 — 注册新账号(需管理员开启)\n" +
"/registration on [名额]|off — 开启/关闭普通用户注册(管理员)\n" +
"/capacity / /users — 容量与用户管理(管理员)\n" +
@@ -533,12 +544,7 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage)
"/search 关键词 — 搜索媒体库\n" +
"/downloads — 下载列表\n" +
"/stats — 媒体库统计\n\n" +
- "Mgo 管理命令:\n" +
- "用户:
+ {scopeLabel || media.title} · {targetIds.length > 1 ? `将应用到 ${targetIds.length} 个媒体` : '单个媒体'}
+ {item.overview || '暂无简介'} {candidateIDText(item)} 系统后台高级控制面板 该库尚未发现任何剧集,触发一次扫描后再来看看/ucr /uinfo /rmemby /only_rm_record /renewall\n" +
- "审计:/userip /auditip /auditdevice /auditclient /udeviceid\n" +
- "清理:/syncunbound /syncgroupm /check_ex /deleted /low_activity\n" +
- "权限:/embyadmin /banall /unbanall /prouser /revuser /embylibs_blockall /embylibs_unblockall\n" +
- "运维:/proadmin /revadmin /backup_db /restore_from_db\n\n" +
+ telegramMgoAdminCommandHelp() + "\n\n" +
"自动推送事件:\n" +
"• 订阅命中新资源\n" +
"• 下载任务完成\n" +
@@ -546,6 +552,16 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage)
"• 系统异常通知"
}
+func telegramMgoAdminCommandHelp() string {
+ return "Mgo 管理命令(管理员可用,已注册到命令栏):\n" +
+ "用户:/ucr 用户名 密码 [天数] 创建账号;/uinfo 用户名 查询账号;/rmemby 用户名 confirm 删除账号;/only_rm_record tg:ID|用户名 仅删 Bot 绑定;/renewall 天数 confirm 批量续期。\n" +
+ "审计:/userip 用户名 查用户 IP;/auditip IP 按 IP 审计;/auditdevice 关键词 按终端设备审计;/auditclient 关键词 按客户端审计;/udeviceid 设备ID 按设备指纹审计。\n" +
+ "清理:/syncunbound 检查未绑定账号;/syncgroupm 校验群成员;/check_ex 检查过期账号;/deleted 按保号规则预览清理候选。\n" +
+ "权限:/embyadmin 用户名 on|off 设置管理员;/banall confirm//unbanall confirm 批量禁用/解禁;/prouser 用户名//revuser 用户名 管理保护名单;/embylibs_blockall//embylibs_unblockall 批量禁用/开放媒体库权限。\n" +
+ "运维:/proadmin TelegramID//revadmin TelegramID 管理 Bot 管理员;/backup_db 备份数据库;/restore_from_db 文件名 confirm 恢复数据库。\n" +
+ "说明:重复别名如 /low_activity、/urm 仍可兼容识别,但不显示在命令栏。"
+}
+
// 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 执行长轮询。
diff --git a/internal/service/telegram_commands.go b/internal/service/telegram_commands.go
index d6efaab..21fe1f3 100644
--- a/internal/service/telegram_commands.go
+++ b/internal/service/telegram_commands.go
@@ -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
diff --git a/internal/service/telegram_menu.go b/internal/service/telegram_menu.go
index 192d7fb..894accb 100644
--- a/internal/service/telegram_menu.go
+++ b/internal/service/telegram_menu.go
@@ -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
}
diff --git a/internal/service/thetvdb.go b/internal/service/thetvdb.go
index 0b6d0e8..51453d8 100644
--- a/internal/service/thetvdb.go
+++ b/internal/service/thetvdb.go
@@ -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 {
diff --git a/internal/service/tmdb.go b/internal/service/tmdb.go
index f4606ac..d2e17b4 100644
--- a/internal/service/tmdb.go
+++ b/internal/service/tmdb.go
@@ -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"`
diff --git a/web/src/api/library.ts b/web/src/api/library.ts
index 5254916..a3cb86e 100644
--- a/web/src/api/library.ts
+++ b/web/src/api/library.ts
@@ -15,6 +15,24 @@ export interface MediaSearchPage {
page_size?: number
}
+export interface ManualScrapeCandidate {
+ source: string
+ media_type?: string
+ title: string
+ overview?: string
+ poster_url?: string
+ backdrop_url?: string
+ year?: number
+ rating?: number
+ tmdb_id?: number
+ bangumi_id?: number
+ douban_id?: string
+ thetvdb_id?: string
+ languages?: string[]
+ countries?: string[]
+ genres?: string[]
+}
+
export const libraryAPI = {
list: (options?: { includeHidden?: boolean }) =>
api
@@ -56,4 +74,15 @@ export const mediaAPI = {
.then((r) => r.data),
get: (id: string) => api.get
+ ) : (
+
手动搜索刮削
+
+ ) : (
+
{item.title}
+ {item.source}
+ {item.year ? {item.year} : null}
+