mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
Fix scraping workflows subscriptions bot and recycle bin
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -83,8 +83,8 @@ func telegramGetWebhookHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// 路由:POST /api/admin/telegram/polling/start (需 admin 认证)
|
||||
func telegramStartPollingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
svc.TelegramBot.StartPolling(c.Request.Context())
|
||||
c.JSON(http.StatusOK, gin.H{"message": "polling started"})
|
||||
result := svc.TelegramBot.StartPolling(c.Request.Context())
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ func telegramStartPollingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// 路由:POST /api/admin/telegram/polling/stop (需 admin 认证)
|
||||
func telegramStopPollingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
svc.TelegramBot.StopPolling()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "polling stopped"})
|
||||
stopped := svc.TelegramBot.StopPolling()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "polling stopped", "stopped": stopped})
|
||||
}
|
||||
}
|
||||
|
||||
+13
-12
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\国产剧`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -86,6 +86,17 @@ type TelegramBotService struct {
|
||||
pending map[int64]pendingInput // telegram_user_id -> awaited text input
|
||||
}
|
||||
|
||||
// TelegramPollingStartResult describes what happened when local long polling
|
||||
// was requested. The admin UI uses it to avoid a silent "started" toast when
|
||||
// no Telegram channel can actually poll.
|
||||
type TelegramPollingStartResult struct {
|
||||
Message string `json:"message"`
|
||||
Started int `json:"started"`
|
||||
AlreadyRunning int `json:"already_running"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// pendingInput tracks a button-initiated action that awaits the user's next
|
||||
// text message (e.g. tapping「注册」then sending "用户名 密码").
|
||||
type pendingInput struct {
|
||||
@@ -503,8 +514,8 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage)
|
||||
"<b>/signin</b> — 签到\n" +
|
||||
"<b>/devices</b> — 查看登录设备\n" +
|
||||
"<b>/kick all|编号</b> — 踢下线设备\n" +
|
||||
"<b>/setname 新用户名</b> — 修改用户名\n" +
|
||||
"<b>/setpass 新密码</b> — 修改密码\n" +
|
||||
"<b>/setname 当前密码 新用户名</b> — 修改用户名\n" +
|
||||
"<b>/setpass 当前密码 新密码</b> — 修改密码\n" +
|
||||
"<b>/redeem 兑换码</b> — 注册或续期兑换\n" +
|
||||
"<b>/hideadult on|off</b> — 隐藏或显示成人目录\n\n" +
|
||||
"系统状态、搜索、下载列表与统计命令仅管理员可用。"
|
||||
@@ -514,7 +525,7 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage)
|
||||
"<b>/help</b> — 帮助信息\n" +
|
||||
"<b>/account</b> / <b>/devices</b> / <b>/kick all|编号</b> — 用户自助设备管理\n" +
|
||||
"<b>/signin</b> / <b>/redeem 兑换码</b> — 签到与兑换\n" +
|
||||
"<b>/setname 新用户名</b> / <b>/setpass 新密码</b> — 用户自助改名改密\n" +
|
||||
"<b>/setname 当前密码 新用户名</b> / <b>/setpass 当前密码 新密码</b> — 用户自助改名改密\n" +
|
||||
"<b>/register 用户名 密码</b> — 注册新账号(需管理员开启)\n" +
|
||||
"<b>/registration on [名额]|off</b> — 开启/关闭普通用户注册(管理员)\n" +
|
||||
"<b>/capacity</b> / <b>/users</b> — 容量与用户管理(管理员)\n" +
|
||||
@@ -533,12 +544,7 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage)
|
||||
"<b>/search 关键词</b> — 搜索媒体库\n" +
|
||||
"<b>/downloads</b> — 下载列表\n" +
|
||||
"<b>/stats</b> — 媒体库统计\n\n" +
|
||||
"<b>Mgo 管理命令:</b>\n" +
|
||||
"用户:<code>/ucr</code> <code>/uinfo</code> <code>/rmemby</code> <code>/only_rm_record</code> <code>/renewall</code>\n" +
|
||||
"审计:<code>/userip</code> <code>/auditip</code> <code>/auditdevice</code> <code>/auditclient</code> <code>/udeviceid</code>\n" +
|
||||
"清理:<code>/syncunbound</code> <code>/syncgroupm</code> <code>/check_ex</code> <code>/deleted</code> <code>/low_activity</code>\n" +
|
||||
"权限:<code>/embyadmin</code> <code>/banall</code> <code>/unbanall</code> <code>/prouser</code> <code>/revuser</code> <code>/embylibs_blockall</code> <code>/embylibs_unblockall</code>\n" +
|
||||
"运维:<code>/proadmin</code> <code>/revadmin</code> <code>/backup_db</code> <code>/restore_from_db</code>\n\n" +
|
||||
telegramMgoAdminCommandHelp() + "\n\n" +
|
||||
"<b>自动推送事件:</b>\n" +
|
||||
"• 订阅命中新资源\n" +
|
||||
"• 下载任务完成\n" +
|
||||
@@ -546,6 +552,16 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage)
|
||||
"• 系统异常通知"
|
||||
}
|
||||
|
||||
func telegramMgoAdminCommandHelp() string {
|
||||
return "<b>Mgo 管理命令(管理员可用,已注册到命令栏):</b>\n" +
|
||||
"用户:<code>/ucr 用户名 密码 [天数]</code> 创建账号;<code>/uinfo 用户名</code> 查询账号;<code>/rmemby 用户名 confirm</code> 删除账号;<code>/only_rm_record tg:ID|用户名</code> 仅删 Bot 绑定;<code>/renewall 天数 confirm</code> 批量续期。\n" +
|
||||
"审计:<code>/userip 用户名</code> 查用户 IP;<code>/auditip IP</code> 按 IP 审计;<code>/auditdevice 关键词</code> 按终端设备审计;<code>/auditclient 关键词</code> 按客户端审计;<code>/udeviceid 设备ID</code> 按设备指纹审计。\n" +
|
||||
"清理:<code>/syncunbound</code> 检查未绑定账号;<code>/syncgroupm</code> 校验群成员;<code>/check_ex</code> 检查过期账号;<code>/deleted</code> 按保号规则预览清理候选。\n" +
|
||||
"权限:<code>/embyadmin 用户名 on|off</code> 设置管理员;<code>/banall confirm</code>/<code>/unbanall confirm</code> 批量禁用/解禁;<code>/prouser 用户名</code>/<code>/revuser 用户名</code> 管理保护名单;<code>/embylibs_blockall</code>/<code>/embylibs_unblockall</code> 批量禁用/开放媒体库权限。\n" +
|
||||
"运维:<code>/proadmin TelegramID</code>/<code>/revadmin TelegramID</code> 管理 Bot 管理员;<code>/backup_db</code> 备份数据库;<code>/restore_from_db 文件名 confirm</code> 恢复数据库。\n" +
|
||||
"说明:重复别名如 <code>/low_activity</code>、<code>/urm</code> 仍可兼容识别,但不显示在命令栏。"
|
||||
}
|
||||
|
||||
// cmdStatus 处理 /status 命令。
|
||||
func (s *TelegramBotService) cmdHideAdult(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
|
||||
channel := s.findChannelForMessage(ctx, msg)
|
||||
@@ -784,36 +800,65 @@ func (s *TelegramBotService) mediaStatsQuery(libraryIDs []string) *gorm.DB {
|
||||
// ── Polling ──
|
||||
|
||||
// StartPolling 为所有已启用的 Telegram 通知渠道启动长轮询。
|
||||
func (s *TelegramBotService) StartPolling(ctx context.Context) {
|
||||
func (s *TelegramBotService) StartPolling(ctx context.Context) TelegramPollingStartResult {
|
||||
result := TelegramPollingStartResult{Message: "telegram polling started"}
|
||||
channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram")
|
||||
if err != nil {
|
||||
s.log.Error("failed to list telegram channels for polling", zap.Error(err))
|
||||
return
|
||||
result.Message = "failed to list telegram channels"
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
return result
|
||||
}
|
||||
if len(channels) == 0 {
|
||||
result.Message = "no telegram channels configured"
|
||||
result.Errors = append(result.Errors, "没有配置 Telegram 通知渠道")
|
||||
return result
|
||||
}
|
||||
|
||||
for _, ch := range channels {
|
||||
if !ch.Enabled {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, ch.Name+": 通知渠道未启用")
|
||||
continue
|
||||
}
|
||||
configStr := ch.Config
|
||||
if s.crypto != nil && configStr != "" {
|
||||
configStr = s.crypto.Decrypt(configStr)
|
||||
}
|
||||
var cfg map[string]string
|
||||
if err := json.Unmarshal([]byte(configStr), &cfg); err != nil {
|
||||
var rawCfg map[string]any
|
||||
if err := json.Unmarshal([]byte(configStr), &rawCfg); err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, ch.Name+": Telegram 配置解析失败: "+err.Error())
|
||||
continue
|
||||
}
|
||||
cfg := telegramStringConfigFromAny(rawCfg)
|
||||
botToken := cfg["bot_token"]
|
||||
if botToken == "" {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, ch.Name+": Telegram Bot Token 为空")
|
||||
continue
|
||||
}
|
||||
s.pollingMu.Lock()
|
||||
if _, running := s.pollingCancel[botToken]; running {
|
||||
s.pollingMu.Unlock()
|
||||
result.AlreadyRunning++
|
||||
continue
|
||||
}
|
||||
s.pollingMu.Unlock()
|
||||
|
||||
if err := registerTelegramBotCommands(ctx, cfg); err != nil && s.log != nil {
|
||||
s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err)))
|
||||
}
|
||||
if err := deleteTelegramWebhook(ctx, cfg); err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, ch.Name+": "+sanitizeTelegramError(err).Error())
|
||||
continue
|
||||
}
|
||||
|
||||
s.pollingMu.Lock()
|
||||
if _, running := s.pollingCancel[botToken]; running {
|
||||
s.pollingMu.Unlock()
|
||||
result.AlreadyRunning++
|
||||
continue
|
||||
}
|
||||
pollCtx, cancel := context.WithCancel(context.Background())
|
||||
@@ -822,19 +867,27 @@ func (s *TelegramBotService) StartPolling(ctx context.Context) {
|
||||
|
||||
channel := ch
|
||||
go s.pollLoop(pollCtx, cfg, &channel)
|
||||
result.Started++
|
||||
s.log.Info("started telegram polling", zap.String("channel", ch.Name))
|
||||
}
|
||||
if result.Started == 0 && result.AlreadyRunning == 0 {
|
||||
result.Message = "no enabled telegram channels started"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// StopPolling 停止所有 Telegram 长轮询。
|
||||
func (s *TelegramBotService) StopPolling() {
|
||||
func (s *TelegramBotService) StopPolling() int {
|
||||
s.pollingMu.Lock()
|
||||
defer s.pollingMu.Unlock()
|
||||
stopped := 0
|
||||
for token, cancel := range s.pollingCancel {
|
||||
cancel()
|
||||
delete(s.pollingCancel, token)
|
||||
stopped++
|
||||
}
|
||||
s.log.Info("telegram polling stopped")
|
||||
return stopped
|
||||
}
|
||||
|
||||
// pollLoop 对单个 Bot Token 执行长轮询。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -15,6 +15,24 @@ export interface MediaSearchPage {
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface ManualScrapeCandidate {
|
||||
source: string
|
||||
media_type?: string
|
||||
title: string
|
||||
overview?: string
|
||||
poster_url?: string
|
||||
backdrop_url?: string
|
||||
year?: number
|
||||
rating?: number
|
||||
tmdb_id?: number
|
||||
bangumi_id?: number
|
||||
douban_id?: string
|
||||
thetvdb_id?: string
|
||||
languages?: string[]
|
||||
countries?: string[]
|
||||
genres?: string[]
|
||||
}
|
||||
|
||||
export const libraryAPI = {
|
||||
list: (options?: { includeHidden?: boolean }) =>
|
||||
api
|
||||
@@ -56,4 +74,15 @@ export const mediaAPI = {
|
||||
.then((r) => r.data),
|
||||
|
||||
get: (id: string) => api.get<Media>(`/media/${id}`).then((r) => r.data),
|
||||
|
||||
manualScrapeSearch: (id: string, params: { query: string; provider?: string; media_type?: string }) =>
|
||||
api
|
||||
.get<{ items: ManualScrapeCandidate[] }>(`/media/${id}/scrape/search`, { params })
|
||||
.then((r) => r.data.items),
|
||||
|
||||
applyManualScrape: (id: string, match: ManualScrapeCandidate) =>
|
||||
api.post<Media>(`/media/${id}/scrape/apply`, match).then((r) => r.data),
|
||||
|
||||
applyManualScrapeBatch: (mediaIDs: string[], match: ManualScrapeCandidate) =>
|
||||
api.post<{ applied: number; errors?: string[] }>('/media/scrape/apply', { media_ids: mediaIDs, match }).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
@@ -14,9 +14,12 @@ import { AppFooter } from './AppFooter'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePermissionStore } from '../stores/permissions'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import { imageURL } from '../api/client'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { playProfilesAPI } from '../api/play_profiles'
|
||||
import { requestPIN } from './PinDialog'
|
||||
import type { PlayProfile } from '../types'
|
||||
import type { Media, PlayProfile } from '../types'
|
||||
import { groupSeries, seriesCardLink } from '../utils/groupSeries'
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate()
|
||||
@@ -36,6 +39,12 @@ export function Layout() {
|
||||
const [profiles, setProfiles] = useState<PlayProfile[]>([])
|
||||
const [searchFocused, setSearchFocused] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchItems, setSearchItems] = useState<Media[]>([])
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
const [searchTotal, setSearchTotal] = useState(0)
|
||||
const [searchError, setSearchError] = useState('')
|
||||
const searchSeq = useRef(0)
|
||||
const searchCards = useMemo(() => groupSeries(searchItems).slice(0, 8), [searchItems])
|
||||
|
||||
// Auto-collapse sidebar on smaller tablet screens, and auto-hide drawer on path change
|
||||
useEffect(() => {
|
||||
@@ -61,6 +70,48 @@ export function Layout() {
|
||||
}
|
||||
}, [fetchPermissions, isPermissionLoading, permissions, user])
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname === '/search') {
|
||||
const query = new URLSearchParams(location.search).get('q') ?? ''
|
||||
setSearchQuery(query)
|
||||
}
|
||||
}, [location.pathname, location.search])
|
||||
|
||||
useEffect(() => {
|
||||
const query = searchQuery.trim()
|
||||
const seq = ++searchSeq.current
|
||||
if (!searchFocused || !query) {
|
||||
setSearchItems([])
|
||||
setSearchTotal(0)
|
||||
setSearchError('')
|
||||
setSearchLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSearchLoading(true)
|
||||
setSearchError('')
|
||||
const timer = window.setTimeout(() => {
|
||||
mediaAPI
|
||||
.search(query, 24)
|
||||
.then((data) => {
|
||||
if (seq !== searchSeq.current) return
|
||||
setSearchItems(data.items ?? [])
|
||||
setSearchTotal(data.total ?? (data.items ?? []).length)
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq !== searchSeq.current) return
|
||||
setSearchItems([])
|
||||
setSearchTotal(0)
|
||||
setSearchError('搜索失败,请稍后再试')
|
||||
})
|
||||
.finally(() => {
|
||||
if (seq === searchSeq.current) setSearchLoading(false)
|
||||
})
|
||||
}, 220)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [searchFocused, searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setProfiles([])
|
||||
@@ -108,6 +159,7 @@ export function Layout() {
|
||||
e.preventDefault()
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
|
||||
setSearchFocused(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,8 +401,10 @@ export function Layout() {
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onMouseDown={() => setSearchFocused(true)}
|
||||
onClick={() => setSearchFocused(true)}
|
||||
onFocus={() => setSearchFocused(true)}
|
||||
onBlur={() => setSearchFocused(false)}
|
||||
onBlur={() => window.setTimeout(() => setSearchFocused(false), 120)}
|
||||
placeholder="搜索电影、电视剧、演员、种子站点..."
|
||||
className="w-full rounded-full border border-gray-200 bg-gray-50/50 py-2.5 pl-11 pr-12 text-sm text-gray-900 placeholder-gray-500 outline-none transition-all duration-300 focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/40"
|
||||
/>
|
||||
@@ -359,6 +413,79 @@ export function Layout() {
|
||||
Enter
|
||||
</span>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{searchFocused && searchQuery.trim() && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 6, scale: 0.98 }}
|
||||
transition={{ duration: 0.14 }}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
className="absolute left-0 right-0 top-full z-50 mt-3 overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl"
|
||||
>
|
||||
<div className="max-h-[420px] overflow-y-auto p-2">
|
||||
{searchLoading && (
|
||||
<div className="flex items-center gap-2 px-3 py-4 text-sm text-gray-500">
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
||||
搜索中...
|
||||
</div>
|
||||
)}
|
||||
{!searchLoading && searchError && (
|
||||
<div className="px-3 py-4 text-sm text-red-500">{searchError}</div>
|
||||
)}
|
||||
{!searchLoading && !searchError && searchCards.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm text-gray-500">没有找到匹配的本地媒体</div>
|
||||
)}
|
||||
{!searchLoading && !searchError && searchCards.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{searchCards.map((card) => (
|
||||
<Link
|
||||
key={card.key}
|
||||
to={seriesCardLink(card)}
|
||||
onClick={() => setSearchFocused(false)}
|
||||
className="flex items-center gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<div className="h-14 w-10 shrink-0 overflow-hidden rounded-lg bg-gray-100">
|
||||
{card.rep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(card.rep.poster_url)}
|
||||
alt={card.rep.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-gray-400">
|
||||
<LibraryIcon size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-gray-900">
|
||||
{card.rep.title || card.rep.original_name || '未命名媒体'}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-gray-500">
|
||||
{card.rep.year ? <span>{card.rep.year}</span> : null}
|
||||
<span>{card.count > 1 ? `${card.count} 集/条目` : '单条媒体'}</span>
|
||||
{card.rep.width ? <span>{card.rep.width}x{card.rep.height}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to={`/search?q=${encodeURIComponent(searchQuery.trim())}`}
|
||||
onClick={() => setSearchFocused(false)}
|
||||
className="flex items-center justify-between border-t border-gray-100 px-4 py-3 text-sm font-semibold text-brand-600 hover:bg-brand-50/60"
|
||||
>
|
||||
<span>查看全部搜索结果</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{searchTotal > 0 ? `${searchTotal} 个条目` : 'Enter'}
|
||||
</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Check, LoaderCircle, Search, Sparkles, X } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { mediaAPI, type ManualScrapeCandidate } from '../api/library'
|
||||
import type { Media } from '../types'
|
||||
|
||||
interface ManualScrapeDialogProps {
|
||||
open: boolean
|
||||
media: Media | null
|
||||
mediaIds?: string[]
|
||||
defaultQuery?: string
|
||||
mediaType?: string
|
||||
scopeLabel?: string
|
||||
onClose: () => void
|
||||
onApplied?: () => void
|
||||
}
|
||||
|
||||
const providers = [
|
||||
{ value: 'all', label: '全部源' },
|
||||
{ value: 'tmdb', label: 'TMDb' },
|
||||
{ value: 'douban', label: '豆瓣' },
|
||||
{ value: 'bangumi', label: 'Bangumi' },
|
||||
{ value: 'thetvdb', label: 'TheTVDB' },
|
||||
]
|
||||
|
||||
export function ManualScrapeDialog({
|
||||
open,
|
||||
media,
|
||||
mediaIds,
|
||||
defaultQuery,
|
||||
mediaType,
|
||||
scopeLabel,
|
||||
onClose,
|
||||
onApplied,
|
||||
}: ManualScrapeDialogProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [provider, setProvider] = useState('all')
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [applyingKey, setApplyingKey] = useState('')
|
||||
const [items, setItems] = useState<ManualScrapeCandidate[]>([])
|
||||
|
||||
const targetIds = useMemo(() => {
|
||||
const ids = (mediaIds && mediaIds.length > 0 ? mediaIds : media ? [media.id] : []).filter(Boolean)
|
||||
return Array.from(new Set(ids))
|
||||
}, [media, mediaIds])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setQuery(defaultQuery || media?.title || '')
|
||||
setProvider('all')
|
||||
setItems([])
|
||||
setApplyingKey('')
|
||||
}, [defaultQuery, media?.title, open])
|
||||
|
||||
if (!open || !media) return null
|
||||
|
||||
const runSearch = async () => {
|
||||
const text = query.trim()
|
||||
if (!text) {
|
||||
toast.error('请输入标题或 TMDb/豆瓣/Bangumi/TheTVDB ID')
|
||||
return
|
||||
}
|
||||
setSearching(true)
|
||||
try {
|
||||
const results = await mediaAPI.manualScrapeSearch(media.id, {
|
||||
query: text,
|
||||
provider,
|
||||
media_type: mediaType,
|
||||
})
|
||||
setItems(results)
|
||||
if (results.length === 0) toast.error('没有找到可用候选')
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '搜索失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const apply = async (item: ManualScrapeCandidate) => {
|
||||
const key = candidateKey(item)
|
||||
setApplyingKey(key)
|
||||
try {
|
||||
if (targetIds.length > 1) {
|
||||
const result = await mediaAPI.applyManualScrapeBatch(targetIds, item)
|
||||
toast.success(`已应用到 ${result.applied} 个媒体`)
|
||||
} else {
|
||||
await mediaAPI.applyManualScrape(media.id, item)
|
||||
toast.success('已应用手动匹配')
|
||||
}
|
||||
onApplied?.()
|
||||
onClose()
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '应用失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setApplyingKey('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-900/40 px-4 py-8 backdrop-blur-sm">
|
||||
<div className="flex max-h-[86vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-sand-200 bg-white shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-sand-200 px-5 py-4">
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-bold text-ink-600">手动搜索刮削</h2>
|
||||
<p className="mt-1 text-xs text-sand-500">
|
||||
{scopeLabel || media.title} · {targetIds.length > 1 ? `将应用到 ${targetIds.length} 个媒体` : '单个媒体'}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="btn-ghost h-9 w-9 p-0" aria-label="关闭">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-b border-sand-200 p-5 sm:flex-row">
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(event) => setProvider(event.target.value)}
|
||||
className="h-11 rounded-xl border border-sand-200 bg-white px-3 text-sm font-semibold text-ink-600 outline-none focus:border-brand-300"
|
||||
>
|
||||
{providers.map((item) => (
|
||||
<option key={item.value} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-sand-500" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') runSearch() }}
|
||||
className="h-11 w-full rounded-xl border border-sand-200 bg-white pl-9 pr-3 text-sm font-semibold text-ink-600 outline-none focus:border-brand-300"
|
||||
placeholder="输入标题或 TMDb / 豆瓣 / Bangumi / TheTVDB ID"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={runSearch} disabled={searching} className="btn-primary h-11 px-5">
|
||||
{searching ? <LoaderCircle size={16} className="animate-spin" /> : <Sparkles size={16} />}
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex min-h-56 items-center justify-center rounded-xl border border-dashed border-sand-200 text-sm text-sand-500">
|
||||
搜索后在这里选择正确的元数据
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{items.map((item) => {
|
||||
const key = candidateKey(item)
|
||||
const applying = applyingKey === key
|
||||
return (
|
||||
<div key={key} className="flex gap-4 rounded-xl border border-sand-200 bg-white p-3 shadow-sm">
|
||||
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
|
||||
{item.poster_url ? (
|
||||
<img src={imageURL(item.poster_url)} alt={item.title} className="h-full w-full object-cover" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-xs text-sand-500">无海报</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
|
||||
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold uppercase text-brand-700">{item.source}</span>
|
||||
{item.year ? <span className="text-xs text-sand-500">{item.year}</span> : null}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-ink-50">{item.overview || '暂无简介'}</p>
|
||||
<p className="mt-2 text-[11px] font-semibold text-sand-500">{candidateIDText(item)}</p>
|
||||
</div>
|
||||
<button onClick={() => apply(item)} disabled={!!applyingKey} className="btn-outline h-10 shrink-0 self-center px-3 text-xs">
|
||||
{applying ? <LoaderCircle size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
应用匹配
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function candidateKey(item: ManualScrapeCandidate): string {
|
||||
return `${item.source}:${item.tmdb_id || item.bangumi_id || item.douban_id || item.thetvdb_id || item.title}:${item.media_type || ''}`
|
||||
}
|
||||
|
||||
function candidateIDText(item: ManualScrapeCandidate): string {
|
||||
const parts = [
|
||||
item.tmdb_id ? `TMDb ${item.tmdb_id}` : '',
|
||||
item.douban_id ? `豆瓣 ${item.douban_id}` : '',
|
||||
item.bangumi_id ? `Bangumi ${item.bangumi_id}` : '',
|
||||
item.thetvdb_id ? `TheTVDB ${item.thetvdb_id}` : '',
|
||||
item.media_type ? item.media_type : '',
|
||||
].filter(Boolean)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Film, Play, Layers, Star } from 'lucide-react'
|
||||
@@ -6,7 +6,7 @@ import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
export const MediaCard = ({
|
||||
media, progress, count, rating, linkTo, onClick,
|
||||
media, progress, count, rating, linkTo, onClick, actions,
|
||||
}: {
|
||||
media: Media
|
||||
progress?: number
|
||||
@@ -14,6 +14,7 @@ export const MediaCard = ({
|
||||
rating?: number
|
||||
linkTo?: string
|
||||
onClick?: () => void
|
||||
actions?: ReactNode
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const href = linkTo ?? `/media/${media.id}`
|
||||
@@ -131,15 +132,35 @@ export const MediaCard = ({
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="group block w-full text-left">
|
||||
{card}
|
||||
</button>
|
||||
<div className="group relative block w-full">
|
||||
<button type="button" onClick={onClick} className="block w-full text-left">
|
||||
{card}
|
||||
</button>
|
||||
{actions && (
|
||||
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (actions) {
|
||||
return (
|
||||
<div className="group relative block">
|
||||
<Link to={href} className="block">
|
||||
{card}
|
||||
</Link>
|
||||
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={href} className="group block">
|
||||
{card}
|
||||
{card}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ArrowLeft, Play, Film } from 'lucide-react'
|
||||
import { ArrowLeft, Play, Film, Database, FileText, Search, Sparkles, Trash2 } from 'lucide-react'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { storageAPI, type CloudScanStatus } from '../api/storage_config'
|
||||
import { api } from '../api/client'
|
||||
import { recycleAPI } from '../api/recycle'
|
||||
import type { Library, Media } from '../types'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
@@ -13,6 +15,8 @@ import { imageURL } from '../api/client'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { getSeriesKey, groupSeries, isEpisodeLike, seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
import { useWebSocket } from '../hooks/useWebSocket'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||
|
||||
export function LibraryPage() {
|
||||
const { id = '' } = useParams()
|
||||
@@ -27,6 +31,10 @@ export function LibraryPage() {
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [scanProgress, setScanProgress] = useState('')
|
||||
const [scraping, setScraping] = useState(false)
|
||||
const [seriesToolBusy, setSeriesToolBusy] = useState('')
|
||||
const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false)
|
||||
const [manualMovie, setManualMovie] = useState<Media | null>(null)
|
||||
const [movieToolBusy, setMovieToolBusy] = useState('')
|
||||
|
||||
// 剧集模式:选中某个剧集后展开详情
|
||||
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
|
||||
@@ -65,6 +73,16 @@ export function LibraryPage() {
|
||||
return selectedEpisodes.find((s) => s.season === selectedSeason)?.episodes ?? []
|
||||
}, [selectedEpisodes, selectedSeason])
|
||||
|
||||
const selectedSeriesEpisodes = useMemo(
|
||||
() => selectedEpisodes.flatMap((season) => season.episodes),
|
||||
[selectedEpisodes],
|
||||
)
|
||||
|
||||
const selectedSeriesMediaIDs = useMemo(
|
||||
() => selectedSeriesEpisodes.map((ep) => ep.id),
|
||||
[selectedSeriesEpisodes],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
libraryAPI.list().then((all) => {
|
||||
@@ -255,6 +273,107 @@ export function LibraryPage() {
|
||||
finally { setScraping(false) }
|
||||
}
|
||||
|
||||
const runSeriesTool = async (key: string, label: string, action: (media: Media) => Promise<unknown>) => {
|
||||
if (selectedSeriesEpisodes.length === 0) return
|
||||
setSeriesToolBusy(key)
|
||||
try {
|
||||
for (const ep of selectedSeriesEpisodes) {
|
||||
await action(ep)
|
||||
}
|
||||
toast.success(`${label}完成:${selectedSeriesEpisodes.length} 个媒体`)
|
||||
reloadCurrentLibrary()
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || `${label}失败`
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSeriesToolBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSeriesSmartScrape = () => {
|
||||
runSeriesTool('scrape', '整剧智能刮削', (media) => api.post(`/media/${media.id}/scrape`))
|
||||
}
|
||||
|
||||
const handleSeriesProbe = () => {
|
||||
runSeriesTool('probe', '整剧媒体轨探测', (media) => api.post(`/media/${media.id}/probe`))
|
||||
}
|
||||
|
||||
const handleSeriesNFO = () => {
|
||||
runSeriesTool('nfo', '整剧 NFO 写出', (media) => recycleAPI.exportNFO(media.id))
|
||||
}
|
||||
|
||||
const handleSeriesSoftDelete = async () => {
|
||||
if (!selectedSeries || selectedSeriesEpisodes.length === 0) return
|
||||
if (!(await confirmAction({
|
||||
title: '移入回收站',
|
||||
message: `将「${seriesTitle(selectedSeries.rep)}」的 ${selectedSeriesEpisodes.length} 个媒体移至回收站? (磁盘文件保留)`,
|
||||
confirmText: '移入回收站',
|
||||
}))) return
|
||||
await runSeriesTool('delete', '整剧移入回收站', (media) => recycleAPI.softDelete(media.id))
|
||||
clearSelectedSeries()
|
||||
}
|
||||
|
||||
const runMovieTool = async (media: Media, key: string, label: string, action: (media: Media) => Promise<unknown>) => {
|
||||
const busyKey = `${key}:${media.id}`
|
||||
setMovieToolBusy(busyKey)
|
||||
try {
|
||||
await action(media)
|
||||
toast.success(`${label}完成:${media.title}`)
|
||||
reloadCurrentLibrary()
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || `${label}失败`
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setMovieToolBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMovieSmartScrape = (media: Media) => {
|
||||
runMovieTool(media, 'scrape', '智能刮削', (item) => api.post(`/media/${item.id}/scrape`))
|
||||
}
|
||||
|
||||
const handleMovieProbe = (media: Media) => {
|
||||
runMovieTool(media, 'probe', '媒体轨探测', (item) => api.post(`/media/${item.id}/probe`))
|
||||
}
|
||||
|
||||
const handleMovieNFO = (media: Media) => {
|
||||
runMovieTool(media, 'nfo', 'NFO 写出', (item) => recycleAPI.exportNFO(item.id))
|
||||
}
|
||||
|
||||
const handleMovieSoftDelete = async (media: Media) => {
|
||||
if (!(await confirmAction({
|
||||
title: '移入回收站',
|
||||
message: `将「${media.title}」移至回收站? (磁盘文件保留)`,
|
||||
confirmText: '移入回收站',
|
||||
}))) return
|
||||
await runMovieTool(media, 'delete', '移入回收站', (item) => recycleAPI.softDelete(item.id))
|
||||
}
|
||||
|
||||
const movieActions = (media: Media) => {
|
||||
if (role !== 'admin') return undefined
|
||||
const busy = movieToolBusy.endsWith(`:${media.id}`)
|
||||
const buttonClass = 'flex h-8 w-8 items-center justify-center rounded-lg border border-white/70 bg-white/90 text-gray-700 shadow-sm backdrop-blur transition hover:bg-brand-50 hover:text-brand-600 disabled:opacity-50'
|
||||
return (
|
||||
<>
|
||||
<button title="智能刮削" disabled={busy} onClick={() => handleMovieSmartScrape(media)} className={buttonClass}>
|
||||
<Sparkles size={13} />
|
||||
</button>
|
||||
<button title="手动匹配刮削" disabled={busy} onClick={() => setManualMovie(media)} className={buttonClass}>
|
||||
<Search size={13} />
|
||||
</button>
|
||||
<button title="探测媒体轨" disabled={busy} onClick={() => handleMovieProbe(media)} className={buttonClass}>
|
||||
<Database size={13} />
|
||||
</button>
|
||||
<button title="写出本地 NFO" disabled={busy} onClick={() => handleMovieNFO(media)} className={buttonClass}>
|
||||
<FileText size={13} />
|
||||
</button>
|
||||
<button title="移入回收站" disabled={busy} onClick={() => handleMovieSoftDelete(media)} className={`${buttonClass} hover:!bg-red-50 hover:!text-red-500`}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSeriesClick = (card: SeriesCard) => {
|
||||
setSelectedSeries(card)
|
||||
const next = new URLSearchParams(searchParams)
|
||||
@@ -309,7 +428,7 @@ export function LibraryPage() {
|
||||
{!isSeries && items.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
<MediaCard key={m.id} media={m} actions={movieActions(m)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -386,6 +505,34 @@ export function LibraryPage() {
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
|
||||
{role === 'admin' && selectedSeriesEpisodes.length > 0 && (
|
||||
<div className="rounded-2xl border border-sand-200 bg-white/80 p-4 shadow-sm">
|
||||
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={handleSeriesSmartScrape} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
|
||||
<Sparkles size={13} className="text-[#c9954a]" />
|
||||
<span>{seriesToolBusy === 'scrape' ? '刮削中…' : '整剧智能刮削'}</span>
|
||||
</button>
|
||||
<button onClick={() => setManualSeriesScrapeOpen(true)} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
|
||||
<Search size={13} className="text-[#c9954a]" />
|
||||
<span>手动匹配整剧</span>
|
||||
</button>
|
||||
<button onClick={handleSeriesProbe} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
|
||||
<Database size={13} />
|
||||
<span>{seriesToolBusy === 'probe' ? '探测中…' : '探测媒体轨'}</span>
|
||||
</button>
|
||||
<button onClick={handleSeriesNFO} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
|
||||
<FileText size={13} />
|
||||
<span>{seriesToolBusy === 'nfo' ? '写出中…' : '写出本地 NFO'}</span>
|
||||
</button>
|
||||
<button onClick={handleSeriesSoftDelete} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200">
|
||||
<Trash2 size={13} />
|
||||
<span>{seriesToolBusy === 'delete' ? '处理中…' : '移入回收站'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -451,6 +598,26 @@ export function LibraryPage() {
|
||||
<p className="text-ink-50">该库尚未发现任何剧集,触发一次扫描后再来看看</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ManualScrapeDialog
|
||||
open={manualSeriesScrapeOpen}
|
||||
media={selectedSeries?.rep ?? null}
|
||||
mediaIds={selectedSeriesMediaIDs}
|
||||
defaultQuery={selectedSeries ? seriesTitle(selectedSeries.rep) : ''}
|
||||
mediaType={library?.type || 'tv'}
|
||||
scopeLabel={selectedSeries ? seriesTitle(selectedSeries.rep) : '当前剧集'}
|
||||
onClose={() => setManualSeriesScrapeOpen(false)}
|
||||
onApplied={reloadCurrentLibrary}
|
||||
/>
|
||||
<ManualScrapeDialog
|
||||
open={!!manualMovie}
|
||||
media={manualMovie}
|
||||
defaultQuery={manualMovie?.title ?? ''}
|
||||
mediaType={library?.type || 'movie'}
|
||||
scopeLabel={manualMovie?.title ?? '当前电影'}
|
||||
onClose={() => setManualMovie(null)}
|
||||
onApplied={reloadCurrentLibrary}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2, Calendar, Database } from 'lucide-react'
|
||||
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2, Calendar, Database, Search } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
@@ -13,6 +13,7 @@ import { api } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||
|
||||
function fmtDuration(sec: number): string {
|
||||
if (!sec || sec <= 0) return '—'
|
||||
@@ -45,6 +46,7 @@ export function MediaDetailPage() {
|
||||
const [media, setMedia] = useState<Media | null>(null)
|
||||
const [favourite, setFavourite] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
if (!id) return
|
||||
@@ -315,6 +317,10 @@ export function MediaDetailPage() {
|
||||
<Sparkles size={13} className="text-[#c9954a]" />
|
||||
<span>智能刮削 (TMDB)</span>
|
||||
</button>
|
||||
<button onClick={() => setManualScrapeOpen(true)} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Search size={13} className="text-[#c9954a]" />
|
||||
<span>手动匹配刮削</span>
|
||||
</button>
|
||||
<button onClick={reprobe} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Database size={13} className="text-gray-600" />
|
||||
<span>探测媒体轨 (ffprobe)</span>
|
||||
@@ -336,6 +342,15 @@ export function MediaDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ManualScrapeDialog
|
||||
open={manualScrapeOpen}
|
||||
media={media}
|
||||
defaultQuery={media.title}
|
||||
mediaType={media.season_num > 0 || media.episode_num > 0 ? 'tv' : undefined}
|
||||
scopeLabel={media.title}
|
||||
onClose={() => setManualScrapeOpen(false)}
|
||||
onApplied={refresh}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Bell, Loader2, Pencil, Plus, Send, Trash2 } from 'lucide-react'
|
||||
import { Bell, Loader2, Pencil, Play, Plus, Send, Square, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
@@ -17,6 +17,8 @@ export function NotifyChannelsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<NotifyChannel | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [testingID, setTestingID] = useState<string | null>(null)
|
||||
const [pollingBusy, setPollingBusy] = useState<'start' | 'stop' | null>(null)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
@@ -32,6 +34,7 @@ export function NotifyChannelsPage() {
|
||||
}, [])
|
||||
|
||||
const onTest = async (id: string) => {
|
||||
setTestingID(id)
|
||||
try {
|
||||
await notifyChannelsAPI.test(id)
|
||||
toast.success('测试消息已发送')
|
||||
@@ -39,6 +42,42 @@ export function NotifyChannelsPage() {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '发送失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setTestingID(null)
|
||||
}
|
||||
}
|
||||
|
||||
const onStartPolling = async () => {
|
||||
setPollingBusy('start')
|
||||
try {
|
||||
const res = await notifyChannelsAPI.startTelegramPolling()
|
||||
if ((res.started ?? 0) > 0) {
|
||||
toast.success(`已启动 ${res.started} 个 Telegram Bot 轮询`)
|
||||
} else if ((res.already_running ?? 0) > 0) {
|
||||
toast.success('Telegram Bot 轮询已在运行')
|
||||
} else {
|
||||
toast.error(res.errors?.[0] ?? '没有可启动的 Telegram Bot 渠道')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '启动轮询失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setPollingBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const onStopPolling = async () => {
|
||||
setPollingBusy('stop')
|
||||
try {
|
||||
const res = await notifyChannelsAPI.stopTelegramPolling()
|
||||
toast.success(res.stopped > 0 ? `已停止 ${res.stopped} 个 Telegram Bot 轮询` : '当前没有运行中的轮询')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '停止轮询失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setPollingBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,15 +108,35 @@ export function NotifyChannelsPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
setShowForm(true)
|
||||
}}
|
||||
className="neon-button"
|
||||
>
|
||||
<Plus size={16} /> 添加渠道
|
||||
</button>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
onClick={onStartPolling}
|
||||
disabled={pollingBusy !== null}
|
||||
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:border-primary-400/40 hover:text-brand-500 disabled:opacity-50"
|
||||
title="启动 Telegram Bot 长轮询"
|
||||
>
|
||||
{pollingBusy === 'start' ? <Loader2 size={16} className="inline animate-spin" /> : <Play size={16} className="inline" />}
|
||||
{' '}启动 Bot 轮询
|
||||
</button>
|
||||
<button
|
||||
onClick={onStopPolling}
|
||||
disabled={pollingBusy !== null}
|
||||
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:border-primary-400/40 hover:text-brand-500 disabled:opacity-50"
|
||||
title="停止本地长轮询。"
|
||||
>
|
||||
{pollingBusy === 'stop' ? <Loader2 size={16} className="inline animate-spin" /> : <Square size={16} className="inline" />}
|
||||
{' '}停止轮询
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
setShowForm(true)
|
||||
}}
|
||||
className="neon-button"
|
||||
>
|
||||
<Plus size={16} /> 添加渠道
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
@@ -97,6 +156,7 @@ export function NotifyChannelsPage() {
|
||||
key={ch.id}
|
||||
channel={ch}
|
||||
onTest={() => onTest(ch.id)}
|
||||
testing={testingID === ch.id}
|
||||
onEdit={() => {
|
||||
setEditing(ch)
|
||||
setShowForm(true)
|
||||
@@ -140,11 +200,13 @@ const EVENT_OPTIONS = [
|
||||
function ChannelCard({
|
||||
channel,
|
||||
onTest,
|
||||
testing,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
channel: NotifyChannel
|
||||
onTest: () => void
|
||||
testing?: boolean
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
@@ -167,9 +229,10 @@ function ChannelCard({
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<Send size={12} className="inline" /> 测试
|
||||
{testing ? <Loader2 size={12} className="inline animate-spin" /> : <Send size={12} className="inline" />} 测试
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import { RotateCcw, Trash2 } from 'lucide-react'
|
||||
import { Home, RotateCcw, Square, CheckSquare, Trash2 } from 'lucide-react'
|
||||
|
||||
import { recycleAPI } from '../api/recycle'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
@@ -9,32 +10,124 @@ import type { Media } from '../types'
|
||||
export function RecycleBinPage() {
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [batchBusy, setBatchBusy] = useState('')
|
||||
|
||||
const refresh = () =>
|
||||
recycleAPI
|
||||
.list()
|
||||
.then(setItems)
|
||||
.then((rows) => {
|
||||
setItems(rows)
|
||||
setSelectedIds((current) => current.filter((id) => rows.some((item) => item.id === id)))
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const allSelected = items.length > 0 && selectedIds.length === items.length
|
||||
const toggleAll = () => {
|
||||
setSelectedIds(allSelected ? [] : items.map((item) => item.id))
|
||||
}
|
||||
const toggleOne = (id: string) => {
|
||||
setSelectedIds((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id])
|
||||
}
|
||||
const runBatch = async (mode: 'restore' | 'purge') => {
|
||||
if (selectedIds.length === 0) return
|
||||
if (mode === 'purge' && !(await confirmAction({
|
||||
title: '批量彻底删除记录',
|
||||
message: `彻底删除选中的 ${selectedIds.length} 条记录? (磁盘文件保留)`,
|
||||
confirmText: '彻底删除',
|
||||
}))) return
|
||||
setBatchBusy(mode)
|
||||
try {
|
||||
const result = await runRecycleBatchRequest(mode, selectedIds)
|
||||
toast.success(`${mode === 'restore' ? '恢复' : '彻底删除'}完成:${result.applied} 条`)
|
||||
setSelectedIds([])
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const response = (err as { response?: { status?: number; data?: { error?: string } }; message?: string })?.response
|
||||
const msg = response?.data?.error || (response?.status ? `批量操作失败 (${response.status})` : (err as { message?: string })?.message || '批量操作失败')
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setBatchBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const runRecycleBatchRequest = async (mode: 'restore' | 'purge', ids: string[]) => {
|
||||
try {
|
||||
return mode === 'restore'
|
||||
? await recycleAPI.restoreMany(ids)
|
||||
: await recycleAPI.purgeMany(ids)
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
if (status !== 404 && status !== 405) throw err
|
||||
let applied = 0
|
||||
for (const id of ids) {
|
||||
if (mode === 'restore') {
|
||||
await recycleAPI.restore(id)
|
||||
} else {
|
||||
await recycleAPI.purge(id)
|
||||
}
|
||||
applied += 1
|
||||
}
|
||||
return { applied, errors: [] as string[] }
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">回收站</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
软删除的媒体保留在数据库中,可以恢复。彻底删除不会移除磁盘上的文件,只会从数据库清除条目。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">回收站</h1>
|
||||
<p className="mt-2 text-sm text-ink-50">
|
||||
软删除的媒体保留在数据库中,可以恢复。彻底删除不会移除磁盘上的文件,只会从数据库清除条目。
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-sand-500">系统最多保留最新 200 条回收站记录,超过后会自动清理旧记录。</p>
|
||||
</div>
|
||||
<Link to="/" className="btn-outline shrink-0 py-2.5 px-4 text-sm">
|
||||
<Home size={15} />
|
||||
返回系统首页
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-sand-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && <p className="text-ink-50">回收站为空。</p>}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="glass-panel">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-xs font-semibold text-sand-500">
|
||||
已选择 {selectedIds.length} / {items.length} 条
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => runBatch('restore')}
|
||||
disabled={selectedIds.length === 0 || !!batchBusy}
|
||||
className="btn-outline py-2 px-3 text-xs"
|
||||
>
|
||||
<RotateCcw size={13} />
|
||||
{batchBusy === 'restore' ? '恢复中…' : '批量恢复'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => runBatch('purge')}
|
||||
disabled={selectedIds.length === 0 || !!batchBusy}
|
||||
className="btn-outline py-2 px-3 text-xs !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{batchBusy === 'purge' ? '删除中…' : '批量彻底删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="w-10 py-2">
|
||||
<button onClick={toggleAll} className="text-sand-500 hover:text-brand-500" aria-label="全选">
|
||||
{allSelected ? <CheckSquare size={16} /> : <Square size={16} />}
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-2">标题</th>
|
||||
<th>路径</th>
|
||||
<th className="text-right">操作</th>
|
||||
@@ -43,6 +136,11 @@ export function RecycleBinPage() {
|
||||
<tbody>
|
||||
{items.map((m) => (
|
||||
<tr key={m.id} className="border-t border-gray-200">
|
||||
<td className="py-2">
|
||||
<button onClick={() => toggleOne(m.id)} className="text-sand-500 hover:text-brand-500" aria-label={`选择 ${m.title}`}>
|
||||
{selectedIds.includes(m.id) ? <CheckSquare size={16} /> : <Square size={16} />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2 text-ink-600">{m.title}</td>
|
||||
<td className="max-w-md truncate text-ink-50" title={m.path}>
|
||||
{m.path}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import { CheckCircle2, Info, Rss, Sparkles } from 'lucide-react'
|
||||
|
||||
@@ -13,6 +14,8 @@ import { groupSeries, seriesCardLink } from '../utils/groupSeries'
|
||||
const LOCAL_SEARCH_PAGE_SIZE = 2000
|
||||
|
||||
export function SearchPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const urlQuery = searchParams.get('q') ?? ''
|
||||
const [q, setQ] = useState('')
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -34,6 +37,10 @@ export function SearchPage() {
|
||||
.catch(() => setAiAvailable(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setQ(urlQuery)
|
||||
}, [urlQuery])
|
||||
|
||||
const doQuickSearch = useCallback((query: string) => {
|
||||
const seq = ++searchSeq.current
|
||||
if (!query.trim()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user