mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 20:26:36 +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"`
|
||||
|
||||
Reference in New Issue
Block a user