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

手动搜索刮削

+

+ {scopeLabel || media.title} · {targetIds.length > 1 ? `将应用到 ${targetIds.length} 个媒体` : '单个媒体'} +

+
+ +
+ +
+ +
+ + 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" + /> +
+ +
+ +
+ {items.length === 0 ? ( +
+ 搜索后在这里选择正确的元数据 +
+ ) : ( +
+ {items.map((item) => { + const key = candidateKey(item) + const applying = applyingKey === key + return ( +
+
+ {item.poster_url ? ( + {item.title} + ) : ( +
无海报
+ )} +
+
+
+

{item.title}

+ {item.source} + {item.year ? {item.year} : null} +
+

{item.overview || '暂无简介'}

+

{candidateIDText(item)}

+
+ +
+ ) + })} +
+ )} +
+
+
+ ) +} + +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(' · ') +} diff --git a/web/src/components/MediaCard.tsx b/web/src/components/MediaCard.tsx index ba868f7..ad667ac 100644 --- a/web/src/components/MediaCard.tsx +++ b/web/src/components/MediaCard.tsx @@ -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(null) const href = linkTo ?? `/media/${media.id}` @@ -131,15 +132,35 @@ export const MediaCard = ({ if (onClick) { return ( - +
+ + {actions && ( +
+ {actions} +
+ )} +
+ ) + } + + if (actions) { + return ( +
+ + {card} + +
+ {actions} +
+
) } return ( - {card} + {card} ) } diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index aa929cd..14b88a2 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -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(null) + const [movieToolBusy, setMovieToolBusy] = useState('') // 剧集模式:选中某个剧集后展开详情 const [selectedSeries, setSelectedSeries] = useState(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) => { + 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) => { + 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 ( + <> + + + + + + + ) + } + const handleSeriesClick = (card: SeriesCard) => { setSelectedSeries(card) const next = new URLSearchParams(searchParams) @@ -309,7 +428,7 @@ export function LibraryPage() { {!isSeries && items.length > 0 && (
{items.map((m) => ( - + ))}
)} @@ -386,6 +505,34 @@ export function LibraryPage() { ) : null })()} + + {role === 'admin' && selectedSeriesEpisodes.length > 0 && ( +
+

系统后台高级控制面板

+
+ + + + + +
+
+ )} @@ -451,6 +598,26 @@ export function LibraryPage() {

该库尚未发现任何剧集,触发一次扫描后再来看看

)} + + setManualSeriesScrapeOpen(false)} + onApplied={reloadCurrentLibrary} + /> + setManualMovie(null)} + onApplied={reloadCurrentLibrary} + /> ) } diff --git a/web/src/pages/MediaDetailPage.tsx b/web/src/pages/MediaDetailPage.tsx index bfe58d5..c81c058 100644 --- a/web/src/pages/MediaDetailPage.tsx +++ b/web/src/pages/MediaDetailPage.tsx @@ -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(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() { 智能刮削 (TMDB) + +
+ + + +
{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({
+ +
+ + @@ -43,6 +136,11 @@ export function RecycleBinPage() { {items.map((m) => ( +
+ + 标题 路径 操作
+ + {m.title} {m.path} diff --git a/web/src/pages/SearchPage.tsx b/web/src/pages/SearchPage.tsx index 330f828..b88b37b 100644 --- a/web/src/pages/SearchPage.tsx +++ b/web/src/pages/SearchPage.tsx @@ -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([]) 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()) { diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 6b7455f..9df7bee 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -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