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"`
+29
View File
@@ -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<Media>(`/media/${id}`).then((r) => r.data),
manualScrapeSearch: (id: string, params: { query: string; provider?: string; media_type?: string }) =>
api
.get<{ items: ManualScrapeCandidate[] }>(`/media/${id}/scrape/search`, { params })
.then((r) => r.data.items),
applyManualScrape: (id: string, match: ManualScrapeCandidate) =>
api.post<Media>(`/media/${id}/scrape/apply`, match).then((r) => r.data),
applyManualScrapeBatch: (mediaIDs: string[], match: ManualScrapeCandidate) =>
api.post<{ applied: number; errors?: string[] }>('/media/scrape/apply', { media_ids: mediaIDs, match }).then((r) => r.data),
}
+12
View File
@@ -26,4 +26,16 @@ export const notifyChannelsAPI = {
test: (id: string) =>
api.post<{ message: string }>(`/admin/notify/channels/${id}/test`).then((r) => r.data),
startTelegramPolling: () =>
api.post<{
message: string
started: number
already_running: number
skipped: number
errors?: string[]
}>('/admin/telegram/polling/start').then((r) => r.data),
stopTelegramPolling: () =>
api.post<{ message: string; stopped: number }>('/admin/telegram/polling/stop').then((r) => r.data),
}
+6
View File
@@ -8,8 +8,14 @@ export const recycleAPI = {
restore: (id: string) => api.post(`/media/${id}/restore`).then((r) => r.data),
restoreMany: (ids: string[]) =>
api.post<{ applied: number; errors?: string[] }>('/recycle/restore', { media_ids: ids }).then((r) => r.data),
purge: (id: string) => api.delete(`/media/${id}/purge`).then((r) => r.data),
purgeMany: (ids: string[]) =>
api.post<{ applied: number; errors?: string[] }>('/recycle/purge', { media_ids: ids }).then((r) => r.data),
exportNFO: (id: string) =>
api.post<{ path: string }>(`/media/${id}/nfo`).then((r) => r.data),
+130 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import toast from 'react-hot-toast'
@@ -14,9 +14,12 @@ import { AppFooter } from './AppFooter'
import { useAuthStore } from '../stores/auth'
import { usePermissionStore } from '../stores/permissions'
import { usePlayProfileStore } from '../stores/playProfile'
import { imageURL } from '../api/client'
import { mediaAPI } from '../api/library'
import { playProfilesAPI } from '../api/play_profiles'
import { requestPIN } from './PinDialog'
import type { PlayProfile } from '../types'
import type { Media, PlayProfile } from '../types'
import { groupSeries, seriesCardLink } from '../utils/groupSeries'
export function Layout() {
const navigate = useNavigate()
@@ -36,6 +39,12 @@ export function Layout() {
const [profiles, setProfiles] = useState<PlayProfile[]>([])
const [searchFocused, setSearchFocused] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [searchItems, setSearchItems] = useState<Media[]>([])
const [searchLoading, setSearchLoading] = useState(false)
const [searchTotal, setSearchTotal] = useState(0)
const [searchError, setSearchError] = useState('')
const searchSeq = useRef(0)
const searchCards = useMemo(() => groupSeries(searchItems).slice(0, 8), [searchItems])
// Auto-collapse sidebar on smaller tablet screens, and auto-hide drawer on path change
useEffect(() => {
@@ -61,6 +70,48 @@ export function Layout() {
}
}, [fetchPermissions, isPermissionLoading, permissions, user])
useEffect(() => {
if (location.pathname === '/search') {
const query = new URLSearchParams(location.search).get('q') ?? ''
setSearchQuery(query)
}
}, [location.pathname, location.search])
useEffect(() => {
const query = searchQuery.trim()
const seq = ++searchSeq.current
if (!searchFocused || !query) {
setSearchItems([])
setSearchTotal(0)
setSearchError('')
setSearchLoading(false)
return
}
setSearchLoading(true)
setSearchError('')
const timer = window.setTimeout(() => {
mediaAPI
.search(query, 24)
.then((data) => {
if (seq !== searchSeq.current) return
setSearchItems(data.items ?? [])
setSearchTotal(data.total ?? (data.items ?? []).length)
})
.catch(() => {
if (seq !== searchSeq.current) return
setSearchItems([])
setSearchTotal(0)
setSearchError('搜索失败,请稍后再试')
})
.finally(() => {
if (seq === searchSeq.current) setSearchLoading(false)
})
}, 220)
return () => window.clearTimeout(timer)
}, [searchFocused, searchQuery])
useEffect(() => {
if (!user) {
setProfiles([])
@@ -108,6 +159,7 @@ export function Layout() {
e.preventDefault()
if (searchQuery.trim()) {
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
setSearchFocused(false)
}
}
@@ -349,8 +401,10 @@ export function Layout() {
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onMouseDown={() => setSearchFocused(true)}
onClick={() => setSearchFocused(true)}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
onBlur={() => window.setTimeout(() => setSearchFocused(false), 120)}
placeholder="搜索电影、电视剧、演员、种子站点..."
className="w-full rounded-full border border-gray-200 bg-gray-50/50 py-2.5 pl-11 pr-12 text-sm text-gray-900 placeholder-gray-500 outline-none transition-all duration-300 focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/40"
/>
@@ -359,6 +413,79 @@ export function Layout() {
Enter
</span>
</div>
<AnimatePresence>
{searchFocused && searchQuery.trim() && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 6, scale: 0.98 }}
transition={{ duration: 0.14 }}
onMouseDown={(event) => event.preventDefault()}
className="absolute left-0 right-0 top-full z-50 mt-3 overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl"
>
<div className="max-h-[420px] overflow-y-auto p-2">
{searchLoading && (
<div className="flex items-center gap-2 px-3 py-4 text-sm text-gray-500">
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
搜索中...
</div>
)}
{!searchLoading && searchError && (
<div className="px-3 py-4 text-sm text-red-500">{searchError}</div>
)}
{!searchLoading && !searchError && searchCards.length === 0 && (
<div className="px-3 py-4 text-sm text-gray-500">没有找到匹配的本地媒体</div>
)}
{!searchLoading && !searchError && searchCards.length > 0 && (
<div className="space-y-1">
{searchCards.map((card) => (
<Link
key={card.key}
to={seriesCardLink(card)}
onClick={() => setSearchFocused(false)}
className="flex items-center gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-gray-50"
>
<div className="h-14 w-10 shrink-0 overflow-hidden rounded-lg bg-gray-100">
{card.rep.poster_url ? (
<img
src={imageURL(card.rep.poster_url)}
alt={card.rep.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-gray-400">
<LibraryIcon size={16} />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-gray-900">
{card.rep.title || card.rep.original_name || '未命名媒体'}
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-gray-500">
{card.rep.year ? <span>{card.rep.year}</span> : null}
<span>{card.count > 1 ? `${card.count} 集/条目` : '单条媒体'}</span>
{card.rep.width ? <span>{card.rep.width}x{card.rep.height}</span> : null}
</div>
</div>
</Link>
))}
</div>
)}
</div>
<Link
to={`/search?q=${encodeURIComponent(searchQuery.trim())}`}
onClick={() => setSearchFocused(false)}
className="flex items-center justify-between border-t border-gray-100 px-4 py-3 text-sm font-semibold text-brand-600 hover:bg-brand-50/60"
>
<span>查看全部搜索结果</span>
<span className="text-xs text-gray-500">
{searchTotal > 0 ? `${searchTotal} 个条目` : 'Enter'}
</span>
</Link>
</motion.div>
)}
</AnimatePresence>
</form>
</div>
+200
View File
@@ -0,0 +1,200 @@
import { useEffect, useMemo, useState } from 'react'
import { Check, LoaderCircle, Search, Sparkles, X } from 'lucide-react'
import toast from 'react-hot-toast'
import { imageURL } from '../api/client'
import { mediaAPI, type ManualScrapeCandidate } from '../api/library'
import type { Media } from '../types'
interface ManualScrapeDialogProps {
open: boolean
media: Media | null
mediaIds?: string[]
defaultQuery?: string
mediaType?: string
scopeLabel?: string
onClose: () => void
onApplied?: () => void
}
const providers = [
{ value: 'all', label: '全部源' },
{ value: 'tmdb', label: 'TMDb' },
{ value: 'douban', label: '豆瓣' },
{ value: 'bangumi', label: 'Bangumi' },
{ value: 'thetvdb', label: 'TheTVDB' },
]
export function ManualScrapeDialog({
open,
media,
mediaIds,
defaultQuery,
mediaType,
scopeLabel,
onClose,
onApplied,
}: ManualScrapeDialogProps) {
const [query, setQuery] = useState('')
const [provider, setProvider] = useState('all')
const [searching, setSearching] = useState(false)
const [applyingKey, setApplyingKey] = useState('')
const [items, setItems] = useState<ManualScrapeCandidate[]>([])
const targetIds = useMemo(() => {
const ids = (mediaIds && mediaIds.length > 0 ? mediaIds : media ? [media.id] : []).filter(Boolean)
return Array.from(new Set(ids))
}, [media, mediaIds])
useEffect(() => {
if (!open) return
setQuery(defaultQuery || media?.title || '')
setProvider('all')
setItems([])
setApplyingKey('')
}, [defaultQuery, media?.title, open])
if (!open || !media) return null
const runSearch = async () => {
const text = query.trim()
if (!text) {
toast.error('请输入标题或 TMDb/豆瓣/Bangumi/TheTVDB ID')
return
}
setSearching(true)
try {
const results = await mediaAPI.manualScrapeSearch(media.id, {
query: text,
provider,
media_type: mediaType,
})
setItems(results)
if (results.length === 0) toast.error('没有找到可用候选')
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '搜索失败'
toast.error(msg)
} finally {
setSearching(false)
}
}
const apply = async (item: ManualScrapeCandidate) => {
const key = candidateKey(item)
setApplyingKey(key)
try {
if (targetIds.length > 1) {
const result = await mediaAPI.applyManualScrapeBatch(targetIds, item)
toast.success(`已应用到 ${result.applied} 个媒体`)
} else {
await mediaAPI.applyManualScrape(media.id, item)
toast.success('已应用手动匹配')
}
onApplied?.()
onClose()
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '应用失败'
toast.error(msg)
} finally {
setApplyingKey('')
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-900/40 px-4 py-8 backdrop-blur-sm">
<div className="flex max-h-[86vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-sand-200 bg-white shadow-2xl">
<div className="flex items-start justify-between gap-4 border-b border-sand-200 px-5 py-4">
<div>
<h2 className="font-display text-xl font-bold text-ink-600">手动搜索刮削</h2>
<p className="mt-1 text-xs text-sand-500">
{scopeLabel || media.title} · {targetIds.length > 1 ? `将应用到 ${targetIds.length} 个媒体` : '单个媒体'}
</p>
</div>
<button onClick={onClose} className="btn-ghost h-9 w-9 p-0" aria-label="关闭">
<X size={16} />
</button>
</div>
<div className="flex flex-col gap-3 border-b border-sand-200 p-5 sm:flex-row">
<select
value={provider}
onChange={(event) => setProvider(event.target.value)}
className="h-11 rounded-xl border border-sand-200 bg-white px-3 text-sm font-semibold text-ink-600 outline-none focus:border-brand-300"
>
{providers.map((item) => (
<option key={item.value} value={item.value}>{item.label}</option>
))}
</select>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-sand-500" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter') runSearch() }}
className="h-11 w-full rounded-xl border border-sand-200 bg-white pl-9 pr-3 text-sm font-semibold text-ink-600 outline-none focus:border-brand-300"
placeholder="输入标题或 TMDb / 豆瓣 / Bangumi / TheTVDB ID"
/>
</div>
<button onClick={runSearch} disabled={searching} className="btn-primary h-11 px-5">
{searching ? <LoaderCircle size={16} className="animate-spin" /> : <Sparkles size={16} />}
搜索
</button>
</div>
<div className="flex-1 overflow-y-auto p-5">
{items.length === 0 ? (
<div className="flex min-h-56 items-center justify-center rounded-xl border border-dashed border-sand-200 text-sm text-sand-500">
搜索后在这里选择正确的元数据
</div>
) : (
<div className="grid gap-3">
{items.map((item) => {
const key = candidateKey(item)
const applying = applyingKey === key
return (
<div key={key} className="flex gap-4 rounded-xl border border-sand-200 bg-white p-3 shadow-sm">
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
{item.poster_url ? (
<img src={imageURL(item.poster_url)} alt={item.title} className="h-full w-full object-cover" referrerPolicy="no-referrer" />
) : (
<div className="flex h-full items-center justify-center text-xs text-sand-500">无海报</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold uppercase text-brand-700">{item.source}</span>
{item.year ? <span className="text-xs text-sand-500">{item.year}</span> : null}
</div>
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-ink-50">{item.overview || '暂无简介'}</p>
<p className="mt-2 text-[11px] font-semibold text-sand-500">{candidateIDText(item)}</p>
</div>
<button onClick={() => apply(item)} disabled={!!applyingKey} className="btn-outline h-10 shrink-0 self-center px-3 text-xs">
{applying ? <LoaderCircle size={14} className="animate-spin" /> : <Check size={14} />}
应用匹配
</button>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
)
}
function candidateKey(item: ManualScrapeCandidate): string {
return `${item.source}:${item.tmdb_id || item.bangumi_id || item.douban_id || item.thetvdb_id || item.title}:${item.media_type || ''}`
}
function candidateIDText(item: ManualScrapeCandidate): string {
const parts = [
item.tmdb_id ? `TMDb ${item.tmdb_id}` : '',
item.douban_id ? `豆瓣 ${item.douban_id}` : '',
item.bangumi_id ? `Bangumi ${item.bangumi_id}` : '',
item.thetvdb_id ? `TheTVDB ${item.thetvdb_id}` : '',
item.media_type ? item.media_type : '',
].filter(Boolean)
return parts.join(' · ')
}
+27 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Film, Play, Layers, Star } from 'lucide-react'
@@ -6,7 +6,7 @@ import { imageURL } from '../api/client'
import type { Media } from '../types'
export const MediaCard = ({
media, progress, count, rating, linkTo, onClick,
media, progress, count, rating, linkTo, onClick, actions,
}: {
media: Media
progress?: number
@@ -14,6 +14,7 @@ export const MediaCard = ({
rating?: number
linkTo?: string
onClick?: () => void
actions?: ReactNode
}) => {
const ref = useRef<HTMLDivElement>(null)
const href = linkTo ?? `/media/${media.id}`
@@ -131,15 +132,35 @@ export const MediaCard = ({
if (onClick) {
return (
<button type="button" onClick={onClick} className="group block w-full text-left">
{card}
</button>
<div className="group relative block w-full">
<button type="button" onClick={onClick} className="block w-full text-left">
{card}
</button>
{actions && (
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{actions}
</div>
)}
</div>
)
}
if (actions) {
return (
<div className="group relative block">
<Link to={href} className="block">
{card}
</Link>
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{actions}
</div>
</div>
)
}
return (
<Link to={href} className="group block">
{card}
{card}
</Link>
)
}
+169 -2
View File
@@ -2,10 +2,12 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import toast from 'react-hot-toast'
import { ArrowLeft, Play, Film } from 'lucide-react'
import { ArrowLeft, Play, Film, Database, FileText, Search, Sparkles, Trash2 } from 'lucide-react'
import { libraryAPI } from '../api/library'
import { storageAPI, type CloudScanStatus } from '../api/storage_config'
import { api } from '../api/client'
import { recycleAPI } from '../api/recycle'
import type { Library, Media } from '../types'
import { MediaCard } from '../components/MediaCard'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
@@ -13,6 +15,8 @@ import { imageURL } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { getSeriesKey, groupSeries, isEpisodeLike, seriesTitle, type SeriesCard } from '../utils/groupSeries'
import { useWebSocket } from '../hooks/useWebSocket'
import { confirmAction } from '../components/ConfirmDialog'
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
export function LibraryPage() {
const { id = '' } = useParams()
@@ -27,6 +31,10 @@ export function LibraryPage() {
const [scanning, setScanning] = useState(false)
const [scanProgress, setScanProgress] = useState('')
const [scraping, setScraping] = useState(false)
const [seriesToolBusy, setSeriesToolBusy] = useState('')
const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false)
const [manualMovie, setManualMovie] = useState<Media | null>(null)
const [movieToolBusy, setMovieToolBusy] = useState('')
// 剧集模式:选中某个剧集后展开详情
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
@@ -65,6 +73,16 @@ export function LibraryPage() {
return selectedEpisodes.find((s) => s.season === selectedSeason)?.episodes ?? []
}, [selectedEpisodes, selectedSeason])
const selectedSeriesEpisodes = useMemo(
() => selectedEpisodes.flatMap((season) => season.episodes),
[selectedEpisodes],
)
const selectedSeriesMediaIDs = useMemo(
() => selectedSeriesEpisodes.map((ep) => ep.id),
[selectedSeriesEpisodes],
)
useEffect(() => {
if (!id) return
libraryAPI.list().then((all) => {
@@ -255,6 +273,107 @@ export function LibraryPage() {
finally { setScraping(false) }
}
const runSeriesTool = async (key: string, label: string, action: (media: Media) => Promise<unknown>) => {
if (selectedSeriesEpisodes.length === 0) return
setSeriesToolBusy(key)
try {
for (const ep of selectedSeriesEpisodes) {
await action(ep)
}
toast.success(`${label}完成:${selectedSeriesEpisodes.length} 个媒体`)
reloadCurrentLibrary()
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || `${label}失败`
toast.error(msg)
} finally {
setSeriesToolBusy('')
}
}
const handleSeriesSmartScrape = () => {
runSeriesTool('scrape', '整剧智能刮削', (media) => api.post(`/media/${media.id}/scrape`))
}
const handleSeriesProbe = () => {
runSeriesTool('probe', '整剧媒体轨探测', (media) => api.post(`/media/${media.id}/probe`))
}
const handleSeriesNFO = () => {
runSeriesTool('nfo', '整剧 NFO 写出', (media) => recycleAPI.exportNFO(media.id))
}
const handleSeriesSoftDelete = async () => {
if (!selectedSeries || selectedSeriesEpisodes.length === 0) return
if (!(await confirmAction({
title: '移入回收站',
message: `将「${seriesTitle(selectedSeries.rep)}」的 ${selectedSeriesEpisodes.length} 个媒体移至回收站? (磁盘文件保留)`,
confirmText: '移入回收站',
}))) return
await runSeriesTool('delete', '整剧移入回收站', (media) => recycleAPI.softDelete(media.id))
clearSelectedSeries()
}
const runMovieTool = async (media: Media, key: string, label: string, action: (media: Media) => Promise<unknown>) => {
const busyKey = `${key}:${media.id}`
setMovieToolBusy(busyKey)
try {
await action(media)
toast.success(`${label}完成:${media.title}`)
reloadCurrentLibrary()
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || `${label}失败`
toast.error(msg)
} finally {
setMovieToolBusy('')
}
}
const handleMovieSmartScrape = (media: Media) => {
runMovieTool(media, 'scrape', '智能刮削', (item) => api.post(`/media/${item.id}/scrape`))
}
const handleMovieProbe = (media: Media) => {
runMovieTool(media, 'probe', '媒体轨探测', (item) => api.post(`/media/${item.id}/probe`))
}
const handleMovieNFO = (media: Media) => {
runMovieTool(media, 'nfo', 'NFO 写出', (item) => recycleAPI.exportNFO(item.id))
}
const handleMovieSoftDelete = async (media: Media) => {
if (!(await confirmAction({
title: '移入回收站',
message: `将「${media.title}」移至回收站? (磁盘文件保留)`,
confirmText: '移入回收站',
}))) return
await runMovieTool(media, 'delete', '移入回收站', (item) => recycleAPI.softDelete(item.id))
}
const movieActions = (media: Media) => {
if (role !== 'admin') return undefined
const busy = movieToolBusy.endsWith(`:${media.id}`)
const buttonClass = 'flex h-8 w-8 items-center justify-center rounded-lg border border-white/70 bg-white/90 text-gray-700 shadow-sm backdrop-blur transition hover:bg-brand-50 hover:text-brand-600 disabled:opacity-50'
return (
<>
<button title="智能刮削" disabled={busy} onClick={() => handleMovieSmartScrape(media)} className={buttonClass}>
<Sparkles size={13} />
</button>
<button title="手动匹配刮削" disabled={busy} onClick={() => setManualMovie(media)} className={buttonClass}>
<Search size={13} />
</button>
<button title="探测媒体轨" disabled={busy} onClick={() => handleMovieProbe(media)} className={buttonClass}>
<Database size={13} />
</button>
<button title="写出本地 NFO" disabled={busy} onClick={() => handleMovieNFO(media)} className={buttonClass}>
<FileText size={13} />
</button>
<button title="移入回收站" disabled={busy} onClick={() => handleMovieSoftDelete(media)} className={`${buttonClass} hover:!bg-red-50 hover:!text-red-500`}>
<Trash2 size={13} />
</button>
</>
)
}
const handleSeriesClick = (card: SeriesCard) => {
setSelectedSeries(card)
const next = new URLSearchParams(searchParams)
@@ -309,7 +428,7 @@ export function LibraryPage() {
{!isSeries && items.length > 0 && (
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
{items.map((m) => (
<MediaCard key={m.id} media={m} />
<MediaCard key={m.id} media={m} actions={movieActions(m)} />
))}
</div>
)}
@@ -386,6 +505,34 @@ export function LibraryPage() {
</div>
) : null
})()}
{role === 'admin' && selectedSeriesEpisodes.length > 0 && (
<div className="rounded-2xl border border-sand-200 bg-white/80 p-4 shadow-sm">
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
<div className="flex flex-wrap gap-2">
<button onClick={handleSeriesSmartScrape} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
<Sparkles size={13} className="text-[#c9954a]" />
<span>{seriesToolBusy === 'scrape' ? '刮削中…' : '整剧智能刮削'}</span>
</button>
<button onClick={() => setManualSeriesScrapeOpen(true)} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
<Search size={13} className="text-[#c9954a]" />
<span>手动匹配整剧</span>
</button>
<button onClick={handleSeriesProbe} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
<Database size={13} />
<span>{seriesToolBusy === 'probe' ? '探测中…' : '探测媒体轨'}</span>
</button>
<button onClick={handleSeriesNFO} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
<FileText size={13} />
<span>{seriesToolBusy === 'nfo' ? '写出中…' : '写出本地 NFO'}</span>
</button>
<button onClick={handleSeriesSoftDelete} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200">
<Trash2 size={13} />
<span>{seriesToolBusy === 'delete' ? '处理中…' : '移入回收站'}</span>
</button>
</div>
</div>
)}
</div>
</div>
@@ -451,6 +598,26 @@ export function LibraryPage() {
<p className="text-ink-50">该库尚未发现任何剧集,触发一次扫描后再来看看</p>
</div>
)}
<ManualScrapeDialog
open={manualSeriesScrapeOpen}
media={selectedSeries?.rep ?? null}
mediaIds={selectedSeriesMediaIDs}
defaultQuery={selectedSeries ? seriesTitle(selectedSeries.rep) : ''}
mediaType={library?.type || 'tv'}
scopeLabel={selectedSeries ? seriesTitle(selectedSeries.rep) : '当前剧集'}
onClose={() => setManualSeriesScrapeOpen(false)}
onApplied={reloadCurrentLibrary}
/>
<ManualScrapeDialog
open={!!manualMovie}
media={manualMovie}
defaultQuery={manualMovie?.title ?? ''}
mediaType={library?.type || 'movie'}
scopeLabel={manualMovie?.title ?? '当前电影'}
onClose={() => setManualMovie(null)}
onApplied={reloadCurrentLibrary}
/>
</div>
)
}
+16 -1
View File
@@ -1,7 +1,7 @@
import { motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2, Calendar, Database } from 'lucide-react'
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2, Calendar, Database, Search } from 'lucide-react'
import toast from 'react-hot-toast'
import { mediaAPI } from '../api/library'
@@ -13,6 +13,7 @@ import { api } from '../api/client'
import type { Media } from '../types'
import { confirmAction } from '../components/ConfirmDialog'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
function fmtDuration(sec: number): string {
if (!sec || sec <= 0) return '—'
@@ -45,6 +46,7 @@ export function MediaDetailPage() {
const [media, setMedia] = useState<Media | null>(null)
const [favourite, setFavourite] = useState(false)
const [loading, setLoading] = useState(true)
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
const refresh = async () => {
if (!id) return
@@ -315,6 +317,10 @@ export function MediaDetailPage() {
<Sparkles size={13} className="text-[#c9954a]" />
<span>智能刮削 (TMDB)</span>
</button>
<button onClick={() => setManualScrapeOpen(true)} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Search size={13} className="text-[#c9954a]" />
<span>手动匹配刮削</span>
</button>
<button onClick={reprobe} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Database size={13} className="text-gray-600" />
<span>探测媒体轨 (ffprobe)</span>
@@ -336,6 +342,15 @@ export function MediaDetailPage() {
</div>
</div>
</div>
<ManualScrapeDialog
open={manualScrapeOpen}
media={media}
defaultQuery={media.title}
mediaType={media.season_num > 0 || media.episode_num > 0 ? 'tv' : undefined}
scopeLabel={media.title}
onClose={() => setManualScrapeOpen(false)}
onApplied={refresh}
/>
</div>
)
}
+74 -11
View File
@@ -1,5 +1,5 @@
import { FormEvent, useEffect, useState } from 'react'
import { Bell, Loader2, Pencil, Plus, Send, Trash2 } from 'lucide-react'
import { Bell, Loader2, Pencil, Play, Plus, Send, Square, Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import {
@@ -17,6 +17,8 @@ export function NotifyChannelsPage() {
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState<NotifyChannel | null>(null)
const [showForm, setShowForm] = useState(false)
const [testingID, setTestingID] = useState<string | null>(null)
const [pollingBusy, setPollingBusy] = useState<'start' | 'stop' | null>(null)
const refresh = async () => {
setLoading(true)
@@ -32,6 +34,7 @@ export function NotifyChannelsPage() {
}, [])
const onTest = async (id: string) => {
setTestingID(id)
try {
await notifyChannelsAPI.test(id)
toast.success('测试消息已发送')
@@ -39,6 +42,42 @@ export function NotifyChannelsPage() {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '发送失败'
toast.error(msg)
} finally {
setTestingID(null)
}
}
const onStartPolling = async () => {
setPollingBusy('start')
try {
const res = await notifyChannelsAPI.startTelegramPolling()
if ((res.started ?? 0) > 0) {
toast.success(`已启动 ${res.started} 个 Telegram Bot 轮询`)
} else if ((res.already_running ?? 0) > 0) {
toast.success('Telegram Bot 轮询已在运行')
} else {
toast.error(res.errors?.[0] ?? '没有可启动的 Telegram Bot 渠道')
}
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '启动轮询失败'
toast.error(msg)
} finally {
setPollingBusy(null)
}
}
const onStopPolling = async () => {
setPollingBusy('stop')
try {
const res = await notifyChannelsAPI.stopTelegramPolling()
toast.success(res.stopped > 0 ? `已停止 ${res.stopped} 个 Telegram Bot 轮询` : '当前没有运行中的轮询')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '停止轮询失败'
toast.error(msg)
} finally {
setPollingBusy(null)
}
}
@@ -69,15 +108,35 @@ export function NotifyChannelsPage() {
</p>
</div>
</div>
<button
onClick={() => {
setEditing(null)
setShowForm(true)
}}
className="neon-button"
>
<Plus size={16} /> 添加渠道
</button>
<div className="flex flex-wrap justify-end gap-2">
<button
onClick={onStartPolling}
disabled={pollingBusy !== null}
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:border-primary-400/40 hover:text-brand-500 disabled:opacity-50"
title="启动 Telegram Bot 长轮询"
>
{pollingBusy === 'start' ? <Loader2 size={16} className="inline animate-spin" /> : <Play size={16} className="inline" />}
{' '}启动 Bot 轮询
</button>
<button
onClick={onStopPolling}
disabled={pollingBusy !== null}
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:border-primary-400/40 hover:text-brand-500 disabled:opacity-50"
title="停止本地长轮询。"
>
{pollingBusy === 'stop' ? <Loader2 size={16} className="inline animate-spin" /> : <Square size={16} className="inline" />}
{' '}停止轮询
</button>
<button
onClick={() => {
setEditing(null)
setShowForm(true)
}}
className="neon-button"
>
<Plus size={16} /> 添加渠道
</button>
</div>
</div>
{loading && (
@@ -97,6 +156,7 @@ export function NotifyChannelsPage() {
key={ch.id}
channel={ch}
onTest={() => onTest(ch.id)}
testing={testingID === ch.id}
onEdit={() => {
setEditing(ch)
setShowForm(true)
@@ -140,11 +200,13 @@ const EVENT_OPTIONS = [
function ChannelCard({
channel,
onTest,
testing,
onEdit,
onDelete,
}: {
channel: NotifyChannel
onTest: () => void
testing?: boolean
onEdit: () => void
onDelete: () => void
}) {
@@ -167,9 +229,10 @@ function ChannelCard({
<div className="flex shrink-0 gap-2">
<button
onClick={onTest}
disabled={testing}
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
<Send size={12} className="inline" /> 测试
{testing ? <Loader2 size={12} className="inline animate-spin" /> : <Send size={12} className="inline" />} 测试
</button>
<button
onClick={onEdit}
+104 -6
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import toast from 'react-hot-toast'
import { RotateCcw, Trash2 } from 'lucide-react'
import { Home, RotateCcw, Square, CheckSquare, Trash2 } from 'lucide-react'
import { recycleAPI } from '../api/recycle'
import { confirmAction } from '../components/ConfirmDialog'
@@ -9,32 +10,124 @@ import type { Media } from '../types'
export function RecycleBinPage() {
const [items, setItems] = useState<Media[]>([])
const [loading, setLoading] = useState(true)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [batchBusy, setBatchBusy] = useState('')
const refresh = () =>
recycleAPI
.list()
.then(setItems)
.then((rows) => {
setItems(rows)
setSelectedIds((current) => current.filter((id) => rows.some((item) => item.id === id)))
})
.finally(() => setLoading(false))
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const allSelected = items.length > 0 && selectedIds.length === items.length
const toggleAll = () => {
setSelectedIds(allSelected ? [] : items.map((item) => item.id))
}
const toggleOne = (id: string) => {
setSelectedIds((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id])
}
const runBatch = async (mode: 'restore' | 'purge') => {
if (selectedIds.length === 0) return
if (mode === 'purge' && !(await confirmAction({
title: '批量彻底删除记录',
message: `彻底删除选中的 ${selectedIds.length} 条记录? (磁盘文件保留)`,
confirmText: '彻底删除',
}))) return
setBatchBusy(mode)
try {
const result = await runRecycleBatchRequest(mode, selectedIds)
toast.success(`${mode === 'restore' ? '恢复' : '彻底删除'}完成:${result.applied} 条`)
setSelectedIds([])
await refresh()
} catch (err: unknown) {
const response = (err as { response?: { status?: number; data?: { error?: string } }; message?: string })?.response
const msg = response?.data?.error || (response?.status ? `批量操作失败 (${response.status})` : (err as { message?: string })?.message || '批量操作失败')
toast.error(msg)
} finally {
setBatchBusy('')
}
}
const runRecycleBatchRequest = async (mode: 'restore' | 'purge', ids: string[]) => {
try {
return mode === 'restore'
? await recycleAPI.restoreMany(ids)
: await recycleAPI.purgeMany(ids)
} catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status
if (status !== 404 && status !== 405) throw err
let applied = 0
for (const id of ids) {
if (mode === 'restore') {
await recycleAPI.restore(id)
} else {
await recycleAPI.purge(id)
}
applied += 1
}
return { applied, errors: [] as string[] }
}
}
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-ink-600">回收站</h1>
<p className="text-sm text-ink-50">
软删除的媒体保留在数据库中,可以恢复。彻底删除不会移除磁盘上的文件,只会从数据库清除条目。
</p>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">回收站</h1>
<p className="mt-2 text-sm text-ink-50">
软删除的媒体保留在数据库中,可以恢复。彻底删除不会移除磁盘上的文件,只会从数据库清除条目。
</p>
<p className="mt-1 text-xs text-sand-500">系统最多保留最新 200 条回收站记录,超过后会自动清理旧记录。</p>
</div>
<Link to="/" className="btn-outline shrink-0 py-2.5 px-4 text-sm">
<Home size={15} />
返回系统首页
</Link>
</div>
{loading && <p className="text-sand-500">加载中…</p>}
{!loading && items.length === 0 && <p className="text-ink-50">回收站为空。</p>}
{items.length > 0 && (
<div className="glass-panel">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-sand-500">
已选择 {selectedIds.length} / {items.length} 条
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => runBatch('restore')}
disabled={selectedIds.length === 0 || !!batchBusy}
className="btn-outline py-2 px-3 text-xs"
>
<RotateCcw size={13} />
{batchBusy === 'restore' ? '恢复中…' : '批量恢复'}
</button>
<button
onClick={() => runBatch('purge')}
disabled={selectedIds.length === 0 || !!batchBusy}
className="btn-outline py-2 px-3 text-xs !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
>
<Trash2 size={13} />
{batchBusy === 'purge' ? '删除中…' : '批量彻底删除'}
</button>
</div>
</div>
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="w-10 py-2">
<button onClick={toggleAll} className="text-sand-500 hover:text-brand-500" aria-label="全选">
{allSelected ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
</th>
<th className="py-2">标题</th>
<th>路径</th>
<th className="text-right">操作</th>
@@ -43,6 +136,11 @@ export function RecycleBinPage() {
<tbody>
{items.map((m) => (
<tr key={m.id} className="border-t border-gray-200">
<td className="py-2">
<button onClick={() => toggleOne(m.id)} className="text-sand-500 hover:text-brand-500" aria-label={`选择 ${m.title}`}>
{selectedIds.includes(m.id) ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
</td>
<td className="py-2 text-ink-600">{m.title}</td>
<td className="max-w-md truncate text-ink-50" title={m.path}>
{m.path}
+7
View File
@@ -1,4 +1,5 @@
import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import { CheckCircle2, Info, Rss, Sparkles } from 'lucide-react'
@@ -13,6 +14,8 @@ import { groupSeries, seriesCardLink } from '../utils/groupSeries'
const LOCAL_SEARCH_PAGE_SIZE = 2000
export function SearchPage() {
const [searchParams] = useSearchParams()
const urlQuery = searchParams.get('q') ?? ''
const [q, setQ] = useState('')
const [items, setItems] = useState<Media[]>([])
const [loading, setLoading] = useState(false)
@@ -34,6 +37,10 @@ export function SearchPage() {
.catch(() => setAiAvailable(false))
}, [])
useEffect(() => {
setQ(urlQuery)
}, [urlQuery])
const doQuickSearch = useCallback((query: string) => {
const seq = ++searchSeq.current
if (!query.trim()) {
+2
View File
@@ -127,6 +127,8 @@ export interface Media {
scrape_status: string
tmdb_id: number
bangumi_id: number
douban_id?: string
thetvdb_id?: string
languages?: string
countries?: string
genres?: string