mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
Fix local adult metadata and watch history removal
This commit is contained in:
@@ -0,0 +1 @@
|
||||
39f30642a462624f2aa03896465943891e2d28db9f9a03aca94fd9af58d0c108
|
||||
@@ -0,0 +1 @@
|
||||
28604
|
||||
@@ -99,6 +99,7 @@ organizer:
|
||||
smart_classify: false # 是否启用智能分类整理
|
||||
categories:
|
||||
chinese_movie: "华语电影"
|
||||
animation_movie: "动画电影"
|
||||
foreign_movie: "外语电影"
|
||||
euus_movie: "欧美电影"
|
||||
jk_movie: "日韩电影"
|
||||
@@ -107,3 +108,7 @@ organizer:
|
||||
jk_tv: "日韩剧"
|
||||
jp_anime: "日番"
|
||||
cn_anime: "国漫"
|
||||
variety: "综艺"
|
||||
documentary: "纪录片"
|
||||
children: "儿童"
|
||||
uncategorized_tv: "未分类"
|
||||
|
||||
+16
-11
@@ -23,17 +23,17 @@ const EnvPrefix = "MEDIASTATION"
|
||||
|
||||
// Config 是根配置聚合。
|
||||
type Config struct {
|
||||
App AppConfig `mapstructure:"app"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Secrets SecretsConfig `mapstructure:"secrets"`
|
||||
Logging LoggingConfig `mapstructure:"logging"`
|
||||
Cache CacheConfig `mapstructure:"cache"`
|
||||
Media MediaConfig `mapstructure:"media"`
|
||||
Transcoder TranscoderConfig `mapstructure:"transcoder"`
|
||||
AI AIConfig `mapstructure:"ai"`
|
||||
App AppConfig `mapstructure:"app"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Secrets SecretsConfig `mapstructure:"secrets"`
|
||||
Logging LoggingConfig `mapstructure:"logging"`
|
||||
Cache CacheConfig `mapstructure:"cache"`
|
||||
Media MediaConfig `mapstructure:"media"`
|
||||
Transcoder TranscoderConfig `mapstructure:"transcoder"`
|
||||
AI AIConfig `mapstructure:"ai"`
|
||||
FlareSolverr FlareSolverrConfig `mapstructure:"flaresolverr"`
|
||||
ApiConfig ApiConfigConfig `mapstructure:"api_config"`
|
||||
Organizer OrganizerConfig `mapstructure:"organizer"`
|
||||
ApiConfig ApiConfigConfig `mapstructure:"api_config"`
|
||||
Organizer OrganizerConfig `mapstructure:"organizer"`
|
||||
}
|
||||
|
||||
// ApiConfigConfig API 配置相关设置。
|
||||
@@ -133,7 +133,7 @@ type AIConfig struct {
|
||||
|
||||
// OrganizerConfig 配置媒体文件智能分类整理。
|
||||
type OrganizerConfig struct {
|
||||
SmartClassify bool `mapstructure:"smart_classify"`
|
||||
SmartClassify bool `mapstructure:"smart_classify"`
|
||||
AutoAfterDownload bool `mapstructure:"auto_after_download"`
|
||||
Categories map[string]string `mapstructure:"categories"`
|
||||
}
|
||||
@@ -237,6 +237,7 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("organizer.smart_classify", false)
|
||||
v.SetDefault("organizer.auto_after_download", false)
|
||||
v.SetDefault("organizer.categories.chinese_movie", "华语电影")
|
||||
v.SetDefault("organizer.categories.animation_movie", "动画电影")
|
||||
v.SetDefault("organizer.categories.foreign_movie", "外语电影")
|
||||
v.SetDefault("organizer.categories.euus_movie", "欧美电影")
|
||||
v.SetDefault("organizer.categories.jk_movie", "日韩电影")
|
||||
@@ -245,6 +246,10 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("organizer.categories.jk_tv", "日韩剧")
|
||||
v.SetDefault("organizer.categories.jp_anime", "日番")
|
||||
v.SetDefault("organizer.categories.cn_anime", "国漫")
|
||||
v.SetDefault("organizer.categories.variety", "综艺")
|
||||
v.SetDefault("organizer.categories.documentary", "纪录片")
|
||||
v.SetDefault("organizer.categories.children", "儿童")
|
||||
v.SetDefault("organizer.categories.uncategorized_tv", "未分类")
|
||||
|
||||
v.SetDefault("transcoder.encoder", "")
|
||||
v.SetDefault("transcoder.preset", "veryfast")
|
||||
|
||||
@@ -57,6 +57,7 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
service.ApplyRuntimeSetting(svc.Cfg, req.Key, req.Value)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-3
@@ -28,11 +28,21 @@ func smartSearchHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
// Run the actual library search using the cleaned query so the
|
||||
// caller can render results in one round-trip.
|
||||
// caller can render local + external results in one round-trip.
|
||||
items, _ := svc.Media.SearchMedia(c.Request.Context(), intent.Query, 60)
|
||||
external := service.SearchExternalMedia(
|
||||
c.Request.Context(),
|
||||
intent.Query,
|
||||
intent.Year,
|
||||
intent.Type,
|
||||
svc.TMDb,
|
||||
svc.Douban,
|
||||
svc.Bangumi,
|
||||
)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"intent": intent,
|
||||
"items": items,
|
||||
"intent": intent,
|
||||
"items": items,
|
||||
"external_items": external,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -22,34 +23,33 @@ func discoverSectionsHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sections": []gin.H{
|
||||
{"key": "trending_day", "label": "今日热门"},
|
||||
{"key": "trending_week", "label": "本周热门"},
|
||||
{"key": "popular_movie", "label": "热门电影"},
|
||||
{"key": "popular_tv", "label": "热门剧集"},
|
||||
{"key": "top_rated_movie", "label": "高分电影"},
|
||||
{"key": "upcoming_movie", "label": "即将上映"},
|
||||
{"key": "tmdb_trending_day", "label": "TMDb 今日趋势", "provider": "tmdb"},
|
||||
{"key": "tmdb_trending_week", "label": "TMDb 本周热门", "provider": "tmdb"},
|
||||
{"key": "tmdb_popular_movie", "label": "TMDb 热门电影", "provider": "tmdb"},
|
||||
{"key": "tmdb_popular_tv", "label": "TMDb 热门剧集", "provider": "tmdb"},
|
||||
{"key": "tmdb_top_rated_movie", "label": "TMDb 高分电影", "provider": "tmdb"},
|
||||
{"key": "douban_hot_movie", "label": "豆瓣热门电影", "provider": "douban"},
|
||||
{"key": "douban_hot_tv", "label": "豆瓣热门剧集", "provider": "douban"},
|
||||
{"key": "douban_top_movie", "label": "豆瓣高分电影", "provider": "douban"},
|
||||
{"key": "bangumi_calendar", "label": "Bangumi 每日放送", "provider": "bangumi"},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// discoverFeedHandler resolves one or more section keys (?sections=a,b)
|
||||
// to TMDb endpoint paths and returns the joined results keyed by
|
||||
// section name. Unknown keys are silently dropped so URL typos don't
|
||||
// break the page.
|
||||
// to TMDb / Douban / Bangumi rails and returns the joined results keyed by
|
||||
// section name. Unknown keys are silently dropped so URL typos don't break
|
||||
// the page.
|
||||
func discoverFeedHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
keys := strings.Split(c.DefaultQuery("sections", "trending_day,popular_movie"), ",")
|
||||
keys := strings.Split(c.DefaultQuery("sections", "tmdb_trending_day,tmdb_popular_movie,douban_hot_movie,bangumi_calendar"), ",")
|
||||
out := gin.H{}
|
||||
for _, raw := range keys {
|
||||
k := strings.TrimSpace(raw)
|
||||
path := sectionPath(k)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
items, err := svc.Discover.Fetch(c.Request.Context(), path)
|
||||
items, err := discoverSectionItems(c.Request.Context(), svc, k)
|
||||
if err != nil {
|
||||
svc.Log.Debug("discover fetch failed", )
|
||||
svc.Log.Debug("discover fetch failed")
|
||||
items = nil
|
||||
}
|
||||
out[k] = items
|
||||
@@ -58,22 +58,22 @@ func discoverFeedHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// sectionPath maps the UI-facing key to the TMDb endpoint suffix.
|
||||
func sectionPath(k string) string {
|
||||
func discoverSectionItems(ctx context.Context, svc *service.Container, k string) ([]service.ExternalMediaResult, error) {
|
||||
switch k {
|
||||
case "trending_day":
|
||||
return "/trending/movie/day"
|
||||
case "trending_week":
|
||||
return "/trending/movie/week"
|
||||
case "popular_movie":
|
||||
return "/movie/popular"
|
||||
case "popular_tv":
|
||||
return "/tv/popular"
|
||||
case "top_rated_movie":
|
||||
return "/movie/top_rated"
|
||||
case "upcoming_movie":
|
||||
return "/movie/upcoming"
|
||||
case "tmdb_trending_day", "tmdb_trending_week", "tmdb_popular_movie", "tmdb_popular_tv", "tmdb_top_rated_movie",
|
||||
"trending_day", "trending_week", "popular_movie", "popular_tv", "top_rated_movie", "upcoming_movie":
|
||||
return svc.Discover.TMDbSection(ctx, k)
|
||||
case "douban_hot_movie", "douban_hot_tv", "douban_top_movie":
|
||||
if svc.Douban == nil {
|
||||
return []service.ExternalMediaResult{}, nil
|
||||
}
|
||||
return svc.Douban.Discover(ctx, k)
|
||||
case "bangumi_calendar":
|
||||
if svc.Bangumi == nil {
|
||||
return []service.ExternalMediaResult{}, nil
|
||||
}
|
||||
return svc.Bangumi.Calendar(ctx)
|
||||
default:
|
||||
return ""
|
||||
return []service.ExternalMediaResult{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,11 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
@@ -38,72 +34,11 @@ func resolvePTDownloadURL(ctx context.Context, svc *service.Container, raw strin
|
||||
if raw == "" || svc == nil || svc.Site == nil {
|
||||
return raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return raw
|
||||
}
|
||||
host := strings.ToLower(u.Host)
|
||||
|
||||
// 找一个 host 匹配的已配置站点。
|
||||
sites, err := svc.Site.List(ctx)
|
||||
if err != nil || len(sites) == 0 {
|
||||
return raw
|
||||
}
|
||||
var matched *model.Site
|
||||
for i := range sites {
|
||||
su, err := url.Parse(sites[i].URL)
|
||||
if err != nil || su.Host == "" {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(su.Host, host) || strings.HasSuffix(host, "."+strings.ToLower(su.Host)) {
|
||||
matched = &sites[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
// 抽取 torrent id:?id=xxx 是 PT 站普遍写法。
|
||||
id := u.Query().Get("id")
|
||||
if id == "" {
|
||||
return raw
|
||||
}
|
||||
|
||||
adapter := service.GetAdapterForType(matched.Type)
|
||||
if adapter == nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
cfg := service.SiteConfig{
|
||||
Name: matched.Name,
|
||||
Type: matched.Type,
|
||||
URL: strings.TrimRight(matched.URL, "/"),
|
||||
AuthType: matched.AuthType,
|
||||
Cookie: matched.Cookie,
|
||||
APIKey: matched.APIKey,
|
||||
AuthHeader: matched.AuthHeader,
|
||||
UserAgent: matched.UserAgent,
|
||||
Timeout: time.Duration(matched.Timeout) * time.Second,
|
||||
UseProxy: matched.UseProxy,
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 15 * time.Second
|
||||
}
|
||||
|
||||
resolveCtx, cancel := context.WithTimeout(ctx, cfg.Timeout)
|
||||
defer cancel()
|
||||
resolved, err := adapter.GetDownloadURL(resolveCtx, cfg, id)
|
||||
if err != nil || resolved == "" {
|
||||
log.Warn("resolve PT download URL failed (using raw URL)",
|
||||
zap.String("site", matched.Name),
|
||||
zap.String("type", matched.Type),
|
||||
zap.String("raw", raw),
|
||||
zap.Error(err))
|
||||
resolved := svc.Site.ResolveDownloadURL(ctx, raw)
|
||||
if resolved == raw {
|
||||
return raw
|
||||
}
|
||||
log.Info("resolved PT download URL",
|
||||
zap.String("site", matched.Name),
|
||||
zap.String("from", raw),
|
||||
zap.String("to", resolved))
|
||||
return resolved
|
||||
|
||||
@@ -9,6 +9,18 @@ import (
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func listDuplicatesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
libraryID := c.Query("library_id")
|
||||
report, err := svc.Duplicate.Current(c.Request.Context(), libraryID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, report)
|
||||
}
|
||||
}
|
||||
|
||||
func detectDuplicatesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
libraryID := c.Query("library_id")
|
||||
|
||||
+153
-24
@@ -6,6 +6,8 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -36,20 +38,55 @@ func embyUserID(c *gin.Context) string {
|
||||
|
||||
func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, svc.Emby.SystemInfo())
|
||||
c.JSON(http.StatusOK, embyWithRequestAddress(c, svc.Emby.SystemInfo()))
|
||||
}
|
||||
}
|
||||
|
||||
func embySystemInfoPublicHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, svc.Emby.SystemInfoPublic())
|
||||
c.JSON(http.StatusOK, embyWithRequestAddress(c, svc.Emby.SystemInfoPublic()))
|
||||
}
|
||||
}
|
||||
|
||||
func embyRequestBaseURL(c *gin.Context) string {
|
||||
proto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto"))
|
||||
if proto == "" {
|
||||
if c.Request != nil && c.Request.TLS != nil {
|
||||
proto = "https"
|
||||
} else {
|
||||
proto = "http"
|
||||
}
|
||||
}
|
||||
if comma := strings.Index(proto, ","); comma >= 0 {
|
||||
proto = strings.TrimSpace(proto[:comma])
|
||||
}
|
||||
|
||||
host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host"))
|
||||
if host == "" && c.Request != nil {
|
||||
host = strings.TrimSpace(c.Request.Host)
|
||||
}
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(proto+"://"+host, "/")
|
||||
}
|
||||
|
||||
func embyWithRequestAddress(c *gin.Context, payload map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(payload)+2)
|
||||
for key, value := range payload {
|
||||
out[key] = value
|
||||
}
|
||||
if address := embyRequestBaseURL(c); address != "" {
|
||||
out["LocalAddress"] = address
|
||||
out["WanAddress"] = address
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func embySystemEndpointHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"IsLocal": true,
|
||||
"IsLocal": true,
|
||||
"IsInNetwork": true,
|
||||
})
|
||||
}
|
||||
@@ -70,14 +107,85 @@ type embyAuthByNameReq struct {
|
||||
Password string `json:"Password"`
|
||||
}
|
||||
|
||||
func parseEmbyAuthByNameReq(c *gin.Context) (embyAuthByNameReq, error) {
|
||||
req := embyAuthByNameReq{}
|
||||
if strings.Contains(strings.ToLower(c.GetHeader("Content-Type")), "json") {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||
return req, err
|
||||
}
|
||||
req.Username = firstStringFromMap(body, "Username", "username", "Name", "name")
|
||||
req.Pw = firstStringFromMap(body, "Pw", "pw")
|
||||
req.Password = firstStringFromMap(body, "Password", "password")
|
||||
}
|
||||
|
||||
if req.Username == "" || (req.Pw == "" && req.Password == "") {
|
||||
_ = c.Request.ParseForm()
|
||||
if req.Username == "" {
|
||||
req.Username = firstFormValue(c, "Username", "username", "Name", "name")
|
||||
}
|
||||
if req.Pw == "" {
|
||||
req.Pw = firstFormValue(c, "Pw", "pw")
|
||||
}
|
||||
if req.Password == "" {
|
||||
req.Password = firstFormValue(c, "Password", "password")
|
||||
}
|
||||
}
|
||||
|
||||
if req.Username == "" {
|
||||
req.Username = firstQueryValue(c, "Username", "username", "Name", "name")
|
||||
}
|
||||
if req.Pw == "" {
|
||||
req.Pw = firstQueryValue(c, "Pw", "pw")
|
||||
}
|
||||
if req.Password == "" {
|
||||
req.Password = firstQueryValue(c, "Password", "password")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func firstStringFromMap(body map[string]any, keys ...string) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, key := range keys {
|
||||
if value, ok := body[key]; ok {
|
||||
if s, ok := value.(string); ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstFormValue(c *gin.Context, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if values, ok := c.Request.PostForm[key]; ok && len(values) > 0 {
|
||||
if value := strings.TrimSpace(values[0]); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstQueryValue(c *gin.Context, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(c.Query(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// embyAuthByNameHandler 处理 POST /Users/AuthenticateByName。
|
||||
//
|
||||
// 这是 Emby 客户端登录的唯一入口(Infuse / Yamby / Hills 等都走这里)。
|
||||
// 用户名+密码 → 调用我们已有的 AuthService.Login → 返回 AccessToken + User。
|
||||
func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req embyAuthByNameReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
req, err := parseEmbyAuthByNameReq(c)
|
||||
if err != nil {
|
||||
embyError(c, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
@@ -85,6 +193,10 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if password == "" {
|
||||
password = req.Password
|
||||
}
|
||||
if strings.TrimSpace(req.Username) == "" || password == "" {
|
||||
embyError(c, http.StatusBadRequest, "missing username or password")
|
||||
return
|
||||
}
|
||||
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password)
|
||||
if err != nil {
|
||||
embyError(c, http.StatusUnauthorized, err.Error())
|
||||
@@ -96,11 +208,11 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
|
||||
"ServerId": "mediastation-go-001",
|
||||
"User": userPayload,
|
||||
"SessionInfo": gin.H{
|
||||
"Id": resp.User.ID,
|
||||
"UserId": resp.User.ID,
|
||||
"UserName": resp.User.Username,
|
||||
"Client": c.GetHeader("X-Emby-Client"),
|
||||
"DeviceId": c.GetHeader("X-Emby-Device-Id"),
|
||||
"Id": resp.User.ID,
|
||||
"UserId": resp.User.ID,
|
||||
"UserName": resp.User.Username,
|
||||
"Client": c.GetHeader("X-Emby-Client"),
|
||||
"DeviceId": c.GetHeader("X-Emby-Device-Id"),
|
||||
"DeviceName": c.GetHeader("X-Emby-Device-Name"),
|
||||
},
|
||||
})
|
||||
@@ -438,8 +550,8 @@ func embySessionsHandler(_ *service.Container) gin.HandlerFunc {
|
||||
func embyBrandingConfigHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"LoginDisclaimer": "",
|
||||
"CustomCss": "",
|
||||
"LoginDisclaimer": "",
|
||||
"CustomCss": "",
|
||||
"SplashscreenEnabled": false,
|
||||
})
|
||||
}
|
||||
@@ -460,14 +572,34 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container)
|
||||
grp := r.Group(prefix)
|
||||
|
||||
// 公开端点
|
||||
grp.GET("/System/Info/Public", embySystemInfoPublicHandler(svc))
|
||||
grp.GET("/System/Ping", embyPingHandler(svc))
|
||||
grp.HEAD("/System/Ping", embyPingHandler(svc))
|
||||
grp.POST("/System/Ping", embyPingHandler(svc))
|
||||
grp.POST("/Users/AuthenticateByName", embyAuthByNameHandler(svc))
|
||||
grp.GET("/Users/Public", embyPublicUsersHandler(svc))
|
||||
grp.GET("/Branding/Configuration", embyBrandingConfigHandler(svc))
|
||||
grp.GET("/Localization/Options", embyLocalizationOptionsHandler(svc))
|
||||
for _, path := range []string{"/System/Info/Public", "/system/info/public"} {
|
||||
grp.GET(path, embySystemInfoPublicHandler(svc))
|
||||
grp.HEAD(path, embySystemInfoPublicHandler(svc))
|
||||
}
|
||||
for _, path := range []string{"/System/Info", "/system/info"} {
|
||||
grp.GET(path, embySystemInfoHandler(svc))
|
||||
grp.HEAD(path, embySystemInfoHandler(svc))
|
||||
}
|
||||
for _, path := range []string{"/System/Endpoint", "/system/endpoint"} {
|
||||
grp.GET(path, embySystemEndpointHandler(svc))
|
||||
}
|
||||
for _, path := range []string{"/System/Ping", "/system/ping"} {
|
||||
grp.GET(path, embyPingHandler(svc))
|
||||
grp.HEAD(path, embyPingHandler(svc))
|
||||
grp.POST(path, embyPingHandler(svc))
|
||||
}
|
||||
for _, path := range []string{"/Users/AuthenticateByName", "/users/authenticatebyname"} {
|
||||
grp.POST(path, embyAuthByNameHandler(svc))
|
||||
}
|
||||
for _, path := range []string{"/Users/Public", "/users/public"} {
|
||||
grp.GET(path, embyPublicUsersHandler(svc))
|
||||
}
|
||||
for _, path := range []string{"/Branding/Configuration", "/branding/configuration"} {
|
||||
grp.GET(path, embyBrandingConfigHandler(svc))
|
||||
}
|
||||
for _, path := range []string{"/Localization/Options", "/localization/options"} {
|
||||
grp.GET(path, embyLocalizationOptionsHandler(svc))
|
||||
}
|
||||
|
||||
// 图片公开(Infuse 缓存 URL 时会丢 token)
|
||||
grp.GET("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||
@@ -475,10 +607,7 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container)
|
||||
grp.HEAD("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||
|
||||
// 鉴权后端点
|
||||
auth := grp.Group("", middleware.AuthRequired(jwtSecret))
|
||||
auth.GET("/System/Info", embySystemInfoHandler(svc))
|
||||
auth.GET("/System/Endpoint", embySystemEndpointHandler(svc))
|
||||
|
||||
auth := grp.Group("", middleware.EmbyAuthRequired(jwtSecret))
|
||||
auth.GET("/Users/Me", embyMeHandler(svc))
|
||||
auth.GET("/Users", embyListUsersHandler(svc))
|
||||
auth.GET("/Users/:userId", embyGetUserByIDHandler(svc))
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestParseEmbyAuthByNameReqAcceptsLowercaseJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/Users/AuthenticateByName", strings.NewReader(`{"username":"alice","password":"secret"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
req, err := parseEmbyAuthByNameReq(c)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEmbyAuthByNameReq returned error: %v", err)
|
||||
}
|
||||
if req.Username != "alice" || req.Password != "secret" {
|
||||
t.Fatalf("unexpected request: %#v", req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmbyAuthByNameReqAcceptsFormBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/Users/AuthenticateByName", strings.NewReader("Username=bob&Pw=secret"))
|
||||
c.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
req, err := parseEmbyAuthByNameReq(c)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEmbyAuthByNameReq returned error: %v", err)
|
||||
}
|
||||
if req.Username != "bob" || req.Pw != "secret" {
|
||||
t.Fatalf("unexpected request: %#v", req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyWithRequestAddressUsesHost(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "http://192.168.1.4:18080/System/Info/Public", nil)
|
||||
|
||||
payload := embyWithRequestAddress(c, map[string]any{"Id": "mediastation-go-001"})
|
||||
|
||||
if payload["LocalAddress"] != "http://192.168.1.4:18080" {
|
||||
t.Fatalf("unexpected LocalAddress: %#v", payload["LocalAddress"])
|
||||
}
|
||||
if payload["WanAddress"] != "http://192.168.1.4:18080" {
|
||||
t.Fatalf("unexpected WanAddress: %#v", payload["WanAddress"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyWithRequestAddressHonorsForwardedHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "http://127.0.0.1/System/Info/Public", nil)
|
||||
c.Request.Header.Set("X-Forwarded-Proto", "https")
|
||||
c.Request.Header.Set("X-Forwarded-Host", "media.example.test")
|
||||
|
||||
payload := embyWithRequestAddress(c, map[string]any{"Id": "mediastation-go-001"})
|
||||
|
||||
if payload["LocalAddress"] != "https://media.example.test" {
|
||||
t.Fatalf("unexpected LocalAddress: %#v", payload["LocalAddress"])
|
||||
}
|
||||
}
|
||||
+106
-105
@@ -134,135 +134,136 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
authed.POST("/strm/import", middleware.AdminRequired(), importSTRMHandler(svc))
|
||||
|
||||
// Duplicate finder.
|
||||
authed.GET("/duplicates", middleware.AdminRequired(), listDuplicatesHandler(svc))
|
||||
authed.POST("/duplicates/scan", middleware.AdminRequired(), detectDuplicatesHandler(svc))
|
||||
authed.POST("/duplicates/unmark", middleware.AdminRequired(), unmarkDuplicatesHandler(svc))
|
||||
|
||||
// Site management + cross-site torrent search (via SiteHandler).
|
||||
siteHandler := NewSiteHandler(svc)
|
||||
authed.GET("/sites", siteHandler.ListSites)
|
||||
authed.GET("/sites/types", siteHandler.GetSiteTypes)
|
||||
authed.GET("/sites/auth-types", siteHandler.GetAuthTypes)
|
||||
authed.POST("/sites", middleware.AdminRequired(), siteHandler.CreateSite)
|
||||
authed.GET("/sites/:id", siteHandler.GetSite)
|
||||
authed.PUT("/sites/:id", middleware.AdminRequired(), siteHandler.UpdateSite)
|
||||
authed.DELETE("/sites/:id", middleware.AdminRequired(), siteHandler.DeleteSite)
|
||||
authed.POST("/sites/:id/test", middleware.AdminRequired(), siteHandler.TestSite)
|
||||
authed.GET("/sites/search", siteSearchHandler(svc))
|
||||
// Site management + cross-site torrent search (via SiteHandler).
|
||||
siteHandler := NewSiteHandler(svc)
|
||||
authed.GET("/sites", siteHandler.ListSites)
|
||||
authed.GET("/sites/types", siteHandler.GetSiteTypes)
|
||||
authed.GET("/sites/auth-types", siteHandler.GetAuthTypes)
|
||||
authed.POST("/sites", middleware.AdminRequired(), siteHandler.CreateSite)
|
||||
authed.GET("/sites/:id", siteHandler.GetSite)
|
||||
authed.PUT("/sites/:id", middleware.AdminRequired(), siteHandler.UpdateSite)
|
||||
authed.DELETE("/sites/:id", middleware.AdminRequired(), siteHandler.DeleteSite)
|
||||
authed.POST("/sites/:id/test", middleware.AdminRequired(), siteHandler.TestSite)
|
||||
authed.GET("/sites/search", siteSearchHandler(svc))
|
||||
|
||||
// Recycle bin.
|
||||
authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
|
||||
// Recycle bin.
|
||||
authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
|
||||
|
||||
authed.GET("/ws", wsHandler(svc))
|
||||
authed.GET("/ws", wsHandler(svc))
|
||||
|
||||
// SSE event stream.
|
||||
authed.GET("/events", sseHandler(svc))
|
||||
// SSE event stream.
|
||||
authed.GET("/events", sseHandler(svc))
|
||||
|
||||
// Scheduler.
|
||||
authed.GET("/scheduler/tasks", schedulerListTasksHandler(svc))
|
||||
authed.POST("/scheduler/tasks/:id/run", middleware.AdminRequired(), schedulerRunTaskHandler(svc))
|
||||
authed.GET("/scheduler/status", schedulerGetStatusHandler(svc))
|
||||
// Scheduler.
|
||||
authed.GET("/scheduler/tasks", schedulerListTasksHandler(svc))
|
||||
authed.POST("/scheduler/tasks/:id/run", middleware.AdminRequired(), schedulerRunTaskHandler(svc))
|
||||
authed.GET("/scheduler/status", schedulerGetStatusHandler(svc))
|
||||
|
||||
// ── Auxiliary endpoints used by the React UI rails ──
|
||||
authed.GET("/media/recent", recentMediaHandler(svc))
|
||||
authed.GET("/media/stats", mediaStatsHandler(svc))
|
||||
// ── Auxiliary endpoints used by the React UI rails ──
|
||||
authed.GET("/media/recent", recentMediaHandler(svc))
|
||||
authed.GET("/media/stats", mediaStatsHandler(svc))
|
||||
|
||||
// Watch history (extra surface beyond /history).
|
||||
authed.GET("/watch-history", historyListHandler(svc))
|
||||
authed.GET("/watch-history/stats", historyStatsHandler(svc))
|
||||
authed.GET("/watch-history/continue", historyContinueHandler(svc))
|
||||
authed.DELETE("/watch-history", historyDeleteHandler(svc))
|
||||
authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc))
|
||||
// Watch history (extra surface beyond /history).
|
||||
authed.GET("/watch-history", historyListHandler(svc))
|
||||
authed.GET("/watch-history/stats", historyStatsHandler(svc))
|
||||
authed.GET("/watch-history/continue", historyContinueHandler(svc))
|
||||
authed.DELETE("/watch-history", historyDeleteHandler(svc))
|
||||
authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc))
|
||||
|
||||
// Multi-section TMDb feed used by DiscoverPage.
|
||||
authed.GET("/discover/sections", discoverSectionsHandler(svc))
|
||||
authed.GET("/discover/feed", discoverFeedHandler(svc))
|
||||
// Multi-section TMDb feed used by DiscoverPage.
|
||||
authed.GET("/discover/sections", discoverSectionsHandler(svc))
|
||||
authed.GET("/discover/feed", discoverFeedHandler(svc))
|
||||
|
||||
// System metadata + read-only scheduler view.
|
||||
authed.GET("/system/info", systemInfoHandler(svc))
|
||||
authed.GET("/system/status", systemStatusHandler(svc))
|
||||
authed.GET("/system/scheduler", systemSchedulerHandler(svc))
|
||||
// System metadata + read-only scheduler view.
|
||||
authed.GET("/system/info", systemInfoHandler(svc))
|
||||
authed.GET("/system/status", systemStatusHandler(svc))
|
||||
authed.GET("/system/scheduler", systemSchedulerHandler(svc))
|
||||
|
||||
// Richer dashboard rails.
|
||||
authed.GET("/stats/overview", statsOverviewHandler(svc))
|
||||
authed.GET("/stats/trend", statsTrendHandler(svc))
|
||||
authed.GET("/stats/top-content", statsTopContentHandler(svc))
|
||||
authed.GET("/stats/libraries", statsLibrariesHandler(svc))
|
||||
authed.GET("/stats/monitor", statsMonitorHandler(svc))
|
||||
// Richer dashboard rails.
|
||||
authed.GET("/stats/overview", statsOverviewHandler(svc))
|
||||
authed.GET("/stats/trend", statsTrendHandler(svc))
|
||||
authed.GET("/stats/top-content", statsTopContentHandler(svc))
|
||||
authed.GET("/stats/libraries", statsLibrariesHandler(svc))
|
||||
authed.GET("/stats/monitor", statsMonitorHandler(svc))
|
||||
|
||||
// Multi-persona play profiles (caller-scoped, admins via ?all=true).
|
||||
authed.GET("/play-profiles", listPlayProfilesHandler(svc))
|
||||
authed.POST("/play-profiles", createPlayProfileHandler(svc))
|
||||
authed.PUT("/play-profiles/:id", updatePlayProfileHandler(svc))
|
||||
authed.DELETE("/play-profiles/:id", deletePlayProfileHandler(svc))
|
||||
// Multi-persona play profiles (caller-scoped, admins via ?all=true).
|
||||
authed.GET("/play-profiles", listPlayProfilesHandler(svc))
|
||||
authed.POST("/play-profiles", createPlayProfileHandler(svc))
|
||||
authed.PUT("/play-profiles/:id", updatePlayProfileHandler(svc))
|
||||
authed.DELETE("/play-profiles/:id", deletePlayProfileHandler(svc))
|
||||
|
||||
// ── Search aliases ──
|
||||
authed.GET("/search", searchUnifiedHandler(svc))
|
||||
authed.GET("/search/advanced", searchAdvancedHandler(svc))
|
||||
authed.GET("/search/tmdb", searchTMDbHandler(svc))
|
||||
authed.GET("/search/sites", searchSitesHandler(svc))
|
||||
// ── Search aliases ──
|
||||
authed.GET("/search", searchUnifiedHandler(svc))
|
||||
authed.GET("/search/advanced", searchAdvancedHandler(svc))
|
||||
authed.GET("/search/tmdb", searchTMDbHandler(svc))
|
||||
authed.GET("/search/sites", searchSitesHandler(svc))
|
||||
|
||||
// ── System extras ──
|
||||
authed.GET("/system/config", listSystemConfigHandler(svc))
|
||||
authed.GET("/settings/schema", schemaHandler(svc))
|
||||
authed.GET("/system/events/ticket", systemEventsTicketHandler(svc))
|
||||
// ── System extras ──
|
||||
authed.GET("/system/config", listSystemConfigHandler(svc))
|
||||
authed.GET("/settings/schema", schemaHandler(svc))
|
||||
authed.GET("/system/events/ticket", systemEventsTicketHandler(svc))
|
||||
|
||||
// ── Per-user stats ──
|
||||
authed.GET("/stats/user/:id", statsUserHandler(svc))
|
||||
authed.GET("/stats/top-users", statsTopUsersHandler(svc))
|
||||
authed.POST("/stats/play", statsPlayHandler(svc))
|
||||
// ── Per-user stats ──
|
||||
authed.GET("/stats/user/:id", statsUserHandler(svc))
|
||||
authed.GET("/stats/top-users", statsTopUsersHandler(svc))
|
||||
authed.POST("/stats/play", statsPlayHandler(svc))
|
||||
|
||||
// ── Sites extras ──
|
||||
authed.GET("/sites/:id/resource", siteResourceHandler(svc))
|
||||
authed.GET("/sites/:id/userdata", siteUserdataHandler(svc))
|
||||
// ── Sites extras ──
|
||||
authed.GET("/sites/:id/resource", siteResourceHandler(svc))
|
||||
authed.GET("/sites/:id/userdata", siteUserdataHandler(svc))
|
||||
|
||||
// ── Subscription extras ──
|
||||
authed.PUT("/subscriptions/:id", updateSubscriptionHandler(svc))
|
||||
authed.POST("/subscriptions/:id/search", searchSubscriptionHandler(svc))
|
||||
// ── Subscription extras ──
|
||||
authed.PUT("/subscriptions/:id", updateSubscriptionHandler(svc))
|
||||
authed.POST("/subscriptions/:id/search", searchSubscriptionHandler(svc))
|
||||
|
||||
// ── Playlist extras ──
|
||||
authed.POST("/playlists/:id/reorder", reorderPlaylistHandler(svc))
|
||||
authed.DELETE("/playlists/:id/items/by-id/:item_id", deletePlaylistItemByIDHandler(svc))
|
||||
// ── Playlist extras ──
|
||||
authed.POST("/playlists/:id/reorder", reorderPlaylistHandler(svc))
|
||||
authed.DELETE("/playlists/:id/items/by-id/:item_id", deletePlaylistItemByIDHandler(svc))
|
||||
|
||||
// ── DLNA per-renderer control ──
|
||||
authed.POST("/dlna/:uuid/play", dlnaPlayHandler(svc))
|
||||
authed.POST("/dlna/:uuid/pause", dlnaPauseHandler(svc))
|
||||
authed.POST("/dlna/:uuid/stop", dlnaStopHandler(svc))
|
||||
authed.GET("/dlna/:uuid/status", dlnaStatusHandler(svc))
|
||||
// ── DLNA per-renderer control ──
|
||||
authed.POST("/dlna/:uuid/play", dlnaPlayHandler(svc))
|
||||
authed.POST("/dlna/:uuid/pause", dlnaPauseHandler(svc))
|
||||
authed.POST("/dlna/:uuid/stop", dlnaStopHandler(svc))
|
||||
authed.GET("/dlna/:uuid/status", dlnaStatusHandler(svc))
|
||||
|
||||
// ── Media favourite alias surface ──
|
||||
authed.GET("/favorites", listFavoritesAliasHandler(svc))
|
||||
authed.POST("/media/:id/favorite", addMediaFavoriteHandler(svc))
|
||||
authed.DELETE("/media/:id/favorite", removeMediaFavoriteHandler(svc))
|
||||
authed.GET("/media/:id/favorite/status", getMediaFavoriteStatusHandler(svc))
|
||||
authed.POST("/media/:id/ai-scrape", aiScrapeMediaHandler(svc))
|
||||
authed.POST("/media/scrape/test", scrapeTestHandler(svc))
|
||||
authed.POST("/media/organize", middleware.AdminRequired(), organizeBulkHandler(svc))
|
||||
// ── Media favourite alias surface ──
|
||||
authed.GET("/favorites", listFavoritesAliasHandler(svc))
|
||||
authed.POST("/media/:id/favorite", addMediaFavoriteHandler(svc))
|
||||
authed.DELETE("/media/:id/favorite", removeMediaFavoriteHandler(svc))
|
||||
authed.GET("/media/:id/favorite/status", getMediaFavoriteStatusHandler(svc))
|
||||
authed.POST("/media/:id/ai-scrape", aiScrapeMediaHandler(svc))
|
||||
authed.POST("/media/scrape/test", scrapeTestHandler(svc))
|
||||
authed.POST("/media/organize", middleware.AdminRequired(), organizeBulkHandler(svc))
|
||||
|
||||
// ── Playback metadata + external player handoff ──
|
||||
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
|
||||
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
|
||||
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
|
||||
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
|
||||
authed.GET("/playback/transcode/:job_id/status", transcodeStatusHandler(svc))
|
||||
// ── Playback metadata + external player handoff ──
|
||||
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
|
||||
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
|
||||
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
|
||||
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
|
||||
authed.GET("/playback/transcode/:job_id/status", transcodeStatusHandler(svc))
|
||||
|
||||
// ── Download task ops + sync triggers ──
|
||||
authed.POST("/download/:id/pause", downloadPauseHandler(svc))
|
||||
authed.POST("/download/:id/resume", downloadResumeHandler(svc))
|
||||
authed.POST("/download/:id/organize", middleware.AdminRequired(), downloadOrganizeOneHandler(svc))
|
||||
authed.POST("/download/organize", middleware.AdminRequired(), downloadOrganizeAllHandler(svc))
|
||||
authed.POST("/download/sync", middleware.AdminRequired(), downloadSyncHandler(svc))
|
||||
authed.POST("/download/start-auto-sync", middleware.AdminRequired(), downloadAutoSyncHandler(svc))
|
||||
authed.GET("/download/tasks", downloadTasksAliasHandler(svc))
|
||||
// ── Download task ops + sync triggers ──
|
||||
authed.POST("/download/:id/pause", downloadPauseHandler(svc))
|
||||
authed.POST("/download/:id/resume", downloadResumeHandler(svc))
|
||||
authed.POST("/download/:id/organize", middleware.AdminRequired(), downloadOrganizeOneHandler(svc))
|
||||
authed.POST("/download/organize", middleware.AdminRequired(), downloadOrganizeAllHandler(svc))
|
||||
authed.POST("/download/sync", middleware.AdminRequired(), downloadSyncHandler(svc))
|
||||
authed.POST("/download/start-auto-sync", middleware.AdminRequired(), downloadAutoSyncHandler(svc))
|
||||
authed.GET("/download/tasks", downloadTasksAliasHandler(svc))
|
||||
|
||||
// ── Assistant (multi-turn AI chat) ──
|
||||
authed.GET("/admin/assistant/sessions", listAssistantSessionsHandler(svc))
|
||||
authed.POST("/admin/assistant/sessions", createAssistantSessionHandler(svc))
|
||||
authed.GET("/admin/assistant/session/:id", getAssistantSessionHandler(svc))
|
||||
authed.DELETE("/admin/assistant/session/:id", deleteAssistantSessionHandler(svc))
|
||||
authed.POST("/admin/assistant/chat", assistantChatHandler(svc))
|
||||
authed.POST("/admin/assistant/execute", assistantExecuteHandler(svc))
|
||||
authed.POST("/admin/assistant/undo/:op_id", assistantUndoHandler(svc))
|
||||
authed.GET("/admin/assistant/history", assistantHistoryHandler(svc))
|
||||
// ── Assistant (multi-turn AI chat) ──
|
||||
authed.GET("/admin/assistant/sessions", listAssistantSessionsHandler(svc))
|
||||
authed.POST("/admin/assistant/sessions", createAssistantSessionHandler(svc))
|
||||
authed.GET("/admin/assistant/session/:id", getAssistantSessionHandler(svc))
|
||||
authed.DELETE("/admin/assistant/session/:id", deleteAssistantSessionHandler(svc))
|
||||
authed.POST("/admin/assistant/chat", assistantChatHandler(svc))
|
||||
authed.POST("/admin/assistant/execute", assistantExecuteHandler(svc))
|
||||
authed.POST("/admin/assistant/undo/:op_id", assistantUndoHandler(svc))
|
||||
authed.GET("/admin/assistant/history", assistantHistoryHandler(svc))
|
||||
}
|
||||
|
||||
// Admin-only endpoints.
|
||||
|
||||
@@ -55,7 +55,7 @@ func imageProxyHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// scrapeOneHandler enriches a single media via TMDb. Admin-only.
|
||||
// scrapeOneHandler enriches a single media via the configured scraper chain.
|
||||
func scrapeOneHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
||||
@@ -63,10 +63,6 @@ func scrapeOneHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if !svc.TMDb.Enabled() {
|
||||
c.JSON(http.StatusPreconditionFailed, gin.H{"error": "tmdb api key not configured"})
|
||||
return
|
||||
}
|
||||
if err := svc.Scraper.EnrichOne(c.Request.Context(), m); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -76,17 +72,13 @@ func scrapeOneHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// scrapeLibraryHandler enriches every pending media in a library. Admin-only.
|
||||
// scrapeLibraryHandler retries every pending/no_match media in a library.
|
||||
func scrapeLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !svc.TMDb.Enabled() {
|
||||
c.JSON(http.StatusPreconditionFailed, gin.H{"error": "tmdb api key not configured"})
|
||||
return
|
||||
}
|
||||
// Run in the background so HTTP returns instantly; the WS hub
|
||||
// pushes per-item progress on the "scrape" topic.
|
||||
go func(libID string) {
|
||||
_, _ = svc.Scraper.EnrichLibrary(context.Background(), libID)
|
||||
_, _ = svc.Scraper.EnrichLibrary(context.Background(), libID, true)
|
||||
}(c.Param("id"))
|
||||
c.JSON(http.StatusAccepted, gin.H{"status": "scraping"})
|
||||
}
|
||||
|
||||
@@ -22,10 +22,13 @@ func updateSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
Model(&model.Subscription{}).
|
||||
Where("id = ?", c.Param("id")).
|
||||
Updates(map[string]any{
|
||||
"name": patch.Name,
|
||||
"feed_url": patch.FeedURL,
|
||||
"filter": patch.Filter,
|
||||
"enabled": patch.Enabled,
|
||||
"name": patch.Name,
|
||||
"feed_url": patch.FeedURL,
|
||||
"filter": patch.Filter,
|
||||
"media_type": patch.MediaType,
|
||||
"media_category": patch.MediaCategory,
|
||||
"save_path": patch.SavePath,
|
||||
"enabled": patch.Enabled,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -12,10 +12,13 @@ import (
|
||||
)
|
||||
|
||||
type subscriptionReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
FeedURL string `json:"feed_url" binding:"required"`
|
||||
Filter string `json:"filter"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
FeedURL string `json:"feed_url" binding:"required"`
|
||||
Filter string `json:"filter"`
|
||||
MediaType string `json:"media_type"`
|
||||
MediaCategory string `json:"media_category"`
|
||||
SavePath string `json:"save_path"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
@@ -31,11 +34,14 @@ func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
s := &model.Subscription{
|
||||
UserID: uid.(string),
|
||||
Name: req.Name,
|
||||
FeedURL: req.FeedURL,
|
||||
Filter: req.Filter,
|
||||
Enabled: enabled,
|
||||
UserID: uid.(string),
|
||||
Name: req.Name,
|
||||
FeedURL: req.FeedURL,
|
||||
Filter: req.Filter,
|
||||
MediaType: req.MediaType,
|
||||
MediaCategory: req.MediaCategory,
|
||||
SavePath: req.SavePath,
|
||||
Enabled: enabled,
|
||||
}
|
||||
if err := svc.Subscription.Create(c.Request.Context(), s); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -16,6 +16,9 @@ func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if tracks == nil {
|
||||
tracks = []service.SubtitleTrack{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"tracks": tracks})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// Package middleware — Emby API 兼容层认证中间件。
|
||||
// 支持 X-Emby-Token / Bearer / URL token / Username+Password 四种认证方式。
|
||||
// 支持 X-Emby-Token / X-MediaBrowser-Token / Bearer / MediaBrowser / URL token。
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -15,41 +16,13 @@ const EmbyCtxUserID = "emby_user_id"
|
||||
|
||||
// EmbyAuthRequired Emby 认证中间件。
|
||||
// 按优先级尝试以下认证方式:
|
||||
// 1. X-Emby-Token 请求头
|
||||
// 2. Authorization: Bearer <token> 请求头
|
||||
// 3. ?token=<token> URL 参数
|
||||
// 4. (仅 AuthenticateByName 端点)POST body 中的 Username+Password
|
||||
// 1. X-Emby-Token / X-MediaBrowser-Token 请求头
|
||||
// 2. Authorization: Bearer <token> / MediaBrowser Token="<token>" 请求头
|
||||
// 3. X-Emby-Authorization: MediaBrowser Token="<token>"
|
||||
// 4. ?token= / ?api_key= / ?apiKey= URL 参数
|
||||
func EmbyAuthRequired(secret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ""
|
||||
|
||||
// 1. X-Emby-Token 头
|
||||
if t := c.GetHeader("X-Emby-Token"); t != "" {
|
||||
token = t
|
||||
}
|
||||
|
||||
// 2. Authorization: Bearer <token> 或 Emby <token>
|
||||
if token == "" {
|
||||
if authHeader := c.GetHeader("Authorization"); authHeader != "" {
|
||||
// Strip "Bearer " or "Emby " prefix
|
||||
for _, prefix := range []string{"Bearer ", "Emby "} {
|
||||
if len(authHeader) > len(prefix) && authHeader[:len(prefix)] == prefix {
|
||||
token = authHeader[len(prefix):]
|
||||
break
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
token = authHeader
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. URL 参数 token
|
||||
if token == "" {
|
||||
if t := c.Query("token"); t != "" {
|
||||
token = t
|
||||
}
|
||||
}
|
||||
token := extractEmbyToken(c)
|
||||
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
@@ -86,6 +59,53 @@ func EmbyAuthRequired(secret string) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func extractEmbyToken(c *gin.Context) string {
|
||||
for _, header := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} {
|
||||
if value := strings.TrimSpace(c.GetHeader(header)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
for _, header := range []string{"Authorization", "X-Emby-Authorization"} {
|
||||
if value := strings.TrimSpace(c.GetHeader(header)); value != "" {
|
||||
if token := tokenFromAuthHeader(value); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range []string{"token", "api_key", "apiKey", "ApiKey"} {
|
||||
if value := strings.TrimSpace(c.Query(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func tokenFromAuthHeader(value string) string {
|
||||
for _, prefix := range []string{"Bearer ", "Emby "} {
|
||||
if strings.HasPrefix(value, prefix) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(value, prefix))
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(value, "MediaBrowser ") || strings.Contains(value, "Token=") {
|
||||
return tokenFromMediaBrowserAuth(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func tokenFromMediaBrowserAuth(value string) string {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(part), "MediaBrowser "))
|
||||
if !strings.HasPrefix(part, "Token=") {
|
||||
continue
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(part, "Token="))
|
||||
return strings.Trim(token, `"`)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetEmbyUserID 从上下文中获取 Emby 用户 ID。
|
||||
func GetEmbyUserID(c *gin.Context) string {
|
||||
if uid, exists := c.Get(EmbyCtxUserID); exists {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func TestEmbyAuthRequiredAcceptsEmbyClientTokenFormats(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const secret = "test-secret"
|
||||
token := signedTestToken(t, secret)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
headerKey string
|
||||
headerVal string
|
||||
query string
|
||||
}{
|
||||
{name: "x emby token", headerKey: "X-Emby-Token", headerVal: token},
|
||||
{name: "x mediabrowser token", headerKey: "X-MediaBrowser-Token", headerVal: token},
|
||||
{name: "authorization mediabrowser token", headerKey: "Authorization", headerVal: `MediaBrowser Client="Infuse", Token="` + token + `"`},
|
||||
{name: "x emby authorization", headerKey: "X-Emby-Authorization", headerVal: `MediaBrowser Client="VidHub", Token="` + token + `"`},
|
||||
{name: "query api key", query: "?api_key=" + token},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.GET("/Users/Me", EmbyAuthRequired(secret), func(c *gin.Context) {
|
||||
c.String(http.StatusOK, GetUserID(c))
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodGet, "/Users/Me"+tt.query, nil)
|
||||
if tt.headerKey != "" {
|
||||
req.Header.Set(tt.headerKey, tt.headerVal)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Body.String(); got != "user-1" {
|
||||
t.Fatalf("expected user id, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func signedTestToken(t *testing.T, secret string) string {
|
||||
t.Helper()
|
||||
raw := jwt.NewWithClaims(jwt.SigningMethodHS256, &Claims{
|
||||
UserID: "user-1",
|
||||
Role: "admin",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
},
|
||||
})
|
||||
token, err := raw.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign token: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func CORS(origins []string) gin.HandlerFunc {
|
||||
c.Header("Vary", "Origin")
|
||||
}
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With")
|
||||
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With, X-Emby-Token, X-MediaBrowser-Token, X-Emby-Authorization")
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
|
||||
@@ -3,38 +3,20 @@ package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ApiConfig 存储第三方 API 密钥和配置信息。
|
||||
// APIKey 字段在 JSON 序列化时隐藏(json:"-"),通过加密存储。
|
||||
type ApiConfig struct {
|
||||
ID string `gorm:"primaryKey;size:36" json:"id"`
|
||||
Provider string `gorm:"size:64;uniqueIndex;not null" json:"provider"`
|
||||
APIKey string `gorm:"size:512" json:"-"`
|
||||
BaseURL string `gorm:"size:512" json:"base_url,omitempty"`
|
||||
Extra string `gorm:"type:text" json:"extra,omitempty"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Description string `gorm:"size:255" json:"description,omitempty"`
|
||||
Base
|
||||
Provider string `gorm:"size:64;uniqueIndex;not null" json:"provider"`
|
||||
APIKey string `gorm:"size:512" json:"-"`
|
||||
BaseURL string `gorm:"size:512" json:"base_url,omitempty"`
|
||||
Extra string `gorm:"type:text" json:"extra,omitempty"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Description string `gorm:"size:255" json:"description,omitempty"`
|
||||
LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
|
||||
TestResult string `gorm:"size:32" json:"test_result,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// BeforeCreate 生成 UUID。
|
||||
func (c *ApiConfig) BeforeCreate(_ *gorm.DB) error {
|
||||
if c.ID == "" {
|
||||
c.ID = uuid.NewString()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate 更新时间戳。
|
||||
func (c *ApiConfig) BeforeUpdate(_ *gorm.DB) error {
|
||||
c.UpdatedAt = time.Now()
|
||||
return nil
|
||||
TestResult string `gorm:"size:32" json:"test_result,omitempty"`
|
||||
}
|
||||
|
||||
// ApiProvider 定义支持的 API 提供者列表。
|
||||
@@ -57,6 +39,6 @@ func PredefinedProviders() []ApiProvider {
|
||||
{ID: "openai", Name: "OpenAI", Description: "GPT 系列模型", HasAPIKey: true, HasBaseURL: true},
|
||||
{ID: "deepseek", Name: "DeepSeek", Description: "DeepSeek 大模型", HasAPIKey: true, HasBaseURL: true},
|
||||
{ID: "siliconflow", Name: "SiliconFlow", Description: "AI 模型聚合 API", HasAPIKey: true, HasBaseURL: true},
|
||||
{ID: "adult", Name: "Adult API", Description: "Adult 内容元数据(需额外权限)", HasAPIKey: true, HasBaseURL: false},
|
||||
{ID: "adult", Name: "Adult / 番号", Description: "JavDB/JavBus 成人内容元数据", HasAPIKey: false, HasBaseURL: true},
|
||||
}
|
||||
}
|
||||
|
||||
+26
-23
@@ -79,9 +79,9 @@ type Media struct {
|
||||
ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"`
|
||||
TMDbID int `json:"tmdb_id"`
|
||||
BangumiID int `json:"bangumi_id"`
|
||||
Languages string `gorm:"size:64" json:"languages,omitempty"` // 逗号分隔的 ISO 639-1 代码,如 "zh,en"
|
||||
Countries string `gorm:"size:128" json:"countries,omitempty"` // 逗号分隔的 ISO 3166-1,如 "CN,US"
|
||||
Genres string `gorm:"size:255" json:"genres,omitempty"` // 逗号分隔的类型名,如 "Action,Animation"
|
||||
Languages string `gorm:"size:64" json:"languages,omitempty"` // 逗号分隔的 ISO 639-1 代码,如 "zh,en"
|
||||
Countries string `gorm:"size:128" json:"countries,omitempty"` // 逗号分隔的 ISO 3166-1,如 "CN,US"
|
||||
Genres string `gorm:"size:255" json:"genres,omitempty"` // 逗号分隔的类型名,如 "Action,Animation"
|
||||
NSFW bool `gorm:"default:false" json:"nsfw"`
|
||||
|
||||
// STRMURL is the indirection target for .strm files: when present the
|
||||
@@ -113,7 +113,7 @@ type Media struct {
|
||||
type APIConfig struct {
|
||||
Base
|
||||
Provider string `gorm:"uniqueIndex;size:32;not null" json:"provider"`
|
||||
APIKey string `gorm:"type:text" json:"-"` // ciphertext (never serialised)
|
||||
APIKey string `gorm:"type:text" json:"-"` // ciphertext (never serialised)
|
||||
BaseURL string `gorm:"size:512" json:"base_url,omitempty"`
|
||||
Extra string `gorm:"type:text" json:"extra,omitempty"` // free-form JSON
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
@@ -182,12 +182,15 @@ type DownloadTask struct {
|
||||
// Subscription 是自动化规则,轮询 RSS 源并将匹配种子排队到配置的下载客户端。
|
||||
type Subscription struct {
|
||||
Base
|
||||
UserID string `gorm:"index;size:36" json:"user_id"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
FeedURL string `gorm:"size:2048;not null" json:"feed_url"`
|
||||
Filter string `gorm:"size:512" json:"filter"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||
UserID string `gorm:"index;size:36" json:"user_id"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
FeedURL string `gorm:"size:2048;not null" json:"feed_url"`
|
||||
Filter string `gorm:"size:512" json:"filter"`
|
||||
MediaType string `gorm:"size:16" json:"media_type,omitempty"`
|
||||
MediaCategory string `gorm:"size:128" json:"media_category,omitempty"`
|
||||
SavePath string `gorm:"size:1024" json:"save_path,omitempty"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||
}
|
||||
|
||||
// Setting 是单个键/值系统级偏好(供管理 UI 使用)。
|
||||
@@ -216,19 +219,19 @@ type AccessLog struct {
|
||||
// AllowedLibraryIDs is a JSON array of library UUIDs (empty = all).
|
||||
type PlayProfile struct {
|
||||
Base
|
||||
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
||||
Name string `gorm:"size:64;not null" json:"name"`
|
||||
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||
ContentRatingLimit string `gorm:"size:16" json:"content_rating_limit,omitempty"`
|
||||
AllowAdult bool `gorm:"default:false" json:"allow_adult"`
|
||||
RequirePIN bool `gorm:"default:false" json:"require_pin"`
|
||||
PINHash string `gorm:"size:128" json:"-"`
|
||||
PreferredSubtitleLang string `gorm:"size:16" json:"preferred_subtitle_lang,omitempty"`
|
||||
PreferredAudioLang string `gorm:"size:16" json:"preferred_audio_lang,omitempty"`
|
||||
AutoplayNext bool `gorm:"default:true" json:"autoplay_next"`
|
||||
SkipIntro bool `gorm:"default:false" json:"skip_intro"`
|
||||
AllowedLibraryIDs string `gorm:"type:text;default:'[]'" json:"allowed_library_ids"`
|
||||
TotalWatchTime int64 `gorm:"default:0" json:"total_watch_time"`
|
||||
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
||||
Name string `gorm:"size:64;not null" json:"name"`
|
||||
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||
ContentRatingLimit string `gorm:"size:16" json:"content_rating_limit,omitempty"`
|
||||
AllowAdult bool `gorm:"default:false" json:"allow_adult"`
|
||||
RequirePIN bool `gorm:"default:false" json:"require_pin"`
|
||||
PINHash string `gorm:"size:128" json:"-"`
|
||||
PreferredSubtitleLang string `gorm:"size:16" json:"preferred_subtitle_lang,omitempty"`
|
||||
PreferredAudioLang string `gorm:"size:16" json:"preferred_audio_lang,omitempty"`
|
||||
AutoplayNext bool `gorm:"default:true" json:"autoplay_next"`
|
||||
SkipIntro bool `gorm:"default:false" json:"skip_intro"`
|
||||
AllowedLibraryIDs string `gorm:"type:text;default:'[]'" json:"allowed_library_ids"`
|
||||
TotalWatchTime int64 `gorm:"default:0" json:"total_watch_time"`
|
||||
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -19,55 +19,55 @@ import (
|
||||
|
||||
// Container 是所有 repositories 的注册表,注入到 services 中。
|
||||
type Container struct {
|
||||
DB *gorm.DB
|
||||
User *UserRepository
|
||||
Library *LibraryRepository
|
||||
Media *MediaRepository
|
||||
Series *SeriesRepository
|
||||
History *HistoryRepository
|
||||
Favorite *FavoriteRepository
|
||||
Playlist *PlaylistRepository
|
||||
Download *DownloadRepository
|
||||
Subscription *SubscriptionRepository
|
||||
Setting *SettingRepository
|
||||
Log *AccessLogRepository
|
||||
Permission *PermissionRepository
|
||||
RefreshToken *RefreshTokenRepository
|
||||
ApiConfig *ApiConfigRepository
|
||||
DownloadClient *DownloadClientRepository
|
||||
NotifyChannel *NotifyChannelRepository
|
||||
Site *SiteRepository
|
||||
STRM *STRMRepository
|
||||
PlayProfile *PlayProfileRepository
|
||||
StorageConfig *StorageConfigRepository
|
||||
Assistant *AssistantRepository
|
||||
DB *gorm.DB
|
||||
User *UserRepository
|
||||
Library *LibraryRepository
|
||||
Media *MediaRepository
|
||||
Series *SeriesRepository
|
||||
History *HistoryRepository
|
||||
Favorite *FavoriteRepository
|
||||
Playlist *PlaylistRepository
|
||||
Download *DownloadRepository
|
||||
Subscription *SubscriptionRepository
|
||||
Setting *SettingRepository
|
||||
Log *AccessLogRepository
|
||||
Permission *PermissionRepository
|
||||
RefreshToken *RefreshTokenRepository
|
||||
ApiConfig *ApiConfigRepository
|
||||
DownloadClient *DownloadClientRepository
|
||||
NotifyChannel *NotifyChannelRepository
|
||||
Site *SiteRepository
|
||||
STRM *STRMRepository
|
||||
PlayProfile *PlayProfileRepository
|
||||
StorageConfig *StorageConfigRepository
|
||||
Assistant *AssistantRepository
|
||||
}
|
||||
|
||||
// New 将每个 repository 连接到单个 *gorm.DB。
|
||||
func New(db *gorm.DB) *Container {
|
||||
return &Container{
|
||||
DB: db,
|
||||
User: &UserRepository{db: db},
|
||||
Library: &LibraryRepository{db: db},
|
||||
Media: &MediaRepository{db: db},
|
||||
Series: &SeriesRepository{db: db},
|
||||
History: &HistoryRepository{db: db},
|
||||
Favorite: &FavoriteRepository{db: db},
|
||||
Playlist: &PlaylistRepository{db: db},
|
||||
Download: &DownloadRepository{db: db},
|
||||
Subscription: &SubscriptionRepository{db: db},
|
||||
Setting: &SettingRepository{db: db},
|
||||
Log: &AccessLogRepository{db: db},
|
||||
Permission: &PermissionRepository{db: db},
|
||||
RefreshToken: &RefreshTokenRepository{db: db},
|
||||
ApiConfig: &ApiConfigRepository{db: db},
|
||||
DB: db,
|
||||
User: &UserRepository{db: db},
|
||||
Library: &LibraryRepository{db: db},
|
||||
Media: &MediaRepository{db: db},
|
||||
Series: &SeriesRepository{db: db},
|
||||
History: &HistoryRepository{db: db},
|
||||
Favorite: &FavoriteRepository{db: db},
|
||||
Playlist: &PlaylistRepository{db: db},
|
||||
Download: &DownloadRepository{db: db},
|
||||
Subscription: &SubscriptionRepository{db: db},
|
||||
Setting: &SettingRepository{db: db},
|
||||
Log: &AccessLogRepository{db: db},
|
||||
Permission: &PermissionRepository{db: db},
|
||||
RefreshToken: &RefreshTokenRepository{db: db},
|
||||
ApiConfig: &ApiConfigRepository{db: db},
|
||||
DownloadClient: &DownloadClientRepository{db: db},
|
||||
NotifyChannel: &NotifyChannelRepository{db: db},
|
||||
Site: &SiteRepository{db: db},
|
||||
STRM: &STRMRepository{db: db},
|
||||
PlayProfile: &PlayProfileRepository{db: db},
|
||||
StorageConfig: &StorageConfigRepository{db: db},
|
||||
Assistant: &AssistantRepository{db: db},
|
||||
Site: &SiteRepository{db: db},
|
||||
STRM: &STRMRepository{db: db},
|
||||
PlayProfile: &PlayProfileRepository{db: db},
|
||||
StorageConfig: &StorageConfigRepository{db: db},
|
||||
Assistant: &AssistantRepository{db: db},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,13 +188,13 @@ type MediaRepository struct{ db *gorm.DB }
|
||||
// 海报、TMDb/Bangumi ID、scrape_status 等)覆盖回零值。
|
||||
//
|
||||
// 之前用 Assign(*m).FirstOrCreate(m) 会把整张零值结构体写回,导致:
|
||||
// 1. scrape_status 从 'matched' / 'no_match' 被清空成 '';
|
||||
// 2. 新建行使 GORM `default:pending` 也得不到应用(因为 zero value 被
|
||||
// 显式写入)。这两个问题都让 EnrichLibrary(WHERE scrape_status='pending')
|
||||
// 永远捞不到数据。
|
||||
// 1. scrape_status 从 'matched' / 'no_match' 被清空成 ”;
|
||||
// 2. 新建行使 GORM `default:pending` 也得不到应用(因为 zero value 被
|
||||
// 显式写入)。这两个问题都让 EnrichLibrary(WHERE scrape_status='pending')
|
||||
// 永远捞不到数据。
|
||||
func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
|
||||
var existing model.Media
|
||||
err := r.db.WithContext(ctx).Where("path = ?", m.Path).First(&existing).Error
|
||||
err := r.db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 新行:保证 scrape_status 走 GORM default:pending(即留空让数据库填)。
|
||||
if m.ScrapeStatus == "" {
|
||||
@@ -215,18 +215,58 @@ func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
|
||||
"video_codec": m.VideoCodec,
|
||||
"audio_codec": m.AudioCodec,
|
||||
"container": m.Container,
|
||||
"deleted_at": nil,
|
||||
}
|
||||
if m.Title != "" {
|
||||
// scanner 给出的标题只是从路径推导,刮削后 title 已被替换为
|
||||
// 真实剧名。仅在 existing 还停留在 'pending'/'' 时回填扫描标题,
|
||||
// 避免覆盖刮削结果。
|
||||
if existing.ScrapeStatus == "pending" || existing.ScrapeStatus == "" || existing.ScrapeStatus == "no_match" {
|
||||
if m.ScrapeStatus == "matched" || existing.ScrapeStatus == "pending" || existing.ScrapeStatus == "" || existing.ScrapeStatus == "no_match" {
|
||||
updates["title"] = m.Title
|
||||
if m.Year > 0 {
|
||||
updates["year"] = m.Year
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.ScrapeStatus == "matched" {
|
||||
updates["scrape_status"] = m.ScrapeStatus
|
||||
if m.OriginalName != "" {
|
||||
updates["original_name"] = m.OriginalName
|
||||
}
|
||||
if m.PosterURL != "" {
|
||||
updates["poster_url"] = m.PosterURL
|
||||
}
|
||||
if m.BackdropURL != "" {
|
||||
updates["backdrop_url"] = m.BackdropURL
|
||||
}
|
||||
if m.Overview != "" {
|
||||
updates["overview"] = m.Overview
|
||||
}
|
||||
if m.Rating > 0 {
|
||||
updates["rating"] = m.Rating
|
||||
}
|
||||
if m.Year > 0 {
|
||||
updates["year"] = m.Year
|
||||
}
|
||||
if m.TMDbID > 0 {
|
||||
updates["tm_db_id"] = m.TMDbID
|
||||
}
|
||||
if m.BangumiID > 0 {
|
||||
updates["bangumi_id"] = m.BangumiID
|
||||
}
|
||||
if m.Languages != "" {
|
||||
updates["languages"] = m.Languages
|
||||
}
|
||||
if m.Countries != "" {
|
||||
updates["countries"] = m.Countries
|
||||
}
|
||||
if m.Genres != "" {
|
||||
updates["genres"] = m.Genres
|
||||
}
|
||||
if m.NSFW {
|
||||
updates["nsfw"] = true
|
||||
}
|
||||
}
|
||||
if lib := m.LibraryID; lib != "" && lib != existing.LibraryID {
|
||||
updates["library_id"] = m.LibraryID
|
||||
}
|
||||
@@ -237,7 +277,7 @@ func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
|
||||
updates["episode_num"] = m.EpisodeNum
|
||||
}
|
||||
|
||||
if err := r.db.WithContext(ctx).Model(&model.Media{}).
|
||||
if err := r.db.WithContext(ctx).Unscoped().Model(&model.Media{}).
|
||||
Where("id = ?", existing.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -415,7 +455,7 @@ type SubscriptionRepository struct{ db *gorm.DB }
|
||||
|
||||
// Create inserts a new subscription rule.
|
||||
func (r *SubscriptionRepository) Create(ctx context.Context, s *model.Subscription) error {
|
||||
return r.db.WithContext(ctx).Create(s).Error
|
||||
return r.db.WithContext(ctx).Select("*").Omit("DeletedAt").Create(s).Error
|
||||
}
|
||||
|
||||
// List returns all subscription rules.
|
||||
@@ -590,11 +630,11 @@ func (r *ApiConfigRepository) List(ctx context.Context) ([]model.ApiConfig, erro
|
||||
func (r *ApiConfigRepository) Upsert(ctx context.Context, c *model.ApiConfig) error {
|
||||
return r.db.WithContext(ctx).Where("provider = ?", c.Provider).
|
||||
Assign(model.ApiConfig{
|
||||
APIKey: c.APIKey,
|
||||
BaseURL: c.BaseURL,
|
||||
Extra: c.Extra,
|
||||
Enabled: c.Enabled,
|
||||
UpdatedAt: time.Now(),
|
||||
Base: model.Base{UpdatedAt: time.Now()},
|
||||
APIKey: c.APIKey,
|
||||
BaseURL: c.BaseURL,
|
||||
Extra: c.Extra,
|
||||
Enabled: c.Enabled,
|
||||
}).FirstOrCreate(c).Error
|
||||
}
|
||||
|
||||
@@ -602,11 +642,11 @@ func (r *ApiConfigRepository) Upsert(ctx context.Context, c *model.ApiConfig) er
|
||||
func (r *ApiConfigRepository) Update(ctx context.Context, c *model.ApiConfig) error {
|
||||
return r.db.WithContext(ctx).Model(&model.ApiConfig{}).
|
||||
Where("provider = ?", c.Provider).Updates(map[string]any{
|
||||
"api_key": c.APIKey,
|
||||
"base_url": c.BaseURL,
|
||||
"extra": c.Extra,
|
||||
"enabled": c.Enabled,
|
||||
"updated_at": time.Now(),
|
||||
"api_key": c.APIKey,
|
||||
"base_url": c.BaseURL,
|
||||
"extra": c.Extra,
|
||||
"enabled": c.Enabled,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
adultFC2Pattern = regexp.MustCompile(`(?i)\bFC2[-_\s]?(?:PPV[-_\s]?)?(\d{5,8})\b`)
|
||||
adultHEYZOPattern = regexp.MustCompile(`(?i)\bHEYZO[-_\s]?(\d{3,6})\b`)
|
||||
adultUncensoredPattern = regexp.MustCompile(`(?i)\b(\d{6})[-_](\d{3,5})\b`)
|
||||
adultStandardPattern = regexp.MustCompile(`(?i)(?:^|[^A-Z0-9])([A-Z]{2,10})[-_\s]?(\d{2,8})(?:[^A-Z0-9]|$)`)
|
||||
adultTitlePattern = regexp.MustCompile(`(?is)<h[123][^>]*>(.*?)</h[123]>`)
|
||||
adultTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
|
||||
adultAnchorPattern = regexp.MustCompile(`(?is)<a\b([^>]*)>(.*?)</a>`)
|
||||
adultImagePattern = regexp.MustCompile(`(?is)<img\b([^>]*)>`)
|
||||
adultJavBusCoverPattern = regexp.MustCompile(`(?is)class="bigImage"[^>]*href="([^"]+)"`)
|
||||
adultSamplePattern = regexp.MustCompile(`(?is)<a[^>]+class="[^"]*\bsample-box\b[^"]*"[^>]+href="([^"]+)"`)
|
||||
adultAttrPattern = regexp.MustCompile(`(?is)([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*["']([^"']*)["']`)
|
||||
)
|
||||
|
||||
var adultExcludedPrefixes = map[string]struct{}{
|
||||
"AC": {}, "AAC": {}, "AVC": {}, "BD": {}, "CD": {}, "DDP": {}, "DTS": {},
|
||||
"FHD": {}, "HD": {}, "HEVC": {}, "HDR": {}, "MP": {}, "SD": {}, "UHD": {},
|
||||
"WEB": {}, "X264": {}, "X265": {},
|
||||
}
|
||||
|
||||
type AdultProvider struct {
|
||||
log *zap.Logger
|
||||
client *http.Client
|
||||
apiConfig *APIConfigService
|
||||
}
|
||||
|
||||
func NewAdultProvider(log *zap.Logger, apiConfig *APIConfigService) *AdultProvider {
|
||||
return &AdultProvider{
|
||||
log: log,
|
||||
apiConfig: apiConfig,
|
||||
client: NewExternalHTTPClient(12 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdultProvider) Enabled() bool {
|
||||
return p != nil
|
||||
}
|
||||
|
||||
func (p *AdultProvider) Search(ctx context.Context, code string) (*Match, error) {
|
||||
code = normalizeAdultCode(code)
|
||||
if code == "" {
|
||||
return nil, errors.New("empty adult code")
|
||||
}
|
||||
bases := p.resolveBases(ctx)
|
||||
if len(bases) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var lastErr error
|
||||
for _, base := range bases {
|
||||
base = strings.TrimRight(base, "/")
|
||||
var match *Match
|
||||
var err error
|
||||
if strings.Contains(base, "javbus") {
|
||||
match, err = p.scrapeJavBus(ctx, base, code)
|
||||
} else {
|
||||
match, err = p.scrapeJavDB(ctx, base, code)
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if p.log != nil {
|
||||
p.log.Debug("adult scrape source failed", zap.String("base", base), zap.String("code", code), zap.Error(err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if match != nil {
|
||||
match.OriginalName = code
|
||||
match.NSFW = true
|
||||
return match, nil
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (p *AdultProvider) resolveBases(ctx context.Context) []string {
|
||||
out := []string{"https://javdb.com", "https://www.javbus.com"}
|
||||
if p.apiConfig == nil {
|
||||
return out
|
||||
}
|
||||
resolved, err := p.apiConfig.Resolve(ctx, "adult")
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
if !resolved.Enabled && (resolved.BaseURL != "" || resolved.Extra != "" || resolved.APIKey != "") {
|
||||
return nil
|
||||
}
|
||||
if resolved.BaseURL != "" {
|
||||
out = []string{resolved.BaseURL}
|
||||
}
|
||||
if resolved.Extra != "" {
|
||||
for _, part := range strings.Split(resolved.Extra, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(part, "http://") || strings.HasPrefix(part, "https://") {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
return dedupeStrings(out)
|
||||
}
|
||||
|
||||
func (p *AdultProvider) scrapeJavDB(ctx context.Context, base, code string) (*Match, error) {
|
||||
searchURL := base + "/search?q=" + url.QueryEscape(code) + "&f=all"
|
||||
body, err := p.fetchText(ctx, searchURL, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detail := ""
|
||||
for _, found := range adultAnchorPattern.FindAllStringSubmatch(body, -1) {
|
||||
if len(found) < 3 {
|
||||
continue
|
||||
}
|
||||
attrs := adultAttrs(found[1])
|
||||
if !strings.Contains(" "+attrs["class"]+" ", " box ") || attrs["href"] == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(stripAdultHTML(found[2])), code) {
|
||||
detail = absolutizeURL(base, attrs["href"])
|
||||
break
|
||||
}
|
||||
}
|
||||
if detail == "" {
|
||||
return nil, nil
|
||||
}
|
||||
body, err = p.fetchText(ctx, detail, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseAdultDetailHTML(body, code, "javdb", detail), nil
|
||||
}
|
||||
|
||||
func (p *AdultProvider) scrapeJavBus(ctx context.Context, base, code string) (*Match, error) {
|
||||
body, err := p.fetchText(ctx, base+"/"+url.PathEscape(code), base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseAdultDetailHTML(body, code, "javbus", base+"/"+url.PathEscape(code)), nil
|
||||
}
|
||||
|
||||
func (p *AdultProvider) fetchText(ctx context.Context, targetURL, referer string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
applyAdultHeaders(req, referer)
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return "", nil
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("adult source %s returned %d", targetURL, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func parseAdultDetailHTML(body, code, source, detailURL string) *Match {
|
||||
match := &Match{
|
||||
OriginalName: code,
|
||||
NSFW: true,
|
||||
Genres: []string{"Adult", source},
|
||||
}
|
||||
if title := firstAdultTitle(body, code); title != "" {
|
||||
match.Title = title
|
||||
}
|
||||
if match.Title == "" {
|
||||
return nil
|
||||
}
|
||||
if source == "javbus" {
|
||||
if m := adultJavBusCoverPattern.FindStringSubmatch(body); len(m) > 1 {
|
||||
match.PosterURL = absolutizeURL(detailURL, m[1])
|
||||
}
|
||||
} else if cover := firstAdultImage(body, "video-cover", "cover", "column-video-cover"); cover != "" {
|
||||
match.PosterURL = absolutizeURL(detailURL, cover)
|
||||
}
|
||||
if m := adultSamplePattern.FindStringSubmatch(body); len(m) > 1 {
|
||||
match.BackdropURL = absolutizeURL(detailURL, m[1])
|
||||
}
|
||||
match.Year = firstYearInText(body)
|
||||
match.Rating = firstRatingInText(body)
|
||||
return match
|
||||
}
|
||||
|
||||
func firstAdultTitle(body, code string) string {
|
||||
for _, found := range adultTitlePattern.FindAllStringSubmatch(body, -1) {
|
||||
if len(found) < 2 {
|
||||
continue
|
||||
}
|
||||
title := strings.TrimSpace(stripAdultHTML(found[1]))
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
title = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(title, code), strings.ToUpper(code)))
|
||||
if title != "" {
|
||||
return title
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func applyAdultHeaders(req *http.Request, referer string) {
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,ja;q=0.8,en;q=0.7")
|
||||
if referer != "" {
|
||||
req.Header.Set("Referer", referer)
|
||||
}
|
||||
}
|
||||
|
||||
func AdultCodeFromMediaPath(path string) string {
|
||||
if code := normalizeAdultCode(filepath.Base(path)); code != "" {
|
||||
return code
|
||||
}
|
||||
return normalizeAdultCode(path)
|
||||
}
|
||||
|
||||
func normalizeAdultCode(input string) string {
|
||||
input = strings.ToUpper(strings.TrimSpace(input))
|
||||
if input == "" {
|
||||
return ""
|
||||
}
|
||||
input = strings.ReplaceAll(input, "_", "-")
|
||||
if m := adultFC2Pattern.FindStringSubmatch(input); len(m) > 1 {
|
||||
return "FC2-PPV-" + m[1]
|
||||
}
|
||||
if m := adultHEYZOPattern.FindStringSubmatch(input); len(m) > 1 {
|
||||
return "HEYZO-" + m[1]
|
||||
}
|
||||
if m := adultUncensoredPattern.FindStringSubmatch(input); len(m) > 2 {
|
||||
return m[1] + "-" + m[2]
|
||||
}
|
||||
for _, m := range adultStandardPattern.FindAllStringSubmatch(input, -1) {
|
||||
if len(m) < 3 {
|
||||
continue
|
||||
}
|
||||
prefix := strings.TrimSpace(m[1])
|
||||
if _, excluded := adultExcludedPrefixes[prefix]; excluded {
|
||||
continue
|
||||
}
|
||||
return prefix + "-" + m[2]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stripAdultHTML(value string) string {
|
||||
value = adultTagPattern.ReplaceAllString(value, " ")
|
||||
return strings.Join(strings.Fields(html.UnescapeString(value)), " ")
|
||||
}
|
||||
|
||||
func firstAdultImage(body string, classNeedles ...string) string {
|
||||
for _, found := range adultImagePattern.FindAllStringSubmatch(body, -1) {
|
||||
if len(found) < 2 {
|
||||
continue
|
||||
}
|
||||
attrs := adultAttrs(found[1])
|
||||
class := strings.ToLower(attrs["class"])
|
||||
for _, needle := range classNeedles {
|
||||
if strings.Contains(class, strings.ToLower(needle)) {
|
||||
if attrs["src"] != "" {
|
||||
return attrs["src"]
|
||||
}
|
||||
if attrs["data-src"] != "" {
|
||||
return attrs["data-src"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func adultAttrs(raw string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, found := range adultAttrPattern.FindAllStringSubmatch(raw, -1) {
|
||||
if len(found) >= 3 {
|
||||
out[strings.ToLower(found[1])] = html.UnescapeString(found[2])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func absolutizeURL(base, raw string) string {
|
||||
raw = strings.TrimSpace(html.UnescapeString(raw))
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err == nil && u.IsAbs() {
|
||||
return raw
|
||||
}
|
||||
b, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return b.ResolveReference(u).String()
|
||||
}
|
||||
|
||||
func firstYearInText(body string) int {
|
||||
m := regexp.MustCompile(`(?:19|20)\d{2}[-/.]\d{1,2}[-/.]\d{1,2}`).FindString(body)
|
||||
if len(m) >= 4 {
|
||||
year, _ := strconv.Atoi(m[:4])
|
||||
return year
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstRatingInText(body string) float32 {
|
||||
m := regexp.MustCompile(`(?i)(?:score|rating|評分|评分)[^0-9]{0,20}([0-9](?:\.[0-9])?)`).FindStringSubmatch(body)
|
||||
if len(m) > 1 {
|
||||
v, _ := strconv.ParseFloat(m[1], 32)
|
||||
return float32(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func dedupeStrings(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 := strings.ToLower(value)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeAdultCode(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"SSIS001.mp4": "SSIS-001",
|
||||
"fc2-ppv-1234567.mkv": "FC2-PPV-1234567",
|
||||
"heyzo_1234.mp4": "HEYZO-1234",
|
||||
"120118_001-carib.mp4": "120118-001",
|
||||
"movie.1080p.x264.mkv": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeAdultCode(in); got != want {
|
||||
t.Fatalf("normalizeAdultCode(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdultDetailHTML(t *testing.T) {
|
||||
html := `<html>
|
||||
<h2 class="title"><strong>SSIS-001 测试标题</strong></h2>
|
||||
<img class="video-cover" src="/covers/ssis001.jpg">
|
||||
<a class="sample-box" href="/samples/1.jpg"></a>
|
||||
<span class="score"><span class="value">4.7</span></span>
|
||||
<div>日期 2024-05-01</div>
|
||||
</html>`
|
||||
|
||||
got := parseAdultDetailHTML(html, "SSIS-001", "javdb", "https://javdb.com/v/abc")
|
||||
if got == nil {
|
||||
t.Fatal("parseAdultDetailHTML returned nil")
|
||||
}
|
||||
if got.Title != "测试标题" || got.OriginalName != "SSIS-001" || !got.NSFW {
|
||||
t.Fatalf("unexpected metadata: %+v", got)
|
||||
}
|
||||
if got.PosterURL != "https://javdb.com/covers/ssis001.jpg" || got.BackdropURL != "https://javdb.com/samples/1.jpg" {
|
||||
t.Fatalf("unexpected artwork: %+v", got)
|
||||
}
|
||||
if got.Year != 2024 {
|
||||
t.Fatalf("year = %d, want 2024", got.Year)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ func (s *APIConfigService) SeedDefaults(ctx context.Context) error {
|
||||
{Provider: "thetvdb", BaseURL: "https://api4.thetvdb.com/v4", Description: "TheTVDB (tv)", Enabled: true},
|
||||
{Provider: "fanart", BaseURL: "https://webservice.fanart.tv/v3", Description: "Fanart.tv (artwork)", Enabled: true},
|
||||
{Provider: "douban", Description: "Douban cookie (zh metadata)", Enabled: true},
|
||||
{Provider: "adult", BaseURL: "https://javdb.com", Extra: "https://www.javbus.com", Description: "Adult / 番号元数据(JavDB/JavBus)", Enabled: true},
|
||||
{Provider: "openai", BaseURL: "https://api.openai.com/v1", Description: "OpenAI-compatible (smart search)", Enabled: true},
|
||||
}
|
||||
for i := range defaults {
|
||||
@@ -64,16 +65,16 @@ func (s *APIConfigService) SeedDefaults(ctx context.Context) error {
|
||||
// PublicView is the safe-to-display projection of an API config row.
|
||||
// The plaintext key is never returned — only a mask.
|
||||
type PublicView struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
Extra string `json:"extra,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Description string `json:"description,omitempty"`
|
||||
HasKey bool `json:"has_key"`
|
||||
MaskedKey string `json:"masked_key,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
Extra string `json:"extra,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Description string `json:"description,omitempty"`
|
||||
HasKey bool `json:"has_key"`
|
||||
MaskedKey string `json:"masked_key,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// List returns every API config row (with masked keys).
|
||||
@@ -103,10 +104,10 @@ func (s *APIConfigService) Get(ctx context.Context, provider string) (*PublicVie
|
||||
// client. Empty struct (with no error) when the provider is unknown or
|
||||
// the API key is empty.
|
||||
type Resolved struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Extra string
|
||||
Enabled bool
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Extra string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// Resolve fetches the live configuration for a provider, decrypting the
|
||||
|
||||
@@ -156,22 +156,21 @@ func (s *ApiConfigService) testTMDb(cfg *model.ApiConfig) (string, error) {
|
||||
return "error", errors.New("API key is required")
|
||||
}
|
||||
|
||||
testURL := "https://api.themoviedb.org/3/configuration?api_key=" + cfg.APIKey
|
||||
resp, err := http.Get(testURL)
|
||||
baseURL := strings.TrimRight(cfg.BaseURL, "/")
|
||||
if baseURL == "" {
|
||||
baseURL = strings.TrimRight(s.cfg.Secrets.TMDbAPIProxy, "/")
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.themoviedb.org/3"
|
||||
}
|
||||
testURL := baseURL + "/configuration?api_key=" + url.QueryEscape(cfg.APIKey)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, testURL, nil)
|
||||
if err != nil {
|
||||
return "error", err
|
||||
}
|
||||
client := NewExternalHTTPClient(10 * time.Second)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// 如果配置了代理,使用代理
|
||||
if s.cfg.Secrets.TMDbAPIProxy != "" {
|
||||
proxyURL := s.cfg.Secrets.TMDbAPIProxy + "?api_key=" + cfg.APIKey
|
||||
resp, err = http.Get(proxyURL)
|
||||
if err != nil {
|
||||
return "error", fmt.Errorf("TMDb connection failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
return "success", nil
|
||||
}
|
||||
return "error", fmt.Errorf("TMDb API returned status %d", resp.StatusCode)
|
||||
}
|
||||
return "error", fmt.Errorf("TMDb connection failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -40,7 +40,7 @@ func NewBangumiProvider(cfg *config.Config, log *zap.Logger) *BangumiProvider {
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
base: "https://api.bgm.tv",
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
client: NewExternalHTTPClient(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -29,7 +31,7 @@ func NewDiscoverService(log *zap.Logger, tmdb *TMDbProvider) *DiscoverService {
|
||||
return &DiscoverService{
|
||||
log: log,
|
||||
tmdb: tmdb,
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
client: NewExternalHTTPClient(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +45,39 @@ func (d *DiscoverService) Popular(ctx context.Context) ([]Match, error) {
|
||||
return d.fetch(ctx, "/movie/popular")
|
||||
}
|
||||
|
||||
// TMDbSection returns one TMDb rail converted to the common external
|
||||
// discovery shape used by the multi-source Discover page.
|
||||
func (d *DiscoverService) TMDbSection(ctx context.Context, key string) ([]ExternalMediaResult, error) {
|
||||
path := tmdbDiscoverPath(key)
|
||||
if path == "" {
|
||||
return []ExternalMediaResult{}, nil
|
||||
}
|
||||
matches, err := d.Fetch(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mediaType := "movie"
|
||||
if strings.Contains(path, "/tv/") {
|
||||
mediaType = "tv"
|
||||
}
|
||||
out := make([]ExternalMediaResult, 0, len(matches))
|
||||
for _, item := range matches {
|
||||
out = append(out, ExternalMediaResult{
|
||||
Source: "tmdb",
|
||||
MediaType: mediaType,
|
||||
Title: item.Title,
|
||||
Overview: item.Overview,
|
||||
PosterURL: item.PosterURL,
|
||||
BackdropURL: item.BackdropURL,
|
||||
Year: item.Year,
|
||||
Rating: item.Rating,
|
||||
TMDbID: item.TMDbID,
|
||||
SubscribeKeyword: buildSubscribeKeyword(item.Title, item.Year),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fetch is the shared helper that paginates page=1 only — that's all the
|
||||
// home page needs and it keeps us under TMDb's 50 rps limit.
|
||||
func (d *DiscoverService) fetch(ctx context.Context, path string) ([]Match, error) {
|
||||
@@ -130,3 +165,161 @@ func (d *DiscoverService) Fetch(ctx context.Context, path string) ([]Match, erro
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func tmdbDiscoverPath(key string) string {
|
||||
switch key {
|
||||
case "tmdb_trending_day", "trending_day":
|
||||
return "/trending/movie/day"
|
||||
case "tmdb_trending_week", "trending_week":
|
||||
return "/trending/movie/week"
|
||||
case "tmdb_popular_movie", "popular_movie":
|
||||
return "/movie/popular"
|
||||
case "tmdb_popular_tv", "popular_tv":
|
||||
return "/tv/popular"
|
||||
case "tmdb_top_rated_movie", "top_rated_movie":
|
||||
return "/movie/top_rated"
|
||||
case "tmdb_upcoming_movie", "upcoming_movie":
|
||||
return "/movie/upcoming"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Discover returns public Douban movie/TV rails. Douban does not require a
|
||||
// formal API key here; these are the same public web endpoints the site uses.
|
||||
func (d *DoubanProvider) Discover(ctx context.Context, key string) ([]ExternalMediaResult, error) {
|
||||
doubanType := "movie"
|
||||
tag := "热门"
|
||||
switch key {
|
||||
case "douban_hot_movie":
|
||||
doubanType = "movie"
|
||||
tag = "热门"
|
||||
case "douban_top_movie":
|
||||
doubanType = "movie"
|
||||
tag = "高分"
|
||||
case "douban_hot_tv":
|
||||
doubanType = "tv"
|
||||
tag = "热门"
|
||||
default:
|
||||
return []ExternalMediaResult{}, nil
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("type", doubanType)
|
||||
q.Set("tag", tag)
|
||||
q.Set("sort", "recommend")
|
||||
q.Set("page_limit", "24")
|
||||
q.Set("page_start", "0")
|
||||
u := "https://movie.douban.com/j/search_subjects?" + q.Encode()
|
||||
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 discover: %d", resp.StatusCode)
|
||||
}
|
||||
var page struct {
|
||||
Subjects []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Rate string `json:"rate"`
|
||||
Cover string `json:"cover"`
|
||||
URL string `json:"url"`
|
||||
} `json:"subjects"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ExternalMediaResult, 0, len(page.Subjects))
|
||||
mediaType := "movie"
|
||||
if doubanType == "tv" {
|
||||
mediaType = "tv"
|
||||
}
|
||||
for _, subject := range page.Subjects {
|
||||
if strings.TrimSpace(subject.Title) == "" {
|
||||
continue
|
||||
}
|
||||
rating, _ := strconv.ParseFloat(subject.Rate, 32)
|
||||
out = append(out, ExternalMediaResult{
|
||||
Source: "douban",
|
||||
MediaType: mediaType,
|
||||
Title: subject.Title,
|
||||
PosterURL: subject.Cover,
|
||||
Rating: float32(rating),
|
||||
DoubanID: subject.ID,
|
||||
SubscribeKeyword: subject.Title,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Calendar returns Bangumi's public on-air anime calendar as a recommendation
|
||||
// rail. It needs no token, but NewBangumiProvider still attaches one when set.
|
||||
func (b *BangumiProvider) Calendar(ctx context.Context) ([]ExternalMediaResult, error) {
|
||||
type subject struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
NameCN string `json:"name_cn"`
|
||||
Summary string `json:"summary"`
|
||||
AirDate string `json:"air_date"`
|
||||
Images struct {
|
||||
Large string `json:"large"`
|
||||
Common string `json:"common"`
|
||||
} `json:"images"`
|
||||
Rating struct {
|
||||
Score float32 `json:"score"`
|
||||
} `json:"rating"`
|
||||
}
|
||||
type day struct {
|
||||
Items []subject `json:"items"`
|
||||
}
|
||||
var days []day
|
||||
if err := b.getJSON(ctx, b.base+"/calendar", &days); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ExternalMediaResult, 0, 24)
|
||||
seen := map[int]struct{}{}
|
||||
for _, day := range days {
|
||||
for _, item := range day.Items {
|
||||
if _, ok := seen[item.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item.ID] = struct{}{}
|
||||
title := strings.TrimSpace(item.NameCN)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(item.Name)
|
||||
}
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
poster := item.Images.Large
|
||||
if poster == "" {
|
||||
poster = item.Images.Common
|
||||
}
|
||||
year := 0
|
||||
if len(item.AirDate) >= 4 {
|
||||
year, _ = strconv.Atoi(item.AirDate[:4])
|
||||
}
|
||||
out = append(out, ExternalMediaResult{
|
||||
Source: "bangumi",
|
||||
MediaType: "anime",
|
||||
Title: title,
|
||||
Overview: item.Summary,
|
||||
PosterURL: poster,
|
||||
Year: year,
|
||||
Rating: item.Rating.Score,
|
||||
BangumiID: item.ID,
|
||||
SubscribeKeyword: buildSubscribeKeyword(title, year),
|
||||
})
|
||||
if len(out) >= 24 {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// and poster URLs. A valid Douban cookie is required to avoid IP bans.
|
||||
//
|
||||
// We use the search endpoint at:
|
||||
// https://movie.douban.com/j/subject_suggest?q=...
|
||||
//
|
||||
// https://movie.douban.com/j/subject_suggest?q=...
|
||||
//
|
||||
// And the detail endpoint at:
|
||||
// https://movie.douban.com/j/subject_abstract?subject_id=...
|
||||
//
|
||||
// https://movie.douban.com/j/subject_abstract?subject_id=...
|
||||
//
|
||||
// The provider is used as a supplemental source: after TMDb matches we
|
||||
// attempt a Douban lookup to grab a localized Chinese title + overview.
|
||||
@@ -41,13 +43,15 @@ func NewDoubanProvider(cfg *config.Config, log *zap.Logger) *DoubanProvider {
|
||||
return &DoubanProvider{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
client: NewExternalHTTPClient(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether a Douban cookie is configured.
|
||||
// Enabled reports whether Douban lookup is available. Public movie.douban.com
|
||||
// suggest endpoints work without an API key; a cookie is optional and only
|
||||
// helps when Douban applies stricter anti-scraping rules.
|
||||
func (d *DoubanProvider) Enabled() bool {
|
||||
return strings.TrimSpace(d.cfg.Secrets.DoubanCookie) != ""
|
||||
return true
|
||||
}
|
||||
|
||||
// userAgents for anti-scraping randomization.
|
||||
@@ -64,6 +68,7 @@ type DoubanMatch struct {
|
||||
Year string `json:"year"`
|
||||
Img string `json:"img"`
|
||||
Rating float32 `json:"rating"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// Search runs a Douban subject_suggest query and returns the top match.
|
||||
@@ -92,6 +97,7 @@ func (d *DoubanProvider) Search(ctx context.Context, query string) (*DoubanMatch
|
||||
Title string `json:"title"`
|
||||
Year string `json:"year"`
|
||||
Img string `json:"img"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
var results []suggestion
|
||||
if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
|
||||
@@ -106,6 +112,7 @@ func (d *DoubanProvider) Search(ctx context.Context, query string) (*DoubanMatch
|
||||
Title: r.Title,
|
||||
Year: r.Year,
|
||||
Img: r.Img,
|
||||
Type: r.Type,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
//
|
||||
// Settings consumed (system Setting table):
|
||||
//
|
||||
// qbittorrent.url e.g. http://127.0.0.1:8080
|
||||
// qbittorrent.username qBittorrent WebUI user
|
||||
// qbittorrent.password qBittorrent WebUI password
|
||||
// qbittorrent.savepath optional default save dir
|
||||
// qbittorrent.url e.g. http://127.0.0.1:8080
|
||||
// qbittorrent.username qBittorrent WebUI user
|
||||
// qbittorrent.password qBittorrent WebUI password
|
||||
// qbittorrent.savepath optional default save dir
|
||||
//
|
||||
// Settings can be updated at runtime via the admin UI; ReloadConfig()
|
||||
// re-reads them and re-authenticates.
|
||||
@@ -18,9 +18,11 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -30,28 +32,36 @@ import (
|
||||
|
||||
// DownloadService is the single download orchestrator.
|
||||
type DownloadService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
hub *Hub
|
||||
qb *QBitClient
|
||||
organizer *OrganizerService
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
hub *Hub
|
||||
qb *QBitClient
|
||||
organizer *OrganizerService
|
||||
site *SiteService
|
||||
|
||||
mu sync.Mutex
|
||||
stopCh chan struct{}
|
||||
pollOnce sync.Once
|
||||
prevStates map[string]bool // hash -> wasCompleted
|
||||
mu sync.Mutex
|
||||
stopCh chan struct{}
|
||||
pollOnce sync.Once
|
||||
prevStates map[string]bool // hash -> wasCompleted
|
||||
}
|
||||
|
||||
var torrentEpisodeToken = regexp.MustCompile(`(?i)e\d{1,3}`)
|
||||
|
||||
// NewDownloadService is the constructor.
|
||||
func NewDownloadService(log *zap.Logger, repo *repository.Container, hub *Hub, organizer *OrganizerService) *DownloadService {
|
||||
func NewDownloadService(log *zap.Logger, repo *repository.Container, hub *Hub, organizer *OrganizerService, site ...*SiteService) *DownloadService {
|
||||
var siteSvc *SiteService
|
||||
if len(site) > 0 {
|
||||
siteSvc = site[0]
|
||||
}
|
||||
return &DownloadService{
|
||||
log: log,
|
||||
repo: repo,
|
||||
hub: hub,
|
||||
qb: NewQBitClient(log, QBitConfig{}),
|
||||
organizer: organizer,
|
||||
log: log,
|
||||
repo: repo,
|
||||
hub: hub,
|
||||
qb: NewQBitClient(log, QBitConfig{}),
|
||||
organizer: organizer,
|
||||
site: siteSvc,
|
||||
prevStates: make(map[string]bool),
|
||||
stopCh: make(chan struct{}),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,9 +125,27 @@ func (d *DownloadService) AddDownload(ctx context.Context, userID, urlStr, saveP
|
||||
if savePath == "" {
|
||||
savePath, _ = d.repo.Setting.Get(ctx, "qbittorrent.savepath")
|
||||
}
|
||||
var siteFetchErr error
|
||||
if d.site != nil {
|
||||
if data, name, err := d.site.FetchTorrentFile(ctx, urlStr); err == nil {
|
||||
if err := d.qb.AddTorrentFile(ctx, data, name, savePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d.createTask(ctx, userID, urlStr, savePath)
|
||||
} else {
|
||||
siteFetchErr = err
|
||||
}
|
||||
}
|
||||
if err := d.qb.AddTorrent(ctx, urlStr, savePath); err != nil {
|
||||
if siteFetchErr != nil && !strings.Contains(siteFetchErr.Error(), "no matching PT site") {
|
||||
return nil, errors.Join(err, siteFetchErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return d.createTask(ctx, userID, urlStr, savePath)
|
||||
}
|
||||
|
||||
func (d *DownloadService) createTask(ctx context.Context, userID, urlStr, savePath string) (*model.DownloadTask, error) {
|
||||
t := &model.DownloadTask{
|
||||
UserID: userID,
|
||||
Source: "qbittorrent",
|
||||
@@ -131,6 +159,38 @@ func (d *DownloadService) AddDownload(ctx context.Context, userID, urlStr, saveP
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (d *DownloadService) TorrentExistsByName(ctx context.Context, name string) bool {
|
||||
query := normalizeTorrentName(name)
|
||||
if query == "" {
|
||||
return false
|
||||
}
|
||||
live, err := d.qb.List(ctx, "")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, torrent := range live {
|
||||
current := normalizeTorrentName(torrent.Name)
|
||||
if current == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(current, query) || strings.Contains(query, current) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeTorrentName(name string) string {
|
||||
name = torrentEpisodeToken.ReplaceAllString(strings.ToLower(name), "")
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// List returns every persisted download task augmented with live data
|
||||
// from qBittorrent when available.
|
||||
func (d *DownloadService) List(ctx context.Context) ([]model.DownloadTask, []QBitTorrent, error) {
|
||||
|
||||
@@ -50,10 +50,11 @@ type Group struct {
|
||||
|
||||
// Report is the summary the React UI displays.
|
||||
type Report struct {
|
||||
TotalScanned int `json:"total_scanned"`
|
||||
GroupsFound int `json:"groups_found"`
|
||||
ItemsMarked int `json:"items_marked"`
|
||||
Groups []Group `json:"groups"`
|
||||
TotalScanned int `json:"total_scanned"`
|
||||
GroupsFound int `json:"groups_found"`
|
||||
ItemsMarked int `json:"items_marked"`
|
||||
MissingRemoved int64 `json:"missing_removed"`
|
||||
Groups []Group `json:"groups"`
|
||||
}
|
||||
|
||||
// Detect walks every media row in the given library (or all libraries
|
||||
@@ -69,7 +70,9 @@ func (d *DuplicateService) Detect(ctx context.Context, libraryID string) (*Repor
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rep := &Report{TotalScanned: len(rows)}
|
||||
rep := &Report{Groups: []Group{}}
|
||||
rows = d.removeMissingRows(ctx, rows, rep)
|
||||
rep.TotalScanned = len(rows)
|
||||
totalToHash := 0
|
||||
for i := range rows {
|
||||
if rows[i].FileHash == "" && rows[i].Path != "" {
|
||||
@@ -158,6 +161,68 @@ func (d *DuplicateService) Detect(ctx context.Context, libraryID string) (*Repor
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// Current returns duplicate groups already marked in the database. It keeps
|
||||
// the UI useful after a prior scan and avoids requiring POST on page load.
|
||||
func (d *DuplicateService) Current(ctx context.Context, libraryID string) (*Report, error) {
|
||||
var rows []model.Media
|
||||
q := d.repo.DB.WithContext(ctx).Where("is_duplicate = ? OR duplicate_of <> ''", true)
|
||||
if libraryID != "" {
|
||||
q = q.Where("library_id = ?", libraryID)
|
||||
}
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rep := &Report{TotalScanned: len(rows), Groups: []Group{}}
|
||||
byPrimary := make(map[string][]model.Media)
|
||||
for _, row := range rows {
|
||||
if row.DuplicateOf == "" {
|
||||
continue
|
||||
}
|
||||
byPrimary[row.DuplicateOf] = append(byPrimary[row.DuplicateOf], row)
|
||||
}
|
||||
for primaryID, dupes := range byPrimary {
|
||||
primary, err := d.repo.Media.FindByID(ctx, primaryID)
|
||||
if err != nil || primary == nil {
|
||||
continue
|
||||
}
|
||||
hash := primary.FileHash
|
||||
if hash == "" && len(dupes) > 0 {
|
||||
hash = dupes[0].FileHash
|
||||
}
|
||||
rep.Groups = append(rep.Groups, Group{
|
||||
Hash: hash,
|
||||
Primary: *primary,
|
||||
Duplicates: dupes,
|
||||
})
|
||||
}
|
||||
rep.GroupsFound = len(rep.Groups)
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
func (d *DuplicateService) removeMissingRows(ctx context.Context, rows []model.Media, rep *Report) []model.Media {
|
||||
kept := make([]model.Media, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.Path == "" {
|
||||
kept = append(kept, row)
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(row.Path); err == nil {
|
||||
kept = append(kept, row)
|
||||
continue
|
||||
} else if !os.IsNotExist(err) {
|
||||
kept = append(kept, row)
|
||||
continue
|
||||
}
|
||||
res := d.repo.DB.WithContext(ctx).Where("id = ?", row.ID).Delete(&model.Media{})
|
||||
if res.Error != nil {
|
||||
d.log.Warn("remove missing duplicate candidate failed", zap.String("media", row.ID), zap.Error(res.Error))
|
||||
continue
|
||||
}
|
||||
rep.MissingRemoved += res.RowsAffected
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// Unmark clears the is_duplicate flag for every row in the given library
|
||||
// (or all when libraryID is empty). Useful when the operator deletes the
|
||||
// physical duplicates manually.
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
//
|
||||
// Detects season + episode numbers from filenames. Recognised patterns:
|
||||
//
|
||||
// S01E02 / s1e2
|
||||
// 1x02 / 01x02
|
||||
// EP02 / E02
|
||||
// 第2集 / 第02集
|
||||
// S01E02 / s1e2
|
||||
// 1x02 / 01x02
|
||||
// EP02 / E02
|
||||
// 第2集 / 第02集
|
||||
//
|
||||
// For bare episode markers such as "EP02", the parser also looks at parent
|
||||
// folders like "Season 02" / "S02" / "第2季" before falling back to season 1.
|
||||
//
|
||||
// When neither a season nor an episode marker is present, returns (0, 0).
|
||||
package service
|
||||
@@ -18,10 +21,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
||||
patCN = regexp.MustCompile(`第\s*(\d{1,3})\s*[集话話]`)
|
||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
||||
patCN = regexp.MustCompile(`第\s*(\d{1,3})\s*[集话話期]`)
|
||||
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*(\d{1,2})\s*季`)
|
||||
)
|
||||
|
||||
// ParseEpisode tries to extract (season, episode) from an arbitrary filename.
|
||||
@@ -40,18 +44,47 @@ func ParseEpisode(path string) (season, episode int) {
|
||||
return
|
||||
}
|
||||
if m := patEP.FindStringSubmatch(name); len(m) >= 2 {
|
||||
season = 1
|
||||
season = seasonFromParents(path)
|
||||
if season == 0 {
|
||||
season = 1
|
||||
}
|
||||
episode = mustAtoi(m[1])
|
||||
return
|
||||
}
|
||||
if m := patCN.FindStringSubmatch(name); len(m) >= 2 {
|
||||
season = 1
|
||||
season = seasonFromParents(path)
|
||||
if season == 0 {
|
||||
season = 1
|
||||
}
|
||||
episode = mustAtoi(m[1])
|
||||
return
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func seasonFromParents(path string) int {
|
||||
dir := filepath.Dir(path)
|
||||
for i := 0; i < 4; i++ {
|
||||
base := filepath.Base(dir)
|
||||
if base == "." || base == string(filepath.Separator) {
|
||||
return 0
|
||||
}
|
||||
if m := patSeasonFolder.FindStringSubmatch(base); len(m) >= 3 {
|
||||
for _, group := range m[1:] {
|
||||
if group != "" {
|
||||
return mustAtoi(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return 0
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func mustAtoi(s string) int {
|
||||
v, _ := strconv.Atoi(s)
|
||||
return v
|
||||
|
||||
@@ -4,8 +4,8 @@ import "testing"
|
||||
|
||||
func TestParseEpisode(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
wantS, wantE int
|
||||
in string
|
||||
wantS, wantE int
|
||||
}{
|
||||
{"Breaking.Bad.S01E02.1080p.mkv", 1, 2},
|
||||
{"breaking.bad.s5e14.mkv", 5, 14},
|
||||
@@ -13,8 +13,13 @@ func TestParseEpisode(t *testing.T) {
|
||||
{"Friends 10x24 - The One Where.mkv", 10, 24},
|
||||
{"Some Anime - EP05 [1080p].mkv", 1, 5},
|
||||
{"Some Anime - E12.mkv", 1, 12},
|
||||
{`Some Show/Season 02/Some Show - EP03.mkv`, 2, 3},
|
||||
{`Some Show/S02/Some Show - E04.mkv`, 2, 4},
|
||||
{`剧集/第2季/剧集 第05集.mkv`, 2, 5},
|
||||
{"日剧 第03集.mkv", 1, 3},
|
||||
{"日剧 第12话.mkv", 1, 12},
|
||||
{"综艺 第4期下.mkv", 1, 4},
|
||||
{`综艺/Season 06/综艺 第17期.mkv`, 6, 17},
|
||||
{"Movie.2020.1080p.mkv", 0, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExternalMediaResult is a metadata candidate from an online catalog. It is
|
||||
// intentionally separate from model.Media because the item may not exist in
|
||||
// the local library yet.
|
||||
type ExternalMediaResult struct {
|
||||
Source string `json:"source"`
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
BackdropURL string `json:"backdrop_url,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
Rating float32 `json:"rating,omitempty"`
|
||||
TMDbID int `json:"tmdb_id,omitempty"`
|
||||
BangumiID int `json:"bangumi_id,omitempty"`
|
||||
DoubanID string `json:"douban_id,omitempty"`
|
||||
SubscribeKeyword string `json:"subscribe_keyword"`
|
||||
}
|
||||
|
||||
// SearchExternalMedia fans out one normalized search intent to TMDb, Douban
|
||||
// and Bangumi. This mirrors MoviePilot's separation of "metadata discovery"
|
||||
// from later tracker searching/downloading, but keeps our Go service small.
|
||||
func SearchExternalMedia(ctx context.Context, query string, year int, mediaType string, tmdb *TMDbProvider, douban *DoubanProvider, bangumi *BangumiProvider) []ExternalMediaResult {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return []ExternalMediaResult{}
|
||||
}
|
||||
|
||||
results := make([]ExternalMediaResult, 0, 6)
|
||||
addMatch := func(source, typ string, m *Match) {
|
||||
if m == nil || strings.TrimSpace(m.Title) == "" {
|
||||
return
|
||||
}
|
||||
results = append(results, ExternalMediaResult{
|
||||
Source: source,
|
||||
MediaType: typ,
|
||||
Title: m.Title,
|
||||
Overview: m.Overview,
|
||||
PosterURL: m.PosterURL,
|
||||
BackdropURL: m.BackdropURL,
|
||||
Year: m.Year,
|
||||
Rating: m.Rating,
|
||||
TMDbID: m.TMDbID,
|
||||
BangumiID: m.BangumiID,
|
||||
SubscribeKeyword: buildSubscribeKeyword(m.Title, m.Year),
|
||||
})
|
||||
}
|
||||
|
||||
if tmdb != nil {
|
||||
if mediaType == "" || mediaType == "movie" {
|
||||
if m, err := tmdb.SearchMovie(ctx, query, year); err == nil {
|
||||
addMatch("tmdb", "movie", m)
|
||||
}
|
||||
}
|
||||
if mediaType == "" || mediaType == "tv" || mediaType == "anime" {
|
||||
if m, err := tmdb.SearchTV(ctx, query, year); err == nil {
|
||||
addMatch("tmdb", "tv", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bangumi != nil && (mediaType == "" || mediaType == "anime") {
|
||||
if m, err := bangumi.Search(ctx, query); err == nil {
|
||||
addMatch("bangumi", "anime", m)
|
||||
}
|
||||
}
|
||||
|
||||
if douban != nil {
|
||||
if m, err := douban.Search(ctx, query); err == nil && m != nil {
|
||||
yearValue, _ := strconv.Atoi(m.Year)
|
||||
typ := normalizeDoubanType(m.Type, mediaType)
|
||||
results = append(results, ExternalMediaResult{
|
||||
Source: "douban",
|
||||
MediaType: typ,
|
||||
Title: m.Title,
|
||||
PosterURL: m.Img,
|
||||
Year: yearValue,
|
||||
Rating: m.Rating,
|
||||
DoubanID: m.DoubanID,
|
||||
SubscribeKeyword: buildSubscribeKeyword(m.Title, yearValue),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeExternalMedia(results)
|
||||
}
|
||||
|
||||
func buildSubscribeKeyword(title string, year int) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if year > 0 {
|
||||
return fmt.Sprintf("%s %d", title, year)
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
func normalizeDoubanType(doubanType, fallback string) string {
|
||||
doubanType = strings.ToLower(strings.TrimSpace(doubanType))
|
||||
switch doubanType {
|
||||
case "movie":
|
||||
return "movie"
|
||||
case "tv", "tvshow", "drama":
|
||||
return "tv"
|
||||
}
|
||||
if fallback != "" {
|
||||
return fallback
|
||||
}
|
||||
return "movie"
|
||||
}
|
||||
|
||||
func dedupeExternalMedia(in []ExternalMediaResult) []ExternalMediaResult {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]ExternalMediaResult, 0, len(in))
|
||||
for _, item := range in {
|
||||
key := item.Source + ":" + strings.ToLower(item.Title)
|
||||
if item.TMDbID > 0 {
|
||||
key = fmt.Sprintf("tmdb:%d:%s", item.TMDbID, item.MediaType)
|
||||
} else if item.BangumiID > 0 {
|
||||
key = fmt.Sprintf("bangumi:%d", item.BangumiID)
|
||||
} else if item.DoubanID != "" {
|
||||
key = "douban:" + item.DoubanID
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildSubscribeKeyword(t *testing.T) {
|
||||
if got := buildSubscribeKeyword("沙丘", 2024); got != "沙丘 2024" {
|
||||
t.Fatalf("keyword = %q", got)
|
||||
}
|
||||
if got := buildSubscribeKeyword("沙丘", 0); got != "沙丘" {
|
||||
t.Fatalf("keyword without year = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeExternalMedia(t *testing.T) {
|
||||
in := []ExternalMediaResult{
|
||||
{Source: "tmdb", MediaType: "movie", TMDbID: 1, Title: "A"},
|
||||
{Source: "tmdb", MediaType: "movie", TMDbID: 1, Title: "A duplicate"},
|
||||
{Source: "douban", DoubanID: "2", Title: "B"},
|
||||
}
|
||||
got := dedupeExternalMedia(in)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d", len(got))
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ func NewFanartProvider(cfg *config.Config, log *zap.Logger) *FanartProvider {
|
||||
return &FanartProvider{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
client: NewExternalHTTPClient(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,10 +61,10 @@ func (f *FanartProvider) MovieArtwork(ctx context.Context, tmdbID int) (*Artwork
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
type page struct {
|
||||
MoviePoster []entry `json:"movieposter"`
|
||||
MovieBackgr []entry `json:"moviebackground"`
|
||||
HDLogo []entry `json:"hdmovielogo"`
|
||||
MovieThumb []entry `json:"moviethumb"`
|
||||
MoviePoster []entry `json:"movieposter"`
|
||||
MovieBackgr []entry `json:"moviebackground"`
|
||||
HDLogo []entry `json:"hdmovielogo"`
|
||||
MovieThumb []entry `json:"moviethumb"`
|
||||
}
|
||||
u := fmt.Sprintf("https://webservice.fanart.tv/v3/movies/%d?api_key=%s",
|
||||
tmdbID, f.cfg.Secrets.FanartAPIKey)
|
||||
|
||||
@@ -20,24 +20,22 @@ import (
|
||||
|
||||
// AutoInstallFFmpeg 在启动时检测并自动安装 ffmpeg/ffprobe
|
||||
func AutoInstallFFmpeg(log *zap.Logger, cfg *config.Config) (ffprobePath, ffmpegPath string) {
|
||||
// 1. 检查配置中是否已指定路径
|
||||
if cfg.App.FFprobePath != "" {
|
||||
if _, err := os.Stat(cfg.App.FFprobePath); err == nil {
|
||||
log.Info("使用配置的 ffprobe", zap.String("path", cfg.App.FFprobePath))
|
||||
return cfg.App.FFprobePath, cfg.App.FFmpegPath
|
||||
}
|
||||
// 1. 优先使用配置 / PATH / 本机常见软件目录中的现有工具。
|
||||
if path, err := resolveLocalExecutable(cfg.App.FFprobePath, "ffprobe"); err == nil {
|
||||
ffprobePath = path
|
||||
cfg.App.FFprobePath = path
|
||||
log.Info("found local ffprobe", zap.String("path", path))
|
||||
}
|
||||
if path, err := resolveLocalExecutable(cfg.App.FFmpegPath, "ffmpeg"); err == nil {
|
||||
ffmpegPath = path
|
||||
cfg.App.FFmpegPath = path
|
||||
log.Info("found local ffmpeg", zap.String("path", path))
|
||||
}
|
||||
if ffprobePath != "" || ffmpegPath != "" {
|
||||
return ffprobePath, ffmpegPath
|
||||
}
|
||||
|
||||
// 2. 检查系统 PATH
|
||||
if path, err := exec.LookPath("ffprobe"); err == nil {
|
||||
log.Info("在 PATH 中找到 ffprobe", zap.String("path", path))
|
||||
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
return path, ffmpegPath
|
||||
}
|
||||
return path, ""
|
||||
}
|
||||
|
||||
// 3. 检查默认安装位置
|
||||
// 2. 检查默认安装位置。
|
||||
defaultDir := getDefaultInstallDir()
|
||||
ffprobeDefault := filepath.Join(defaultDir, "bin", "ffprobe.exe")
|
||||
ffmpegDefault := filepath.Join(defaultDir, "bin", "ffmpeg.exe")
|
||||
@@ -47,7 +45,7 @@ func AutoInstallFFmpeg(log *zap.Logger, cfg *config.Config) (ffprobePath, ffmpeg
|
||||
return ffprobeDefault, ffmpegDefault
|
||||
}
|
||||
|
||||
// 4. 尝试自动安装
|
||||
// 3. 尝试自动安装。
|
||||
log.Warn("未找到 ffmpeg/ffprobe,尝试自动安装...")
|
||||
installed, err := tryAutoInstall(log, defaultDir)
|
||||
if err != nil {
|
||||
|
||||
+70
-17
@@ -13,7 +13,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -48,25 +50,41 @@ func (f *FFprobeService) Probe(ctx context.Context, path string) (*ProbeResult,
|
||||
if f == nil {
|
||||
return nil, errors.New("ffprobe service nil")
|
||||
}
|
||||
bin := f.cfg.App.FFprobePath
|
||||
if bin == "" {
|
||||
bin = "ffprobe"
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
if bin, err := resolveLocalExecutable(f.cfg.App.FFprobePath, "ffprobe"); err == nil {
|
||||
f.cfg.App.FFprobePath = bin
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(probeCtx, bin,
|
||||
"-v", "error",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
path,
|
||||
)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ffprobe %s: %w", path, err)
|
||||
cmd := exec.CommandContext(probeCtx, bin,
|
||||
"-v", "error",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
path,
|
||||
)
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
return parseProbeJSON(out)
|
||||
}
|
||||
if f.log != nil {
|
||||
f.log.Debug("ffprobe failed, trying ffmpeg fallback", zap.String("path", path), zap.Error(err))
|
||||
}
|
||||
}
|
||||
return parseProbeJSON(out)
|
||||
return f.probeWithFFmpeg(ctx, path)
|
||||
}
|
||||
|
||||
func (f *FFprobeService) probeWithFFmpeg(ctx context.Context, path string) (*ProbeResult, error) {
|
||||
bin, err := resolveLocalExecutable(f.cfg.App.FFmpegPath, "ffmpeg")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ffprobe/ffmpeg unavailable: %w", err)
|
||||
}
|
||||
f.cfg.App.FFmpegPath = bin
|
||||
out, _ := commandOutput(ctx, 30*time.Second, bin, "-hide_banner", "-i", path)
|
||||
res := parseFFmpegProbeText(string(out))
|
||||
if res.VideoCodec == "" && res.AudioCodec == "" && res.DurationSec == 0 {
|
||||
return nil, fmt.Errorf("ffmpeg probe %s: no stream metadata parsed", path)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// rawProbe mirrors the relevant fields of `ffprobe -show_format -show_streams`.
|
||||
@@ -108,3 +126,38 @@ func parseProbeJSON(data []byte) (*ProbeResult, error) {
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
var (
|
||||
ffmpegDurationRE = regexp.MustCompile(`Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)`)
|
||||
ffmpegInputRE = regexp.MustCompile(`Input #\d+,\s*(.+?),\s*from`)
|
||||
ffmpegVideoRE = regexp.MustCompile(`Video:\s*([^,\s]+).*?(\d{2,5})x(\d{2,5})`)
|
||||
ffmpegAudioRE = regexp.MustCompile(`Audio:\s*([^,\s]+)`)
|
||||
)
|
||||
|
||||
func parseFFmpegProbeText(text string) *ProbeResult {
|
||||
res := &ProbeResult{}
|
||||
if match := ffmpegInputRE.FindStringSubmatch(text); len(match) == 2 {
|
||||
res.Container = strings.TrimSpace(match[1])
|
||||
}
|
||||
if match := ffmpegDurationRE.FindStringSubmatch(text); len(match) == 4 {
|
||||
hours, _ := strconv.Atoi(match[1])
|
||||
minutes, _ := strconv.Atoi(match[2])
|
||||
seconds, _ := strconv.ParseFloat(match[3], 64)
|
||||
res.DurationSec = hours*3600 + minutes*60 + int(seconds)
|
||||
}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if res.VideoCodec == "" {
|
||||
if match := ffmpegVideoRE.FindStringSubmatch(line); len(match) == 4 {
|
||||
res.VideoCodec = strings.TrimSpace(match[1])
|
||||
res.Width, _ = strconv.Atoi(match[2])
|
||||
res.Height, _ = strconv.Atoi(match[3])
|
||||
}
|
||||
}
|
||||
if res.AudioCodec == "" {
|
||||
if match := ffmpegAudioRE.FindStringSubmatch(line); len(match) == 2 {
|
||||
res.AudioCodec = strings.TrimSpace(match[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseFFmpegProbeText(t *testing.T) {
|
||||
text := `Input #0, matroska,webm, from 'show.mkv':
|
||||
Duration: 00:23:42.11, start: 0.000000, bitrate: 5132 kb/s
|
||||
Stream #0:0: Video: h264 (Main), yuv420p(progressive), 1920x1080 [SAR 1:1 DAR 16:9], 23.98 fps
|
||||
Stream #0:1(jpn): Audio: eac3, 48000 Hz, stereo, fltp, 128 kb/s (default)`
|
||||
|
||||
got := parseFFmpegProbeText(text)
|
||||
if got.Container != "matroska,webm" {
|
||||
t.Fatalf("container = %q", got.Container)
|
||||
}
|
||||
if got.DurationSec != 1422 {
|
||||
t.Fatalf("duration = %d", got.DurationSec)
|
||||
}
|
||||
if got.VideoCodec != "h264" || got.Width != 1920 || got.Height != 1080 {
|
||||
t.Fatalf("video = %#v", got)
|
||||
}
|
||||
if got.AudioCodec != "eac3" {
|
||||
t.Fatalf("audio = %q", got.AudioCodec)
|
||||
}
|
||||
}
|
||||
@@ -77,15 +77,9 @@ type ImageProxy struct {
|
||||
// NewImageProxy is the constructor.
|
||||
func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||||
// Honor HTTP(S)_PROXY env vars so deployments behind GFW can pull
|
||||
// from image.tmdb.org via their HTTP proxy without extra config.
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
MaxIdleConns: 32,
|
||||
MaxIdleConnsPerHost: 8,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
// from image.tmdb.org via their HTTP proxy without extra config. On
|
||||
// Windows we also honor the current user's system proxy settings.
|
||||
transport := NewExternalTransport()
|
||||
return &ImageProxy{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
@@ -112,6 +106,24 @@ func (p *ImageProxy) validateURL(raw string) (*url.URL, error) {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func isLocalImagePath(raw string) bool {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || isHTTPish(raw) {
|
||||
return false
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(raw))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isHTTPish(raw string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(raw), "http://") || strings.HasPrefix(strings.ToLower(raw), "https://")
|
||||
}
|
||||
|
||||
// detectContentType returns the MIME type of data using the first 512 bytes.
|
||||
func detectContentType(data []byte) string {
|
||||
if len(data) > 512 {
|
||||
@@ -132,6 +144,24 @@ func servePlaceholder(w http.ResponseWriter) {
|
||||
// Serve writes the requested image to w. Caller is expected to validate
|
||||
// the JWT before invoking it.
|
||||
func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error {
|
||||
if isLocalImagePath(raw) {
|
||||
path := filepath.Clean(raw)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || len(data) == 0 {
|
||||
servePlaceholder(w)
|
||||
return nil
|
||||
}
|
||||
stat, _ := os.Stat(path)
|
||||
modTime := time.Now()
|
||||
if stat != nil {
|
||||
modTime = stat.ModTime()
|
||||
}
|
||||
w.Header().Set("Content-Type", detectContentType(data))
|
||||
w.Header().Set("Cache-Control", "public, max-age=604800")
|
||||
http.ServeContent(w, r, filepath.Base(path), modTime, bytes.NewReader(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
u, err := p.validateURL(raw)
|
||||
if err != nil {
|
||||
// Bad URL is the only request-side error; everything else falls
|
||||
@@ -171,7 +201,11 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.R
|
||||
servePlaceholder(w)
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("User-Agent", "MediaStationGo/0.1")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
||||
if strings.Contains(host, "doubanio.com") {
|
||||
req.Header.Set("Referer", "https://movie.douban.com/")
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
)
|
||||
|
||||
func TestImageProxyServesLocalImagePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
imagePath := filepath.Join(dir, "episode-thumb.png")
|
||||
if err := os.WriteFile(imagePath, transparent1x1PNG, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(dir, "cache")}}, zap.NewNop())
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/img", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
if err := proxy.Serve(t.Context(), rec, req, imagePath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got == "" {
|
||||
t.Fatal("missing content-type")
|
||||
}
|
||||
if rec.Body.Len() != len(transparent1x1PNG) {
|
||||
t.Fatalf("body length = %d, want %d", rec.Body.Len(), len(transparent1x1PNG))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LocalMetadata contains metadata read from Kodi/Jellyfin sidecar NFO files.
|
||||
type LocalMetadata struct {
|
||||
Title string
|
||||
OriginalName string
|
||||
AdultCode string
|
||||
Year int
|
||||
Overview string
|
||||
Rating float32
|
||||
PosterURL string
|
||||
BackdropURL string
|
||||
TMDbID int
|
||||
SeasonNum int
|
||||
EpisodeNum int
|
||||
Genres string
|
||||
Countries string
|
||||
Languages string
|
||||
NSFW bool
|
||||
HasNFO bool
|
||||
HasArtwork bool
|
||||
}
|
||||
|
||||
type nfoUniqueID struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type nfoFanart struct {
|
||||
Value string `xml:",chardata"`
|
||||
Thumbs []string `xml:"thumb"`
|
||||
}
|
||||
|
||||
type nfoArt struct {
|
||||
Poster string `xml:"poster"`
|
||||
Thumb string `xml:"thumb"`
|
||||
Fanart string `xml:"fanart"`
|
||||
Backdrop string `xml:"backdrop"`
|
||||
Background string `xml:"background"`
|
||||
Banner string `xml:"banner"`
|
||||
Landscape string `xml:"landscape"`
|
||||
}
|
||||
|
||||
type nfoDocument struct {
|
||||
XMLName xml.Name `xml:""`
|
||||
Title string `xml:"title"`
|
||||
ShowTitle string `xml:"showtitle"`
|
||||
OriginalTitle string `xml:"originaltitle"`
|
||||
SortTitle string `xml:"sorttitle"`
|
||||
Num string `xml:"num"`
|
||||
Year int `xml:"year"`
|
||||
Premiered string `xml:"premiered"`
|
||||
ReleaseDate string `xml:"releasedate"`
|
||||
Release string `xml:"release"`
|
||||
Aired string `xml:"aired"`
|
||||
Plot string `xml:"plot"`
|
||||
Outline string `xml:"outline"`
|
||||
OriginalPlot string `xml:"originalplot"`
|
||||
Rating float32 `xml:"rating"`
|
||||
Poster string `xml:"poster"`
|
||||
Thumbs []string `xml:"thumb"`
|
||||
Fanart nfoFanart `xml:"fanart"`
|
||||
Art nfoArt `xml:"art"`
|
||||
TMDbID int `xml:"tmdbid"`
|
||||
UniqueIDs []nfoUniqueID `xml:"uniqueid"`
|
||||
Season int `xml:"season"`
|
||||
Episode int `xml:"episode"`
|
||||
Genres []string `xml:"genre"`
|
||||
Tags []string `xml:"tag"`
|
||||
Countries []string `xml:"country"`
|
||||
Languages []string `xml:"language"`
|
||||
Studio string `xml:"studio"`
|
||||
Maker string `xml:"maker"`
|
||||
Publisher string `xml:"publisher"`
|
||||
Label string `xml:"label"`
|
||||
Directors []string `xml:"director"`
|
||||
Actors []nfoActor `xml:"actor"`
|
||||
}
|
||||
|
||||
type nfoActor struct {
|
||||
Name string `xml:"name"`
|
||||
Role string `xml:"role"`
|
||||
}
|
||||
|
||||
// ReadLocalMetadata reads sidecar NFO files for a media path. For TV/anime it
|
||||
// merges show-level tvshow.nfo with episode-level sidecar metadata.
|
||||
func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMetadata, error) {
|
||||
if seriesLike {
|
||||
return readSeriesMetadata(mediaPath, libraryRoot)
|
||||
}
|
||||
doc, path, err := findMovieNFO(mediaPath, libraryRoot)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return metadataFromArtwork(mediaPath, ""), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
meta := metadataFromDoc(doc, filepath.Dir(path), false)
|
||||
mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path))
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
|
||||
mediaDir := filepath.Dir(mediaPath)
|
||||
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
|
||||
adultCode := AdultCodeFromMediaPath(mediaPath)
|
||||
names := []string{
|
||||
base + ".nfo",
|
||||
"movie.nfo",
|
||||
filepath.Base(mediaDir) + ".nfo",
|
||||
}
|
||||
if adultCode != "" {
|
||||
names = append([]string{adultCode + ".nfo", strings.ReplaceAll(adultCode, "-", "") + ".nfo"}, names...)
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, name := range names {
|
||||
if name == ".nfo" || name == "" {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(mediaDir, name)
|
||||
key := strings.ToLower(filepath.Clean(path))
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if doc, _, err := readNFO(path); err == nil {
|
||||
return doc, path, nil
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
if libraryRoot == "" || !samePath(mediaDir, filepath.Clean(libraryRoot)) {
|
||||
matches, _ := filepath.Glob(filepath.Join(mediaDir, "*.nfo"))
|
||||
if adultCode != "" {
|
||||
codeKey := strings.ToLower(strings.ReplaceAll(adultCode, "-", ""))
|
||||
for _, match := range matches {
|
||||
baseKey := strings.ToLower(strings.ReplaceAll(strings.TrimSuffix(filepath.Base(match), filepath.Ext(match)), "-", ""))
|
||||
if strings.Contains(baseKey, codeKey) || strings.Contains(codeKey, baseKey) {
|
||||
if doc, _, err := readNFO(match); err == nil {
|
||||
return doc, match, nil
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
if doc, _, err := readNFO(matches[0]); err == nil {
|
||||
return doc, matches[0], nil
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, "", os.ErrNotExist
|
||||
}
|
||||
|
||||
func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) {
|
||||
var meta *LocalMetadata
|
||||
showBaseDir := ""
|
||||
if showDoc, showPath, err := findShowNFO(mediaPath, libraryRoot); err == nil && showDoc != nil {
|
||||
showBaseDir = filepath.Dir(showPath)
|
||||
meta = metadataFromDoc(showDoc, showBaseDir, true)
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if episodeDoc, episodePath, err := readNFO(nfoPath(mediaPath)); err == nil {
|
||||
episodeMeta := metadataFromDoc(episodeDoc, filepath.Dir(episodePath), true)
|
||||
if meta == nil {
|
||||
meta = &LocalMetadata{}
|
||||
}
|
||||
mergeEpisodeMetadata(meta, episodeMeta, episodeDoc)
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
if meta == nil {
|
||||
meta = metadataFromArtwork(mediaPath, showBaseDir)
|
||||
} else {
|
||||
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func readNFO(path string) (*nfoDocument, string, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var doc nfoDocument
|
||||
if err := xml.Unmarshal(body, &doc); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &doc, path, nil
|
||||
}
|
||||
|
||||
func findShowNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
|
||||
dir := filepath.Dir(mediaPath)
|
||||
root := filepath.Clean(libraryRoot)
|
||||
for {
|
||||
names := []string{"tvshow.nfo", "series.nfo"}
|
||||
base := filepath.Base(dir)
|
||||
if seasonFromDir(base) > 0 {
|
||||
parentBase := filepath.Base(filepath.Dir(dir))
|
||||
names = append(names, parentBase+".nfo")
|
||||
}
|
||||
names = append(names, base+".nfo")
|
||||
for _, name := range names {
|
||||
path := filepath.Join(dir, name)
|
||||
if doc, _, err := readNFO(path); err == nil {
|
||||
return doc, path, nil
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
if samePath(dir, root) {
|
||||
return nil, "", os.ErrNotExist
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return nil, "", os.ErrNotExist
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
func metadataFromDoc(doc *nfoDocument, baseDir string, seriesLike bool) *LocalMetadata {
|
||||
if doc == nil {
|
||||
return nil
|
||||
}
|
||||
meta := &LocalMetadata{
|
||||
Title: cleanXMLText(doc.Title),
|
||||
OriginalName: cleanXMLText(doc.OriginalTitle),
|
||||
AdultCode: normalizeAdultCode(doc.Num),
|
||||
Year: doc.Year,
|
||||
Overview: firstText(doc.Plot, doc.Outline, doc.OriginalPlot),
|
||||
Rating: doc.Rating,
|
||||
PosterURL: firstRemoteURL(baseDir, append([]string{doc.Poster, doc.Art.Poster, doc.Art.Thumb}, doc.Thumbs...)...),
|
||||
BackdropURL: firstRemoteURL(baseDir, append([]string{doc.Fanart.Value, doc.Art.Fanart, doc.Art.Backdrop, doc.Art.Background, doc.Art.Banner, doc.Art.Landscape}, doc.Fanart.Thumbs...)...),
|
||||
TMDbID: doc.TMDbID,
|
||||
SeasonNum: doc.Season,
|
||||
EpisodeNum: doc.Episode,
|
||||
Genres: joinNFOValues(adultAwareGenres(doc)),
|
||||
Countries: joinNFOValues(doc.Countries),
|
||||
Languages: joinNFOValues(doc.Languages),
|
||||
HasNFO: true,
|
||||
}
|
||||
if meta.AdultCode == "" {
|
||||
meta.AdultCode = normalizeAdultCode(firstText(doc.OriginalTitle, doc.SortTitle, doc.Title))
|
||||
}
|
||||
if meta.AdultCode != "" {
|
||||
meta.NSFW = true
|
||||
if meta.OriginalName == "" || strings.EqualFold(meta.OriginalName, meta.Title) {
|
||||
meta.OriginalName = meta.AdultCode
|
||||
}
|
||||
}
|
||||
if seriesLike && cleanXMLText(doc.ShowTitle) != "" {
|
||||
meta.Title = cleanXMLText(doc.ShowTitle)
|
||||
if cleanXMLText(doc.Title) != "" {
|
||||
meta.OriginalName = cleanXMLText(doc.Title)
|
||||
}
|
||||
}
|
||||
if meta.Year == 0 {
|
||||
meta.Year = yearFromDate(firstText(doc.Premiered, doc.ReleaseDate, doc.Release, doc.Aired))
|
||||
}
|
||||
if meta.TMDbID == 0 {
|
||||
meta.TMDbID = tmdbIDFromUniqueIDs(doc.UniqueIDs)
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func adultAwareGenres(doc *nfoDocument) []string {
|
||||
if doc == nil {
|
||||
return nil
|
||||
}
|
||||
values := make([]string, 0, len(doc.Genres)+len(doc.Tags)+len(doc.Actors)+4)
|
||||
values = append(values, doc.Genres...)
|
||||
values = append(values, doc.Tags...)
|
||||
for _, value := range []string{doc.Studio, doc.Maker, doc.Publisher, doc.Label} {
|
||||
if cleanXMLText(value) != "" {
|
||||
values = append(values, cleanXMLText(value))
|
||||
}
|
||||
}
|
||||
for _, value := range doc.Directors {
|
||||
if cleanXMLText(value) != "" {
|
||||
values = append(values, cleanXMLText(value))
|
||||
}
|
||||
}
|
||||
for _, actor := range doc.Actors {
|
||||
if cleanXMLText(actor.Name) != "" {
|
||||
values = append(values, cleanXMLText(actor.Name))
|
||||
} else if cleanXMLText(actor.Role) != "" {
|
||||
values = append(values, cleanXMLText(actor.Role))
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func metadataFromArtwork(mediaPath, showBaseDir string) *LocalMetadata {
|
||||
meta := &LocalMetadata{}
|
||||
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
|
||||
if meta.PosterURL == "" && meta.BackdropURL == "" {
|
||||
return nil
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
|
||||
if meta == nil {
|
||||
return
|
||||
}
|
||||
mediaDir := filepath.Dir(mediaPath)
|
||||
if meta.PosterURL == "" {
|
||||
meta.PosterURL = firstExistingImage(mediaDir, localPosterCandidates(mediaPath)...)
|
||||
}
|
||||
if meta.BackdropURL == "" {
|
||||
dirs := []string{mediaDir, showBaseDir}
|
||||
for _, dir := range dirs {
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
if img := firstExistingImage(dir, localBackdropCandidates(mediaPath)...); img != "" {
|
||||
meta.BackdropURL = img
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if meta.PosterURL != "" || meta.BackdropURL != "" {
|
||||
meta.HasArtwork = true
|
||||
}
|
||||
}
|
||||
|
||||
func mergeEpisodeMetadata(dst, episode *LocalMetadata, doc *nfoDocument) {
|
||||
showTitle := cleanXMLText(doc.ShowTitle)
|
||||
episodeTitle := cleanXMLText(doc.Title)
|
||||
if showTitle != "" {
|
||||
dst.Title = showTitle
|
||||
if episodeTitle != "" {
|
||||
dst.OriginalName = episodeTitle
|
||||
}
|
||||
} else if dst.Title != "" && episodeTitle != "" && episodeTitle != dst.Title {
|
||||
dst.OriginalName = episodeTitle
|
||||
} else if dst.Title == "" && episodeTitle != "" {
|
||||
dst.Title = episodeTitle
|
||||
}
|
||||
if dst.OriginalName == "" && episode.OriginalName != "" {
|
||||
dst.OriginalName = episode.OriginalName
|
||||
}
|
||||
if episode.Year > 0 {
|
||||
dst.Year = episode.Year
|
||||
}
|
||||
if episode.Overview != "" {
|
||||
dst.Overview = episode.Overview
|
||||
}
|
||||
if episode.Rating > 0 {
|
||||
dst.Rating = episode.Rating
|
||||
}
|
||||
if episode.PosterURL != "" {
|
||||
dst.PosterURL = episode.PosterURL
|
||||
}
|
||||
if episode.BackdropURL != "" {
|
||||
dst.BackdropURL = episode.BackdropURL
|
||||
}
|
||||
if episode.TMDbID > 0 {
|
||||
dst.TMDbID = episode.TMDbID
|
||||
}
|
||||
if episode.SeasonNum > 0 {
|
||||
dst.SeasonNum = episode.SeasonNum
|
||||
}
|
||||
if episode.EpisodeNum > 0 {
|
||||
dst.EpisodeNum = episode.EpisodeNum
|
||||
}
|
||||
if episode.Genres != "" {
|
||||
dst.Genres = episode.Genres
|
||||
}
|
||||
if episode.Countries != "" {
|
||||
dst.Countries = episode.Countries
|
||||
}
|
||||
if episode.Languages != "" {
|
||||
dst.Languages = episode.Languages
|
||||
}
|
||||
}
|
||||
|
||||
func tmdbIDFromUniqueIDs(ids []nfoUniqueID) int {
|
||||
for _, id := range ids {
|
||||
if strings.EqualFold(strings.TrimSpace(id.Type), "tmdb") {
|
||||
v, _ := strconv.Atoi(strings.TrimSpace(id.Value))
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstRemoteURL(baseDir string, values ...string) string {
|
||||
for _, value := range values {
|
||||
value = cleanXMLText(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if isHTTPURL(value) {
|
||||
return value
|
||||
}
|
||||
if filepath.IsAbs(value) && fileExists(value) {
|
||||
return filepath.Clean(value)
|
||||
}
|
||||
if baseDir != "" {
|
||||
local := filepath.Join(baseDir, filepath.FromSlash(value))
|
||||
if fileExists(local) {
|
||||
return filepath.Clean(local)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func localPosterCandidates(mediaPath string) []string {
|
||||
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
|
||||
names := []string{
|
||||
base,
|
||||
base + "-thumb",
|
||||
base + ".thumb",
|
||||
base + "-cover",
|
||||
base + ".cover",
|
||||
base + "-poster",
|
||||
base + ".poster",
|
||||
"thumb",
|
||||
"poster",
|
||||
"folder",
|
||||
"cover",
|
||||
"movie",
|
||||
"show",
|
||||
}
|
||||
return append(adultArtworkNameCandidates(mediaPath, "poster"), names...)
|
||||
}
|
||||
|
||||
func localBackdropCandidates(mediaPath string) []string {
|
||||
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
|
||||
names := []string{
|
||||
base + "-fanart",
|
||||
base + ".fanart",
|
||||
base + "-backdrop",
|
||||
base + ".backdrop",
|
||||
base + "-background",
|
||||
"fanart",
|
||||
"backdrop",
|
||||
"background",
|
||||
"landscape",
|
||||
"banner",
|
||||
"clearart",
|
||||
}
|
||||
return append(adultArtworkNameCandidates(mediaPath, "backdrop"), names...)
|
||||
}
|
||||
|
||||
func adultArtworkNameCandidates(mediaPath, kind string) []string {
|
||||
code := AdultCodeFromMediaPath(mediaPath)
|
||||
if code == "" {
|
||||
return nil
|
||||
}
|
||||
compact := strings.ReplaceAll(code, "-", "")
|
||||
bases := []string{code, compact}
|
||||
out := make([]string, 0, len(bases)*6)
|
||||
for _, base := range bases {
|
||||
if kind == "poster" {
|
||||
out = append(out, base, base+"-poster", base+".poster", base+"-cover", base+".cover", base+"-thumb", base+".thumb")
|
||||
} else {
|
||||
out = append(out, base+"-fanart", base+".fanart", base+"-backdrop", base+".backdrop", base+"-background", base+"-landscape")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstExistingImage(dir string, names ...string) string {
|
||||
if dir == "" {
|
||||
return ""
|
||||
}
|
||||
for _, name := range names {
|
||||
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} {
|
||||
path := filepath.Join(dir, name+ext)
|
||||
if fileExists(path) {
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
func isHTTPURL(raw string) bool {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
||||
}
|
||||
|
||||
func firstText(values ...string) string {
|
||||
for _, value := range values {
|
||||
if text := cleanXMLText(value); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cleanXMLText(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func joinNFOValues(values []string) string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]struct{}{}
|
||||
for _, value := range values {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = cleanXMLText(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(part)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
func yearFromDate(value string) int {
|
||||
if len(value) < 4 {
|
||||
return 0
|
||||
}
|
||||
year, _ := strconv.Atoi(value[:4])
|
||||
return year
|
||||
}
|
||||
|
||||
func samePath(a, b string) bool {
|
||||
return strings.EqualFold(filepath.Clean(a), filepath.Clean(b))
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestReadLocalMovieMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "Inception.2010.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nfo := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<movie>
|
||||
<title>盗梦空间</title>
|
||||
<originaltitle>Inception</originaltitle>
|
||||
<year>2010</year>
|
||||
<plot>梦境盗窃。</plot>
|
||||
<rating>8.8</rating>
|
||||
<uniqueid type="tmdb">27205</uniqueid>
|
||||
<genre>科幻</genre>
|
||||
<genre>动作</genre>
|
||||
</movie>`
|
||||
if err := os.WriteFile(nfoPath(mediaPath), []byte(nfo), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, dir, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.Title != "盗梦空间" || got.OriginalName != "Inception" || got.Year != 2010 || got.TMDbID != 27205 {
|
||||
t.Fatalf("unexpected metadata: %+v", got)
|
||||
}
|
||||
if got.Genres != "科幻,动作" {
|
||||
t.Fatalf("genres = %q", got.Genres)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLocalEpisodeMetadataMergesShowAndEpisode(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
showDir := filepath.Join(root, "Show")
|
||||
seasonDir := filepath.Join(showDir, "Season 02")
|
||||
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(seasonDir, "Show - EP03.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(`<tvshow><title>正确剧名</title><year>2024</year><tmdbid>123</tmdbid></tvshow>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<episodedetails><title>第三集</title><season>2</season><episode>3</episode><plot>本集简介</plot></episodedetails>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, root, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.Title != "正确剧名" || got.OriginalName != "第三集" || got.SeasonNum != 2 || got.EpisodeNum != 3 {
|
||||
t.Fatalf("unexpected metadata: %+v", got)
|
||||
}
|
||||
if got.Overview != "本集简介" || got.TMDbID != 123 {
|
||||
t.Fatalf("episode/show merge failed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLocalVarietyMetadataUsesLocalArtwork(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
showDir := filepath.Join(root, "哈哈哈哈哈")
|
||||
seasonDir := filepath.Join(showDir, "Season 06")
|
||||
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(seasonDir, "哈哈哈哈哈 - S06E17.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(showDir, "哈哈哈哈哈.nfo"), []byte(`<tvshow><title>哈哈哈哈哈</title><genre>综艺</genre></tvshow>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
episodeThumb := filepath.Join(seasonDir, "哈哈哈哈哈 - S06E17-thumb.jpg")
|
||||
if err := os.WriteFile(episodeThumb, []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backdrop := filepath.Join(showDir, "fanart.jpg")
|
||||
if err := os.WriteFile(backdrop, []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, root, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("metadata is nil")
|
||||
}
|
||||
if got.Title != "哈哈哈哈哈" || got.Genres != "综艺" {
|
||||
t.Fatalf("unexpected metadata: %+v", got)
|
||||
}
|
||||
if got.PosterURL != episodeThumb {
|
||||
t.Fatalf("PosterURL = %q, want %q", got.PosterURL, episodeThumb)
|
||||
}
|
||||
if got.BackdropURL != backdrop {
|
||||
t.Fatalf("BackdropURL = %q, want %q", got.BackdropURL, backdrop)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLocalMetadataWithoutNFOStillFindsArtwork(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mediaPath := filepath.Join(root, "Movie.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
poster := filepath.Join(root, "Movie-poster.jpg")
|
||||
if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, root, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.PosterURL != poster {
|
||||
t.Fatalf("unexpected artwork metadata: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAdultLocalMetadataAndArtwork(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "SSIS-001")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(dir, "SSIS-001.mp4")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nfo := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<movie>
|
||||
<title>成人影片标题</title>
|
||||
<originaltitle>SSIS-001</originaltitle>
|
||||
<num>SSIS-001</num>
|
||||
<releasedate>2024-05-01</releasedate>
|
||||
<plot>本地简介</plot>
|
||||
<poster>SSIS-001-poster.jpg</poster>
|
||||
<fanart><thumb>SSIS-001-fanart.jpg</thumb></fanart>
|
||||
<studio>测试片商</studio>
|
||||
<genre>剧情</genre>
|
||||
<tag>中文字幕</tag>
|
||||
<actor><name>演员A</name></actor>
|
||||
</movie>`
|
||||
if err := os.WriteFile(nfoPath(mediaPath), []byte(nfo), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
poster := filepath.Join(dir, "SSIS-001-poster.jpg")
|
||||
if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fanart := filepath.Join(dir, "SSIS-001-fanart.jpg")
|
||||
if err := os.WriteFile(fanart, []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, root, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.Title != "成人影片标题" || got.AdultCode != "SSIS-001" || !got.NSFW {
|
||||
t.Fatalf("unexpected adult metadata: %+v", got)
|
||||
}
|
||||
if got.OriginalName != "SSIS-001" || got.Year != 2024 || got.Overview != "本地简介" {
|
||||
t.Fatalf("unexpected adult fields: %+v", got)
|
||||
}
|
||||
if got.PosterURL != poster || got.BackdropURL != fanart {
|
||||
t.Fatalf("artwork poster=%q fanart=%q", got.PosterURL, got.BackdropURL)
|
||||
}
|
||||
if got.Genres != "剧情,中文字幕,测试片商,演员A" {
|
||||
t.Fatalf("genres = %q", got.Genres)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAdultMovieNFOFallbackInSingleMovieFolder(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "MIDV-123")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(dir, "video.mp4")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "movie.nfo"), []byte(`<movie><title>本地番号电影</title><num>MIDV-123</num></movie>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
poster := filepath.Join(dir, "video-cover.jpg")
|
||||
if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, root, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.Title != "本地番号电影" || got.AdultCode != "MIDV-123" || got.PosterURL != poster {
|
||||
t.Fatalf("unexpected fallback metadata: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanLibraryUsesLocalMetadata(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
seasonDir := filepath.Join(root, "Show", "Season 02")
|
||||
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(seasonDir, "Show - EP03.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "Show", "tvshow.nfo"), []byte(`<tvshow><title>本地剧名</title><year>2025</year></tvshow>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<episodedetails><title>本地第三集</title><season>2</season><episode>3</episode></episodedetails>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "TV", Path: root, Type: "tv", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
|
||||
res, err := scanner.ScanLibrary(t.Context(), lib.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.LocalMetadata != 1 {
|
||||
t.Fatalf("LocalMetadata = %d, want 1", res.LocalMetadata)
|
||||
}
|
||||
if res.Added != 1 || res.Updated != 0 {
|
||||
t.Fatalf("scan counts added=%d updated=%d, want 1/0", res.Added, res.Updated)
|
||||
}
|
||||
var media model.Media
|
||||
if err := db.First(&media, "path = ?", mediaPath).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.Title != "本地剧名" || media.OriginalName != "本地第三集" || media.SeasonNum != 2 || media.EpisodeNum != 3 || media.ScrapeStatus != "matched" {
|
||||
t.Fatalf("unexpected scanned media: %+v", media)
|
||||
}
|
||||
|
||||
res, err = scanner.ScanLibrary(t.Context(), lib.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Added != 0 || res.Updated != 1 {
|
||||
t.Fatalf("repeat scan counts added=%d updated=%d, want 0/1", res.Added, res.Updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanLibraryParsesEpisodesForMovieTypedLibrary(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
seasonDir := filepath.Join(root, "哈哈哈哈哈 (2020)", "Season 06")
|
||||
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(seasonDir, "哈哈哈哈哈 - S06E17 - 第 17 集.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "综艺", Path: root, Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
|
||||
if _, err := scanner.ScanLibrary(t.Context(), lib.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var media model.Media
|
||||
if err := db.First(&media, "path = ?", mediaPath).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.SeasonNum != 6 || media.EpisodeNum != 17 {
|
||||
t.Fatalf("season/episode = %d/%d, want 6/17", media.SeasonNum, media.EpisodeNum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanLibraryPrunesMissingMedia(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mediaPath := filepath.Join(root, "Show.S02E03.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "TV", Path: root, Type: "tv", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "Show",
|
||||
Path: filepath.Join(root, "old", "Show.S02E03.mkv"),
|
||||
SizeBytes: 123,
|
||||
ScrapeStatus: "pending",
|
||||
}
|
||||
if err := db.Create(&stale).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
|
||||
res, err := scanner.ScanLibrary(t.Context(), lib.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Removed != 1 {
|
||||
t.Fatalf("Removed = %d, want 1", res.Removed)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.Media{}).Where("library_id = ?", lib.ID).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("media count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
@@ -65,9 +65,12 @@ func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error {
|
||||
|
||||
// ListMedia paginates media items inside a library.
|
||||
func (s *MediaService) ListMedia(ctx context.Context, libraryID string, page, pageSize int) ([]model.Media, int64, error) {
|
||||
if pageSize <= 0 || pageSize > 200 {
|
||||
if pageSize <= 0 {
|
||||
pageSize = 50
|
||||
}
|
||||
if pageSize > 2000 {
|
||||
pageSize = 2000
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
var (
|
||||
classifierEpisodeRE = regexp.MustCompile(`(?i)\bS\d{1,2}E\d{1,3}\b|第\s*\d+\s*[集期]|(?:^|[\s._-])E\d{1,3}(?:[\s._-]|$)`)
|
||||
classifierSeasonRE = regexp.MustCompile(`(?i)\bS\d{1,2}\b|第\s*\d+\s*季`)
|
||||
)
|
||||
|
||||
type mediaClassifyInput struct {
|
||||
MediaType string
|
||||
Title string
|
||||
Languages []string
|
||||
Countries []string
|
||||
Genres []string
|
||||
Category string
|
||||
}
|
||||
|
||||
func classifyMediaCategory(input mediaClassifyInput, categories map[string]string) string {
|
||||
mediaType := normalizeMediaType(input.MediaType, input.Title, input.Category)
|
||||
genres := normalizeTokens(input.Genres...)
|
||||
countries := normalizeTokens(input.Countries...)
|
||||
languages := normalizeTokens(input.Languages...)
|
||||
text := strings.ToLower(input.Title + " " + input.Category + " " + strings.Join(input.Genres, " "))
|
||||
|
||||
isChinese := hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN") || hasAny(countries, "CN", "TW", "HK", "MO")
|
||||
isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || strings.Contains(text, "日番")
|
||||
isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP")
|
||||
isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG")
|
||||
isWestern := hasAny(countries,
|
||||
"US", "GB", "UK", "FR", "DE", "CA", "AU", "NZ", "IE", "NL", "SE", "NO", "DK",
|
||||
"FI", "ES", "IT", "PT", "AT", "CH", "BE", "RU",
|
||||
)
|
||||
|
||||
hasGenre := func(values ...string) bool {
|
||||
for _, value := range values {
|
||||
if hasAny(genres, strings.ToUpper(value)) || strings.Contains(text, strings.ToLower(value)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
switch mediaType {
|
||||
case "movie":
|
||||
if hasGenre("16", "ANIMATION", "动画", "动漫") {
|
||||
return categoryName(categories, "animation_movie", "动画电影")
|
||||
}
|
||||
if isChinese {
|
||||
return categoryName(categories, "chinese_movie", "华语电影")
|
||||
}
|
||||
if isEastAsian {
|
||||
return categoryName(categories, "jk_movie", "日韩电影")
|
||||
}
|
||||
if isWestern {
|
||||
return categoryName(categories, "euus_movie", "欧美电影")
|
||||
}
|
||||
return categoryName(categories, "foreign_movie", "外语电影")
|
||||
case "anime":
|
||||
if isChinese {
|
||||
return categoryName(categories, "cn_anime", "国漫")
|
||||
}
|
||||
return categoryName(categories, "jp_anime", "日番")
|
||||
case "variety":
|
||||
return categoryName(categories, "variety", "综艺")
|
||||
case "tv":
|
||||
if hasGenre("10764", "10767", "REALITY", "TALK", "综艺", "真人秀", "脱口秀") {
|
||||
return categoryName(categories, "variety", "综艺")
|
||||
}
|
||||
if hasGenre("99", "DOCUMENTARY", "纪录", "纪录片") {
|
||||
return categoryName(categories, "documentary", "纪录片")
|
||||
}
|
||||
if hasGenre("10762", "KIDS", "儿童") {
|
||||
return categoryName(categories, "children", "儿童")
|
||||
}
|
||||
if hasGenre("16", "ANIMATION", "动画", "动漫") {
|
||||
if isChinese {
|
||||
return categoryName(categories, "cn_anime", "国漫")
|
||||
}
|
||||
return categoryName(categories, "jp_anime", "日番")
|
||||
}
|
||||
if isChinese {
|
||||
return categoryName(categories, "domestic_tv", "国产剧")
|
||||
}
|
||||
if isEastAsian {
|
||||
return categoryName(categories, "jk_tv", "日韩剧")
|
||||
}
|
||||
if isWestern {
|
||||
return categoryName(categories, "euus_tv", "欧美剧")
|
||||
}
|
||||
return categoryName(categories, "uncategorized_tv", "未分类")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeMediaType(mediaType, title, category string) string {
|
||||
raw := strings.ToLower(strings.TrimSpace(mediaType))
|
||||
switch raw {
|
||||
case "movie", "film":
|
||||
return "movie"
|
||||
case "tv", "series", "show", "drama":
|
||||
return "tv"
|
||||
case "anime", "animation":
|
||||
return "anime"
|
||||
case "variety":
|
||||
return "variety"
|
||||
}
|
||||
text := strings.ToLower(title + " " + category)
|
||||
switch {
|
||||
case strings.Contains(text, "movie") || strings.Contains(text, "电影"):
|
||||
return "movie"
|
||||
case strings.Contains(text, "anime") || strings.Contains(text, "bangumi") || strings.Contains(text, "动漫") || strings.Contains(text, "动画"):
|
||||
return "anime"
|
||||
case strings.Contains(text, "variety") || strings.Contains(text, "综艺") || strings.Contains(text, "真人秀"):
|
||||
return "variety"
|
||||
case classifierEpisodeRE.MatchString(text) || classifierSeasonRE.MatchString(text) || strings.Contains(text, "tv") || strings.Contains(text, "剧集") || strings.Contains(text, "电视剧"):
|
||||
return "tv"
|
||||
default:
|
||||
return "movie"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTokens(values ...string) map[string]struct{} {
|
||||
out := make(map[string]struct{})
|
||||
for _, value := range values {
|
||||
for _, part := range strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == '/' || r == '|' || r == ';'
|
||||
}) {
|
||||
part = strings.ToUpper(strings.TrimSpace(part))
|
||||
if part != "" {
|
||||
out[part] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasAny(values map[string]struct{}, needles ...string) bool {
|
||||
for _, needle := range needles {
|
||||
if _, ok := values[strings.ToUpper(needle)]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func categoryName(categories map[string]string, key, fallback string) string {
|
||||
if categories != nil {
|
||||
if name := strings.TrimSpace(categories[key]); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (o *OrganizerService) categoryMap() map[string]string {
|
||||
if o == nil || o.cfg == nil || o.cfg.Organizer.Categories == nil {
|
||||
return nil
|
||||
}
|
||||
return o.cfg.Organizer.Categories
|
||||
}
|
||||
|
||||
func (o *OrganizerService) classifyMedia(ctx context.Context, m *model.Media, mediaType string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if m.Languages == "" && m.Countries == "" && m.Genres == "" && o != nil && o.repo != nil && o.repo.Media != nil {
|
||||
if fresh, err := o.repo.Media.FindByID(ctx, m.ID); err == nil && fresh != nil {
|
||||
m = fresh
|
||||
}
|
||||
}
|
||||
return classifyMediaCategory(mediaClassifyInput{
|
||||
MediaType: mediaType,
|
||||
Title: m.Title + " " + m.OriginalName,
|
||||
Languages: parseCommaList(m.Languages),
|
||||
Countries: parseCommaList(m.Countries),
|
||||
Genres: parseCommaList(m.Genres),
|
||||
}, o.categoryMap())
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) classifySubscriptionItem(ctx context.Context, sub *model.Subscription, title, sourceCategory string) (string, string) {
|
||||
mediaType := normalizeMediaType(sub.MediaType, title+" "+sub.Name+" "+sub.Filter, sourceCategory)
|
||||
category := strings.TrimSpace(sub.MediaCategory)
|
||||
if category == "" {
|
||||
category = classifyMediaCategory(mediaClassifyInput{
|
||||
MediaType: mediaType,
|
||||
Title: title + " " + sub.Name + " " + sub.Filter,
|
||||
Category: sourceCategory,
|
||||
}, s.categoryMap())
|
||||
}
|
||||
return mediaType, category
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) categoryMap() map[string]string {
|
||||
if s == nil || s.cfg == nil || s.cfg.Organizer.Categories == nil {
|
||||
return nil
|
||||
}
|
||||
return s.cfg.Organizer.Categories
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) resolveSubscriptionSavePath(ctx context.Context, sub *model.Subscription, mediaType, category string) string {
|
||||
if sub == nil {
|
||||
return ""
|
||||
}
|
||||
base := strings.TrimSpace(sub.SavePath)
|
||||
if base == "" && s.repo != nil && s.repo.Setting != nil {
|
||||
base, _ = s.repo.Setting.Get(ctx, "qbittorrent.savepath")
|
||||
}
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
if !s.isSmartClassifyEnabled(ctx) || category == "" {
|
||||
return base
|
||||
}
|
||||
if mediaType != "" {
|
||||
return filepath.Join(base, mediaType, sanitizeFilename(category))
|
||||
}
|
||||
return filepath.Join(base, sanitizeFilename(category))
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) isSmartClassifyEnabled(ctx context.Context) bool {
|
||||
if s != nil && s.repo != nil && s.repo.Setting != nil {
|
||||
val, err := s.repo.Setting.Get(ctx, "organizer.smart_classify")
|
||||
if err == nil && val != "" {
|
||||
return val == "true" || val == "1" || val == "on"
|
||||
}
|
||||
}
|
||||
return s != nil && s.cfg != nil && s.cfg.Organizer.SmartClassify
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestClassifyMediaCategoryMatchesMoviePilotStyleRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input mediaClassifyInput
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "movie animation first",
|
||||
input: mediaClassifyInput{
|
||||
MediaType: "movie",
|
||||
Title: "Robot Dreams",
|
||||
Countries: []string{"ES"},
|
||||
Genres: []string{"Animation"},
|
||||
},
|
||||
want: "动画电影",
|
||||
},
|
||||
{
|
||||
name: "tv variety by genre",
|
||||
input: mediaClassifyInput{
|
||||
MediaType: "tv",
|
||||
Title: "声生不息",
|
||||
Countries: []string{"CN"},
|
||||
Genres: []string{"Reality"},
|
||||
},
|
||||
want: "综艺",
|
||||
},
|
||||
{
|
||||
name: "anime china",
|
||||
input: mediaClassifyInput{
|
||||
MediaType: "anime",
|
||||
Countries: []string{"CN"},
|
||||
Genres: []string{"Animation"},
|
||||
},
|
||||
want: "国漫",
|
||||
},
|
||||
{
|
||||
name: "tv documentary before region",
|
||||
input: mediaClassifyInput{
|
||||
MediaType: "tv",
|
||||
Countries: []string{"US"},
|
||||
Genres: []string{"Documentary"},
|
||||
},
|
||||
want: "纪录片",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := classifyMediaCategory(tt.input, nil); got != tt.want {
|
||||
t.Fatalf("category = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionResolveClassifiedSavePath(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.Setting.Set(t.Context(), "organizer.smart_classify", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Setting.Set(t.Context(), "qbittorrent.savepath", filepath.Join("D:", "Downloads")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewSubscriptionService(&config.Config{}, zap.NewNop(), repos, nil, nil, nil)
|
||||
sub := &model.Subscription{Name: "声生不息 自动订阅", MediaType: "tv"}
|
||||
|
||||
mediaType, category := svc.classifySubscriptionItem(t.Context(), sub, "声生不息 S01E01", "综艺")
|
||||
if mediaType != "tv" || category != "综艺" {
|
||||
t.Fatalf("classification = %q/%q, want tv/综艺", mediaType, category)
|
||||
}
|
||||
got := svc.resolveSubscriptionSavePath(t.Context(), sub, mediaType, category)
|
||||
want := filepath.Join("D:", "Downloads", "tv", "综艺")
|
||||
if got != want {
|
||||
t.Fatalf("save path = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func executableCandidates(configured, name string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, 12)
|
||||
add := func(path string) {
|
||||
path = strings.Trim(path, `" `)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(filepath.Ext(path), ".exe") && runtime.GOOS == "windows" && !strings.ContainsAny(path, `\/`) {
|
||||
path += ".exe"
|
||||
}
|
||||
if resolved, err := exec.LookPath(path); err == nil {
|
||||
path = resolved
|
||||
}
|
||||
if stat, err := os.Stat(path); err != nil || stat.IsDir() {
|
||||
return
|
||||
}
|
||||
clean, err := filepath.Abs(path)
|
||||
if err == nil {
|
||||
path = clean
|
||||
}
|
||||
key := strings.ToLower(path)
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, path)
|
||||
}
|
||||
|
||||
if configured != "" {
|
||||
add(configured)
|
||||
}
|
||||
add(name)
|
||||
|
||||
for _, path := range localExecutableCandidates(name) {
|
||||
add(path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveLocalExecutable(configured, name string) (string, error) {
|
||||
for _, path := range executableCandidates(configured, name) {
|
||||
return path, nil
|
||||
}
|
||||
return "", fmt.Errorf("%s not found in PATH or common local app directories", name)
|
||||
}
|
||||
|
||||
func localExecutableCandidates(name string) []string {
|
||||
exe := name
|
||||
if runtime.GOOS == "windows" && !strings.HasSuffix(strings.ToLower(exe), ".exe") {
|
||||
exe += ".exe"
|
||||
}
|
||||
|
||||
out := make([]string, 0, 24)
|
||||
add := func(path string) {
|
||||
if path != "" {
|
||||
out = append(out, path)
|
||||
}
|
||||
}
|
||||
addGlob := func(pattern string) {
|
||||
matches, _ := filepath.Glob(pattern)
|
||||
out = append(out, matches...)
|
||||
}
|
||||
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
add(filepath.Join(wd, "tools", "ffmpeg", "bin", exe))
|
||||
add(filepath.Join(wd, "bin", exe))
|
||||
}
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
exeDir := filepath.Dir(exePath)
|
||||
add(filepath.Join(exeDir, "tools", "ffmpeg", "bin", exe))
|
||||
add(filepath.Join(exeDir, exe))
|
||||
}
|
||||
|
||||
appData := os.Getenv("APPDATA")
|
||||
localAppData := os.Getenv("LOCALAPPDATA")
|
||||
userProfile := os.Getenv("USERPROFILE")
|
||||
programData := os.Getenv("ProgramData")
|
||||
programFiles := os.Getenv("ProgramFiles")
|
||||
programFilesX86 := os.Getenv("ProgramFiles(x86)")
|
||||
|
||||
add(filepath.Join(appData, "bilibili", "ffmpeg", exe))
|
||||
add(filepath.Join(userProfile, "scoop", "shims", exe))
|
||||
add(filepath.Join(userProfile, "scoop", "apps", "ffmpeg", "current", "bin", exe))
|
||||
add(filepath.Join(programData, "chocolatey", "bin", exe))
|
||||
add(filepath.Join("C:\\", "ffmpeg", "bin", exe))
|
||||
add(filepath.Join("C:\\", "tools", "ffmpeg", "bin", exe))
|
||||
add(filepath.Join(programFiles, "ffmpeg", "bin", exe))
|
||||
add(filepath.Join(programFilesX86, "ffmpeg", "bin", exe))
|
||||
|
||||
addGlob(filepath.Join(localAppData, "JianyingPro", "Apps", "*", exe))
|
||||
addGlob(filepath.Join(localAppData, "Programs", "ffmpeg", "bin", exe))
|
||||
addGlob(filepath.Join(localAppData, "*", "ffmpeg", "bin", exe))
|
||||
return out
|
||||
}
|
||||
|
||||
func commandOutput(ctx context.Context, timeout time.Duration, name string, args ...string) ([]byte, error) {
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cmdCtx, name, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if cmdCtx.Err() != nil {
|
||||
return out, cmdCtx.Err()
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
+89
-17
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
@@ -47,6 +48,26 @@ type movieNFO struct {
|
||||
Poster string `xml:"thumb,omitempty"`
|
||||
Fanart string `xml:"fanart,omitempty"`
|
||||
TMDb int `xml:"tmdbid,omitempty"`
|
||||
Genre []string `xml:"genre,omitempty"`
|
||||
Country []string `xml:"country,omitempty"`
|
||||
Language []string `xml:"language,omitempty"`
|
||||
}
|
||||
|
||||
type episodeNFO struct {
|
||||
XMLName xml.Name `xml:"episodedetails"`
|
||||
Title string `xml:"title"`
|
||||
ShowTitle string `xml:"showtitle,omitempty"`
|
||||
Season int `xml:"season,omitempty"`
|
||||
Episode int `xml:"episode,omitempty"`
|
||||
Year int `xml:"year,omitempty"`
|
||||
Plot string `xml:"plot,omitempty"`
|
||||
Rating float32 `xml:"rating,omitempty"`
|
||||
Poster string `xml:"thumb,omitempty"`
|
||||
Fanart string `xml:"fanart,omitempty"`
|
||||
TMDb int `xml:"tmdbid,omitempty"`
|
||||
Genre []string `xml:"genre,omitempty"`
|
||||
Country []string `xml:"country,omitempty"`
|
||||
Language []string `xml:"language,omitempty"`
|
||||
}
|
||||
|
||||
// ExportOne writes a movie.nfo file next to the media file. Existing files
|
||||
@@ -63,26 +84,10 @@ func (s *NFOService) ExportOne(ctx context.Context, mediaID string) (string, err
|
||||
return "", errors.New("media has empty path")
|
||||
}
|
||||
|
||||
doc := movieNFO{
|
||||
Title: m.Title,
|
||||
Original: m.OriginalName,
|
||||
Year: m.Year,
|
||||
Plot: m.Overview,
|
||||
Rating: m.Rating,
|
||||
Poster: m.PosterURL,
|
||||
Fanart: m.BackdropURL,
|
||||
TMDb: m.TMDbID,
|
||||
}
|
||||
out, err := xml.MarshalIndent(doc, "", " ")
|
||||
dst, err := WriteMediaNFO(m)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := []byte(xml.Header + string(out) + "\n")
|
||||
|
||||
dst := nfoPath(m.Path)
|
||||
if err := os.WriteFile(dst, body, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.log.Info("nfo exported", zap.String("media_id", m.ID), zap.String("path", dst))
|
||||
return dst, nil
|
||||
}
|
||||
@@ -113,3 +118,70 @@ func nfoPath(media string) string {
|
||||
base := strings.TrimSuffix(filepath.Base(media), filepath.Ext(media))
|
||||
return filepath.Join(dir, fmt.Sprintf("%s.nfo", base))
|
||||
}
|
||||
|
||||
func WriteMediaNFO(m *model.Media) (string, error) {
|
||||
if m == nil {
|
||||
return "", errors.New("media not found")
|
||||
}
|
||||
if m.Path == "" {
|
||||
return "", errors.New("media has empty path")
|
||||
}
|
||||
|
||||
var doc any
|
||||
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
||||
title := m.OriginalName
|
||||
if title == "" {
|
||||
title = m.Title
|
||||
}
|
||||
doc = episodeNFO{
|
||||
Title: title,
|
||||
ShowTitle: m.Title,
|
||||
Season: m.SeasonNum,
|
||||
Episode: m.EpisodeNum,
|
||||
Year: m.Year,
|
||||
Plot: m.Overview,
|
||||
Rating: m.Rating,
|
||||
Poster: m.PosterURL,
|
||||
Fanart: m.BackdropURL,
|
||||
TMDb: m.TMDbID,
|
||||
Genre: splitNFOList(m.Genres),
|
||||
Country: splitNFOList(m.Countries),
|
||||
Language: splitNFOList(m.Languages),
|
||||
}
|
||||
} else {
|
||||
doc = movieNFO{
|
||||
Title: m.Title,
|
||||
Original: m.OriginalName,
|
||||
Year: m.Year,
|
||||
Plot: m.Overview,
|
||||
Rating: m.Rating,
|
||||
Poster: m.PosterURL,
|
||||
Fanart: m.BackdropURL,
|
||||
TMDb: m.TMDbID,
|
||||
Genre: splitNFOList(m.Genres),
|
||||
Country: splitNFOList(m.Countries),
|
||||
Language: splitNFOList(m.Languages),
|
||||
}
|
||||
}
|
||||
out, err := xml.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dst := nfoPath(m.Path)
|
||||
if err := os.WriteFile(dst, []byte(xml.Header+string(out)+"\n"), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func splitNFOList(value string) []string {
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+72
-145
@@ -4,8 +4,8 @@
|
||||
// completion) and moves/renames it into the configured library directory
|
||||
// using a Jinja2-like template. The default templates:
|
||||
//
|
||||
// movie: {Title} ({Year})/{Title} ({Year}).{Ext}
|
||||
// tv: {Title}/Season {Season:02d}/{Title} - S{Season:02d}E{Episode:02d}.{Ext}
|
||||
// movie: {Title} ({Year})/{Title} ({Year}).{Ext}
|
||||
// tv: {Title}/Season {Season:02d}/{Title} - S{Season:02d}E{Episode:02d}.{Ext}
|
||||
//
|
||||
// We do NOT delete the source after move — the operator can turn that on
|
||||
// via a config flag. This mirrors MediaStation's "organize after download"
|
||||
@@ -59,6 +59,11 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s
|
||||
if err != nil || lib == nil {
|
||||
return "", errors.New("library not found")
|
||||
}
|
||||
if lib.Type == "tv" || lib.Type == "anime" {
|
||||
if err := o.refreshEpisodeIdentity(m, lib); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
ext := filepath.Ext(m.Path)
|
||||
title := sanitizeFilename(m.Title)
|
||||
if title == "" {
|
||||
@@ -121,9 +126,20 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s
|
||||
if err := o.repo.DB.WithContext(ctx).
|
||||
Model(&model.Media{}).
|
||||
Where("id = ?", m.ID).
|
||||
Update("path", dst).Error; err != nil {
|
||||
Updates(map[string]any{
|
||||
"path": dst,
|
||||
"season_num": m.SeasonNum,
|
||||
"episode_num": m.EpisodeNum,
|
||||
}).Error; err != nil {
|
||||
return dst, err
|
||||
}
|
||||
if err := moveSidecarNFO(m.Path, dst); err != nil {
|
||||
o.log.Warn("organize sidecar nfo failed",
|
||||
zap.String("media", m.ID),
|
||||
zap.String("from", nfoPath(m.Path)),
|
||||
zap.String("to", nfoPath(dst)),
|
||||
zap.Error(err))
|
||||
}
|
||||
o.log.Info("organized",
|
||||
zap.String("media", m.ID),
|
||||
zap.String("from", m.Path),
|
||||
@@ -158,6 +174,37 @@ func (o *OrganizerService) OrganizeLibrary(ctx context.Context, libraryID string
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (o *OrganizerService) refreshEpisodeIdentity(m *model.Media, lib *model.Library) error {
|
||||
season, episode := m.SeasonNum, m.EpisodeNum
|
||||
|
||||
if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedSeason > 0 || parsedEpisode > 0 {
|
||||
if parsedSeason > 0 {
|
||||
season = parsedSeason
|
||||
}
|
||||
if parsedEpisode > 0 {
|
||||
episode = parsedEpisode
|
||||
}
|
||||
}
|
||||
|
||||
if local, err := ReadLocalMetadata(m.Path, lib.Path, true); err == nil && local != nil {
|
||||
if local.SeasonNum > 0 {
|
||||
season = local.SeasonNum
|
||||
}
|
||||
if local.EpisodeNum > 0 {
|
||||
episode = local.EpisodeNum
|
||||
}
|
||||
} else if err != nil {
|
||||
o.log.Warn("organize read local metadata failed", zap.String("path", m.Path), zap.Error(err))
|
||||
}
|
||||
|
||||
if season <= 0 || episode <= 0 {
|
||||
return fmt.Errorf("cannot determine season/episode for %s", m.Path)
|
||||
}
|
||||
m.SeasonNum = season
|
||||
m.EpisodeNum = episode
|
||||
return nil
|
||||
}
|
||||
|
||||
// moveFile tries os.Rename first (instant on same fs), then falls back
|
||||
// to copy + remove for cross-device moves.
|
||||
//
|
||||
@@ -191,6 +238,27 @@ func moveFile(src, dst string) error {
|
||||
return os.Remove(src)
|
||||
}
|
||||
|
||||
func moveSidecarNFO(srcMedia, dstMedia string) error {
|
||||
src := nfoPath(srcMedia)
|
||||
dst := nfoPath(dstMedia)
|
||||
if src == dst {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return moveFile(src, dst)
|
||||
}
|
||||
|
||||
// sanitizeFilename removes characters not safe for filesystem names.
|
||||
func sanitizeFilename(s string) string {
|
||||
r := strings.NewReplacer(
|
||||
@@ -224,153 +292,12 @@ func (o *OrganizerService) SmartClassify(ctx context.Context, m *model.Media) st
|
||||
return ""
|
||||
}
|
||||
|
||||
// Fetch fresh metadata from DB (languages, countries, genres may have been updated by scraper)
|
||||
if m.Languages == "" && m.Countries == "" && m.Genres == "" {
|
||||
// Try to reload from DB
|
||||
fresh, err := o.repo.Media.FindByID(ctx, m.ID)
|
||||
if err == nil && fresh != nil {
|
||||
m = fresh
|
||||
}
|
||||
}
|
||||
|
||||
// Parse metadata fields (comma-separated)
|
||||
languages := parseCommaList(m.Languages)
|
||||
countries := parseCommaList(m.Countries)
|
||||
genres := parseCommaList(m.Genres)
|
||||
|
||||
// Determine media type from library
|
||||
lib, err := o.repo.Library.FindByID(ctx, m.LibraryID)
|
||||
if err != nil || lib == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
categories := o.cfg.Organizer.Categories
|
||||
if categories == nil {
|
||||
categories = make(map[string]string)
|
||||
}
|
||||
|
||||
// Helper closures
|
||||
isChinese := func() bool {
|
||||
for _, lang := range languages {
|
||||
if lang == "zh" || lang == "zh-CN" || lang == "zh-TW" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, c := range countries {
|
||||
if c == "CN" || c == "TW" || c == "HK" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
isEastAsian := func() bool {
|
||||
for _, c := range countries {
|
||||
if c == "JP" || c == "KR" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, lang := range languages {
|
||||
if lang == "ja" || lang == "ko" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
isWestern := func() bool {
|
||||
westernCountries := []string{"US", "GB", "FR", "DE", "CA", "AU", "NZ", "IE", "NL", "SE", "NO", "DK", "FI", "ES", "IT", "PT", "AT", "CH", "BE"}
|
||||
for _, c := range countries {
|
||||
for _, wc := range westernCountries {
|
||||
if c == wc {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Classification logic
|
||||
switch lib.Type {
|
||||
case "movie":
|
||||
// Use genres to help classify (e.g., Animation might be anime)
|
||||
isAnimation := false
|
||||
for _, g := range genres {
|
||||
if g == "Animation" {
|
||||
isAnimation = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isChinese() {
|
||||
if name, ok := categories["chinese_movie"]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return "华语电影"
|
||||
}
|
||||
if isEastAsian() || (isAnimation && isEastAsian()) {
|
||||
if name, ok := categories["jk_movie"]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return "日韩电影"
|
||||
}
|
||||
if isWestern() {
|
||||
if name, ok := categories["euus_movie"]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return "欧美电影"
|
||||
}
|
||||
// Fallback: foreign movie
|
||||
if name, ok := categories["foreign_movie"]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return "外语电影"
|
||||
|
||||
case "tv", "anime":
|
||||
if lib.Type == "anime" || contains(genres, "Animation") {
|
||||
// Anime classification
|
||||
if isEastAsian() {
|
||||
// Check if it's Japanese
|
||||
for _, c := range countries {
|
||||
if c == "JP" {
|
||||
if name, ok := categories["jp_anime"]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return "日番"
|
||||
}
|
||||
}
|
||||
}
|
||||
// Chinese anime
|
||||
if isChinese() {
|
||||
if name, ok := categories["cn_anime"]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return "国漫"
|
||||
}
|
||||
}
|
||||
// TV classification
|
||||
if isChinese() {
|
||||
if name, ok := categories["domestic_tv"]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return "国产剧"
|
||||
}
|
||||
if isEastAsian() {
|
||||
if name, ok := categories["jk_tv"]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return "日韩剧"
|
||||
}
|
||||
if isWestern() {
|
||||
if name, ok := categories["euus_tv"]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return "欧美剧"
|
||||
}
|
||||
return "剧集"
|
||||
}
|
||||
|
||||
return ""
|
||||
return o.classifyMedia(ctx, m, lib.Type)
|
||||
}
|
||||
|
||||
// parseCommaList splits a comma-separated string into a slice of trimmed strings.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestOrganizeMediaReDetectsSeasonFromPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sourceDir := filepath.Join(root, "Incoming", "Some Show", "Season 02")
|
||||
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := filepath.Join(sourceDir, "Some Show - EP03.mkv")
|
||||
if err := os.WriteFile(source, []byte("episode"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "TV", Path: root, Type: "tv", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "Some Show",
|
||||
Path: source,
|
||||
Container: "mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 3,
|
||||
ScrapeStatus: "matched",
|
||||
}
|
||||
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
|
||||
dst, err := organizer.OrganizeMedia(t.Context(), media.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(root, "Some Show", "Season 02", "Some Show - S02E03.mkv")
|
||||
if dst != want {
|
||||
t.Fatalf("dst = %q, want %q", dst, want)
|
||||
}
|
||||
if _, err := os.Stat(want); err != nil {
|
||||
t.Fatalf("organized file missing: %v", err)
|
||||
}
|
||||
var refreshed model.Media
|
||||
if err := db.First(&refreshed, "id = ?", media.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if refreshed.SeasonNum != 2 || refreshed.EpisodeNum != 3 || refreshed.Path != want {
|
||||
t.Fatalf("unexpected refreshed media: %+v", refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizeMediaUsesEpisodeNFOSeason(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sourceDir := filepath.Join(root, "Incoming", "Some Show")
|
||||
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := filepath.Join(sourceDir, "Some Show - EP03.mkv")
|
||||
if err := os.WriteFile(source, []byte("episode"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nfo := `<episodedetails><title>第三集</title><season>2</season><episode>3</episode></episodedetails>`
|
||||
if err := os.WriteFile(nfoPath(source), []byte(nfo), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "TV", Path: root, Type: "tv", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "Some Show",
|
||||
Path: source,
|
||||
Container: "mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 3,
|
||||
ScrapeStatus: "matched",
|
||||
}
|
||||
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
|
||||
dst, err := organizer.OrganizeMedia(t.Context(), media.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(root, "Some Show", "Season 02", "Some Show - S02E03.mkv")
|
||||
if dst != want {
|
||||
t.Fatalf("dst = %q, want %q", dst, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProxyFromEnvironmentOrSystem mirrors http.ProxyFromEnvironment, then falls
|
||||
// back to the OS user proxy. On Windows this means the current user's Internet
|
||||
// Settings proxy, e.g. the proxy configured by Clash/V2RayN/系统设置.
|
||||
func ProxyFromEnvironmentOrSystem(req *http.Request) (*url.URL, error) {
|
||||
if proxy, err := http.ProxyFromEnvironment(req); proxy != nil || err != nil {
|
||||
return proxy, err
|
||||
}
|
||||
return systemProxyForRequest(req)
|
||||
}
|
||||
|
||||
// NewExternalHTTPClient builds an HTTP client for third-party APIs. It uses
|
||||
// environment proxies first, then the local OS proxy configuration.
|
||||
func NewExternalHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: NewExternalTransport(),
|
||||
}
|
||||
}
|
||||
|
||||
func NewExternalTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
Proxy: ProxyFromEnvironmentOrSystem,
|
||||
MaxIdleConns: 32,
|
||||
MaxIdleConnsPerHost: 8,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func proxyURLFromProxyServer(proxyServer, requestScheme string) (*url.URL, error) {
|
||||
proxyServer = strings.TrimSpace(proxyServer)
|
||||
if proxyServer == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if !strings.Contains(proxyServer, "=") {
|
||||
return normalizeProxyURL(proxyServer, "http")
|
||||
}
|
||||
|
||||
entries := strings.Split(proxyServer, ";")
|
||||
values := map[string]string{}
|
||||
first := ""
|
||||
for _, entry := range entries {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(entry), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
value = strings.TrimSpace(value)
|
||||
if key == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
if first == "" {
|
||||
first = value
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
|
||||
if value := values[strings.ToLower(requestScheme)]; value != "" {
|
||||
return normalizeProxyURL(value, strings.ToLower(requestScheme))
|
||||
}
|
||||
if value := values["http"]; value != "" {
|
||||
return normalizeProxyURL(value, "http")
|
||||
}
|
||||
if value := values["https"]; value != "" {
|
||||
return normalizeProxyURL(value, "http")
|
||||
}
|
||||
if value := values["socks"]; value != "" {
|
||||
return normalizeProxyURL(value, "socks")
|
||||
}
|
||||
return normalizeProxyURL(first, "http")
|
||||
}
|
||||
|
||||
func normalizeProxyURL(raw, proxyKind string) (*url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !strings.Contains(raw, "://") {
|
||||
scheme := "http"
|
||||
if strings.EqualFold(proxyKind, "socks") {
|
||||
scheme = "socks5"
|
||||
}
|
||||
raw = scheme + "://" + raw
|
||||
}
|
||||
return url.Parse(raw)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !windows
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func systemProxyForRequest(_ *http.Request) (*url.URL, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestProxyURLFromProxyServer(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
proxyServer string
|
||||
requestScheme string
|
||||
want string
|
||||
}{
|
||||
{"bare", "127.0.0.1:10808", "https", "http://127.0.0.1:10808"},
|
||||
{"scheme map https", "http=127.0.0.1:7890;https=127.0.0.1:7891", "https", "http://127.0.0.1:7891"},
|
||||
{"fallback http", "http=127.0.0.1:7890", "https", "http://127.0.0.1:7890"},
|
||||
{"socks", "socks=127.0.0.1:1080", "https", "socks5://127.0.0.1:1080"},
|
||||
{"explicit", "http://127.0.0.1:8080", "https", "http://127.0.0.1:8080"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := proxyURLFromProxyServer(tc.proxyServer, tc.requestScheme)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.String() != tc.want {
|
||||
t.Fatalf("got %q, want %q", got.String(), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//go:build windows
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
const windowsInternetSettingsKey = `Software\Microsoft\Windows\CurrentVersion\Internet Settings`
|
||||
|
||||
func systemProxyForRequest(req *http.Request) (*url.URL, error) {
|
||||
if req == nil || req.URL == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
key, err := registry.OpenKey(registry.CURRENT_USER, windowsInternetSettingsKey, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
enabled, _, err := key.GetIntegerValue("ProxyEnable")
|
||||
if err != nil || enabled == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
proxyServer, _, err := key.GetStringValue("ProxyServer")
|
||||
if err != nil || strings.TrimSpace(proxyServer) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if proxyOverride, _, err := key.GetStringValue("ProxyOverride"); err == nil {
|
||||
if windowsProxyBypass(req.URL.Hostname(), proxyOverride) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return proxyURLFromProxyServer(proxyServer, req.URL.Scheme)
|
||||
}
|
||||
|
||||
func windowsProxyBypass(host, override string) bool {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
if parsed := net.ParseIP(host); parsed != nil && parsed.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, rule := range strings.Split(override, ";") {
|
||||
rule = strings.ToLower(strings.TrimSpace(rule))
|
||||
if rule == "" {
|
||||
continue
|
||||
}
|
||||
if rule == "<local>" && !strings.Contains(host, ".") {
|
||||
return true
|
||||
}
|
||||
if ok, _ := filepath.Match(rule, host); ok {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(rule, "*.") && strings.HasSuffix(host, strings.TrimPrefix(rule, "*")) {
|
||||
return true
|
||||
}
|
||||
if host == rule {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -5,9 +5,9 @@
|
||||
//
|
||||
// We only need three operations for the download flow:
|
||||
//
|
||||
// POST /auth/login
|
||||
// POST /torrents/add (multipart, accepts magnet URL or .torrent bytes)
|
||||
// GET /torrents/info (filtered by hash)
|
||||
// POST /auth/login
|
||||
// POST /torrents/add (multipart, accepts magnet URL or .torrent bytes)
|
||||
// GET /torrents/info (filtered by hash)
|
||||
//
|
||||
// The client stores the SID cookie returned by /auth/login and reuses it
|
||||
// across calls. Re-auth happens transparently on 403.
|
||||
@@ -16,6 +16,8 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -24,6 +26,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -61,6 +64,11 @@ type QBitClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
var (
|
||||
qbitAddVerifyAttempts = 10
|
||||
qbitAddVerifyInterval = 800 * time.Millisecond
|
||||
)
|
||||
|
||||
// NewQBitClient builds a fresh client, applying default URL if blank.
|
||||
func NewQBitClient(log *zap.Logger, cfg QBitConfig) *QBitClient {
|
||||
if cfg.BaseURL == "" {
|
||||
@@ -125,9 +133,58 @@ func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath strin
|
||||
return err
|
||||
}
|
||||
|
||||
torrentData, torrentName, fetchErr := q.fetchTorrentFile(ctx, magnetOrURL)
|
||||
useFileUpload := fetchErr == nil && len(torrentData) > 0
|
||||
return q.addTorrentLocked(ctx, magnetOrURL, torrentData, torrentName, useFileUpload, savePath)
|
||||
}
|
||||
|
||||
func (q *QBitClient) AddTorrentFile(ctx context.Context, data []byte, name, savePath string) error {
|
||||
if len(data) == 0 {
|
||||
return errors.New("empty torrent data")
|
||||
}
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if err := q.ensureAuth(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.addTorrentLocked(ctx, "", data, name, true, savePath)
|
||||
}
|
||||
|
||||
func (q *QBitClient) addTorrentLocked(ctx context.Context, magnetOrURL string, torrentData []byte, torrentName string, useFileUpload bool, savePath string) error {
|
||||
before, beforeErr := q.listLocked(ctx, "")
|
||||
beforeHashes := make(map[string]struct{}, len(before))
|
||||
if beforeErr == nil {
|
||||
for _, torrent := range before {
|
||||
if torrent.Hash != "" {
|
||||
beforeHashes[strings.ToLower(torrent.Hash)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
if useFileUpload && beforeErr == nil {
|
||||
if hash := torrentInfoHash(torrentData); hash != "" {
|
||||
if _, ok := beforeHashes[hash]; ok {
|
||||
q.log.Info("qbittorrent: torrent already exists", zap.String("hash", hash), zap.String("name", torrentName))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(body)
|
||||
_ = w.WriteField("urls", magnetOrURL)
|
||||
if useFileUpload {
|
||||
if strings.TrimSpace(torrentName) == "" {
|
||||
torrentName = "download.torrent"
|
||||
}
|
||||
part, err := w.CreateFormFile("torrents", torrentName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := part.Write(torrentData); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
_ = w.WriteField("urls", magnetOrURL)
|
||||
}
|
||||
if savePath != "" {
|
||||
_ = w.WriteField("savepath", savePath)
|
||||
}
|
||||
@@ -157,13 +214,182 @@ func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath strin
|
||||
if strings.EqualFold(bodyText, "Fails.") {
|
||||
return fmt.Errorf("qbittorrent add: 拒绝任务 (检查 URL 是否需要认证或 savePath 是否可写)")
|
||||
}
|
||||
if beforeErr == nil {
|
||||
accepted := false
|
||||
var lastListErr error
|
||||
for attempt := 0; attempt < qbitAddVerifyAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(qbitAddVerifyInterval)
|
||||
}
|
||||
after, err := q.listLocked(ctx, "")
|
||||
if err != nil {
|
||||
lastListErr = err
|
||||
continue
|
||||
}
|
||||
for _, torrent := range after {
|
||||
if torrent.Hash == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := beforeHashes[torrent.Hash]; !ok {
|
||||
accepted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if accepted {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !accepted {
|
||||
if lastListErr != nil {
|
||||
return fmt.Errorf("qbittorrent add: 无法确认任务已加入下载器: %w", lastListErr)
|
||||
}
|
||||
return fmt.Errorf("qbittorrent add: 下载器未出现新任务,可能种子已存在或 URL 未被下载器接受")
|
||||
}
|
||||
}
|
||||
q.log.Info("qbittorrent: torrent added",
|
||||
zap.String("url", magnetOrURL),
|
||||
zap.String("save_path", savePath),
|
||||
zap.Bool("file_upload", useFileUpload),
|
||||
zap.String("body", bodyText))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *QBitClient) fetchTorrentFile(ctx context.Context, raw string) ([]byte, string, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u.Host == "" {
|
||||
return nil, "", errors.New("not a remote URL")
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return nil, "", errors.New("not an HTTP torrent URL")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "MediaStationGo/0.1")
|
||||
req.Header.Set("Accept", "application/x-bittorrent,application/octet-stream,*/*")
|
||||
|
||||
client := NewExternalHTTPClient(30 * time.Second)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, "", fmt.Errorf("torrent fetch: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
const maxTorrentSize = 32 << 20
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxTorrentSize+1))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, "", errors.New("torrent fetch: empty body")
|
||||
}
|
||||
if len(data) > maxTorrentSize {
|
||||
return nil, "", errors.New("torrent fetch: body too large")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") {
|
||||
return nil, "", errors.New("torrent fetch: upstream returned HTML")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(path.Base(u.Path))
|
||||
if name == "" || name == "." || name == "/" {
|
||||
name = "download.torrent"
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".torrent") {
|
||||
name += ".torrent"
|
||||
}
|
||||
return data, name, nil
|
||||
}
|
||||
|
||||
func torrentInfoHash(data []byte) string {
|
||||
start, end, ok := torrentInfoBounds(data)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
sum := sha1.Sum(data[start:end])
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func torrentInfoBounds(data []byte) (int, int, bool) {
|
||||
if len(data) == 0 || data[0] != 'd' {
|
||||
return 0, 0, false
|
||||
}
|
||||
pos := 1
|
||||
for pos < len(data) && data[pos] != 'e' {
|
||||
keyStart, keyEnd, next, ok := parseBencodeString(data, pos)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
valueStart := next
|
||||
valueEnd, ok := bencodeValueEnd(data, valueStart)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
if string(data[keyStart:keyEnd]) == "info" {
|
||||
return valueStart, valueEnd, true
|
||||
}
|
||||
pos = valueEnd
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func parseBencodeString(data []byte, pos int) (int, int, int, bool) {
|
||||
if pos >= len(data) || data[pos] < '0' || data[pos] > '9' {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
length := 0
|
||||
for pos < len(data) && data[pos] >= '0' && data[pos] <= '9' {
|
||||
length = length*10 + int(data[pos]-'0')
|
||||
pos++
|
||||
}
|
||||
if pos >= len(data) || data[pos] != ':' {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
start := pos + 1
|
||||
end := start + length
|
||||
if end > len(data) {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return start, end, end, true
|
||||
}
|
||||
|
||||
func bencodeValueEnd(data []byte, pos int) (int, bool) {
|
||||
if pos >= len(data) {
|
||||
return 0, false
|
||||
}
|
||||
switch data[pos] {
|
||||
case 'i':
|
||||
end := pos + 1
|
||||
for end < len(data) && data[end] != 'e' {
|
||||
end++
|
||||
}
|
||||
if end >= len(data) {
|
||||
return 0, false
|
||||
}
|
||||
return end + 1, true
|
||||
case 'l', 'd':
|
||||
end := pos + 1
|
||||
for end < len(data) && data[end] != 'e' {
|
||||
next, ok := bencodeValueEnd(data, end)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
end = next
|
||||
}
|
||||
if end >= len(data) {
|
||||
return 0, false
|
||||
}
|
||||
return end + 1, true
|
||||
default:
|
||||
_, _, next, ok := parseBencodeString(data, pos)
|
||||
return next, ok
|
||||
}
|
||||
}
|
||||
|
||||
// List returns every torrent (optionally filtered by status: all / downloading / completed).
|
||||
func (q *QBitClient) List(ctx context.Context, filter string) ([]QBitTorrent, error) {
|
||||
q.mu.Lock()
|
||||
@@ -171,6 +397,10 @@ func (q *QBitClient) List(ctx context.Context, filter string) ([]QBitTorrent, er
|
||||
if err := q.ensureAuth(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.listLocked(ctx, filter)
|
||||
}
|
||||
|
||||
func (q *QBitClient) listLocked(ctx context.Context, filter string) ([]QBitTorrent, error) {
|
||||
u := strings.TrimRight(q.cfg.BaseURL, "/") + "/api/v2/torrents/info"
|
||||
if filter != "" {
|
||||
u += "?filter=" + url.QueryEscape(filter)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestQBitAddTorrentRequiresVisibleNewTask(t *testing.T) {
|
||||
oldAttempts := qbitAddVerifyAttempts
|
||||
oldInterval := qbitAddVerifyInterval
|
||||
qbitAddVerifyAttempts = 2
|
||||
qbitAddVerifyInterval = time.Millisecond
|
||||
defer func() {
|
||||
qbitAddVerifyAttempts = oldAttempts
|
||||
qbitAddVerifyInterval = oldInterval
|
||||
}()
|
||||
|
||||
server := 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/add":
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
case "/api/v2/torrents/info":
|
||||
_, _ = w.Write([]byte("[]"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewQBitClient(zap.NewNop(), QBitConfig{
|
||||
BaseURL: server.URL,
|
||||
Username: "admin",
|
||||
Password: "adminadmin",
|
||||
})
|
||||
|
||||
err := client.AddTorrent(context.Background(), server.URL+"/missing.torrent", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected add to fail when no new torrent appears")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "下载器未出现新任务") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQBitAddTorrentUploadsFetchedTorrentFile(t *testing.T) {
|
||||
var added atomic.Bool
|
||||
server := 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 "/fixture.torrent":
|
||||
w.Header().Set("Content-Type", "application/x-bittorrent")
|
||||
_, _ = w.Write([]byte("d4:infod4:name7:fixtureee"))
|
||||
case "/api/v2/torrents/add":
|
||||
reader, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
t.Errorf("expected multipart add request: %v", err)
|
||||
http.Error(w, "bad multipart", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !multipartHasTorrentFile(reader) {
|
||||
t.Error("expected qbit add request to upload torrent file")
|
||||
http.Error(w, "missing file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
added.Store(true)
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
case "/api/v2/torrents/info":
|
||||
if added.Load() {
|
||||
_, _ = w.Write([]byte(`[{"hash":"abc123","name":"fixture"}]`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("[]"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewQBitClient(zap.NewNop(), QBitConfig{
|
||||
BaseURL: server.URL,
|
||||
Username: "admin",
|
||||
Password: "adminadmin",
|
||||
})
|
||||
|
||||
if err := client.AddTorrent(context.Background(), server.URL+"/fixture.torrent", ""); err != nil {
|
||||
t.Fatalf("expected fetched torrent upload to succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQBitAddTorrentFileTreatsExistingInfoHashAsSuccess(t *testing.T) {
|
||||
torrentData := []byte("d4:infod4:name7:fixtureee")
|
||||
hash := torrentInfoHash(torrentData)
|
||||
if hash == "" {
|
||||
t.Fatal("expected fixture info hash")
|
||||
}
|
||||
var addCalled atomic.Bool
|
||||
server := 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/add":
|
||||
addCalled.Store(true)
|
||||
_, _ = w.Write([]byte("Fails."))
|
||||
case "/api/v2/torrents/info":
|
||||
_, _ = w.Write([]byte(`[{"hash":"` + hash + `","name":"fixture"}]`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewQBitClient(zap.NewNop(), QBitConfig{
|
||||
BaseURL: server.URL,
|
||||
Username: "admin",
|
||||
Password: "adminadmin",
|
||||
})
|
||||
|
||||
if err := client.AddTorrentFile(context.Background(), torrentData, "fixture.torrent", ""); err != nil {
|
||||
t.Fatalf("expected existing torrent to be accepted: %v", err)
|
||||
}
|
||||
if addCalled.Load() {
|
||||
t.Fatal("expected qbit add to be skipped for existing infohash")
|
||||
}
|
||||
}
|
||||
|
||||
func multipartHasTorrentFile(reader *multipart.Reader) bool {
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if part.FormName() == "torrents" && part.FileName() != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
func ApplyRuntimeSettings(ctx context.Context, cfg *config.Config, repos *repository.Container, log *zap.Logger) {
|
||||
if cfg == nil || repos == nil || repos.Setting == nil {
|
||||
return
|
||||
}
|
||||
rows, err := repos.Setting.All(ctx)
|
||||
if err != nil {
|
||||
if log != nil {
|
||||
log.Warn("load runtime settings failed", zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, row := range rows {
|
||||
ApplyRuntimeSetting(cfg, row.Key, row.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
switch key {
|
||||
case "ffmpeg.path", "app.ffmpeg_path":
|
||||
cfg.App.FFmpegPath = value
|
||||
case "ffprobe.path", "app.ffprobe_path":
|
||||
cfg.App.FFprobePath = value
|
||||
case "transcode.hw_accel", "transcoder.encoder":
|
||||
switch value {
|
||||
case "", "auto", "none", "software":
|
||||
cfg.Transcoder.Encoder = ""
|
||||
case "nvenc", "qsv", "vaapi":
|
||||
cfg.Transcoder.Encoder = value
|
||||
}
|
||||
case "transcode.max_height", "transcoder.max_height":
|
||||
if n, err := strconv.Atoi(value); err == nil {
|
||||
cfg.Transcoder.MaxHeight = n
|
||||
}
|
||||
case "transcode.video_bitrate", "transcoder.video_bitrate":
|
||||
cfg.Transcoder.VideoBitrate = value
|
||||
}
|
||||
}
|
||||
+164
-17
@@ -5,14 +5,14 @@
|
||||
// ffprobe (when available) and queues a metadata lookup for newly added
|
||||
// rows.
|
||||
//
|
||||
// For TV / anime libraries we extract season + episode numbers from the
|
||||
// filename via ParseEpisode and store them on the Media row. A future
|
||||
// pass groups episodes into Series rows; the current scaffold lets the
|
||||
// frontend group by `series_id`.
|
||||
// When a filename exposes season + episode numbers we store them on the
|
||||
// Media row for every library type, so variety shows and other episodic
|
||||
// collections get the same grouping experience as TV/anime.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -68,10 +68,13 @@ func NewScannerService(
|
||||
|
||||
// ScanResult summarises a scan run.
|
||||
type ScanResult struct {
|
||||
LibraryID string `json:"library_id"`
|
||||
Visited int `json:"visited"`
|
||||
Added int `json:"added"`
|
||||
Probed int `json:"probed"`
|
||||
LibraryID string `json:"library_id"`
|
||||
Visited int `json:"visited"`
|
||||
Added int `json:"added"`
|
||||
Updated int `json:"updated"`
|
||||
Probed int `json:"probed"`
|
||||
LocalMetadata int `json:"local_metadata"`
|
||||
Removed int64 `json:"removed"`
|
||||
}
|
||||
|
||||
// ScanLibrary walks the library root and persists discovered media files.
|
||||
@@ -81,6 +84,7 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
|
||||
return nil, err
|
||||
}
|
||||
res := &ScanResult{LibraryID: lib.ID}
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
walkFn := func(path string, info walkInfo) error {
|
||||
if info.isDir {
|
||||
@@ -91,6 +95,8 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
|
||||
return nil
|
||||
}
|
||||
res.Visited++
|
||||
seen[filepath.Clean(path)] = struct{}{}
|
||||
isNewMedia := !s.mediaPathExists(ctx, path)
|
||||
|
||||
title, year := CleanQuery(path)
|
||||
if title == "" {
|
||||
@@ -106,11 +112,15 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
|
||||
Container: strings.TrimPrefix(ext, "."),
|
||||
}
|
||||
|
||||
// Detect season/episode for TV / anime libraries.
|
||||
if lib.Type == "tv" || lib.Type == "anime" {
|
||||
s, e := ParseEpisode(path)
|
||||
m.SeasonNum = s
|
||||
m.EpisodeNum = e
|
||||
parsedSeason, parsedEpisode := ParseEpisode(path)
|
||||
m.SeasonNum = parsedSeason
|
||||
m.EpisodeNum = parsedEpisode
|
||||
|
||||
if local, err := ReadLocalMetadata(path, lib.Path, librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0); err == nil && local != nil {
|
||||
applyLocalMetadata(m, local)
|
||||
res.LocalMetadata++
|
||||
} else if err != nil {
|
||||
s.log.Warn("read local metadata failed", zap.String("path", path), zap.Error(err))
|
||||
}
|
||||
|
||||
// Best-effort ffprobe; failure does not abort the file.
|
||||
@@ -134,13 +144,19 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
|
||||
s.log.Warn("upsert media failed", zap.String("path", path), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
res.Added++
|
||||
if isNewMedia {
|
||||
res.Added++
|
||||
} else {
|
||||
res.Updated++
|
||||
}
|
||||
s.hub.Publish("scan", map[string]any{
|
||||
"library_id": lib.ID,
|
||||
"path": path,
|
||||
"visited": res.Visited,
|
||||
"added": res.Added,
|
||||
"updated": res.Updated,
|
||||
"probed": res.Probed,
|
||||
"local_meta": res.LocalMetadata,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -148,18 +164,27 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
|
||||
if err := walk(lib.Path, walkFn); err != nil {
|
||||
return res, err
|
||||
}
|
||||
removed, err := s.pruneMissingMedia(ctx, lib.ID, seen)
|
||||
if err != nil {
|
||||
s.log.Warn("prune missing media failed", zap.String("library_id", lib.ID), zap.Error(err))
|
||||
} else {
|
||||
res.Removed = removed
|
||||
}
|
||||
|
||||
s.hub.Publish("scan", map[string]any{
|
||||
"library_id": lib.ID,
|
||||
"finished": true,
|
||||
"visited": res.Visited,
|
||||
"added": res.Added,
|
||||
"updated": res.Updated,
|
||||
"probed": res.Probed,
|
||||
"local_meta": res.LocalMetadata,
|
||||
"removed": res.Removed,
|
||||
})
|
||||
|
||||
// Fire-and-forget metadata enrichment when at least one provider is
|
||||
// configured.
|
||||
if s.scraper != nil && s.scraper.AnyEnabled() {
|
||||
// Online enrichment is opt-in. Local NFO is always consumed first during
|
||||
// the scan, and matched rows are excluded from EnrichLibrary's pending set.
|
||||
if s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) {
|
||||
go func(libID string) {
|
||||
if _, err := s.scraper.EnrichLibrary(context.Background(), libID); err != nil {
|
||||
s.log.Warn("scraper enrich failed", zap.Error(err))
|
||||
@@ -168,3 +193,125 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *ScannerService) mediaPathExists(ctx context.Context, path string) bool {
|
||||
var count int64
|
||||
err := s.repo.DB.WithContext(ctx).Unscoped().Model(&model.Media{}).
|
||||
Where("path = ?", path).Count(&count).Error
|
||||
return err == nil && count > 0
|
||||
}
|
||||
|
||||
func (s *ScannerService) pruneMissingMedia(ctx context.Context, libraryID string, seen map[string]struct{}) (int64, error) {
|
||||
var rows []model.Media
|
||||
if err := s.repo.DB.WithContext(ctx).
|
||||
Where("library_id = ?", libraryID).
|
||||
Find(&rows).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var removed int64
|
||||
for _, row := range rows {
|
||||
if row.Path == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[filepath.Clean(row.Path)]; ok {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(row.Path); err == nil {
|
||||
continue
|
||||
} else if !os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
res := s.repo.DB.WithContext(ctx).
|
||||
Where("id = ?", row.ID).
|
||||
Delete(&model.Media{})
|
||||
if res.Error != nil {
|
||||
return removed, res.Error
|
||||
}
|
||||
removed += res.RowsAffected
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
func applyLocalMetadata(m *model.Media, local *LocalMetadata) {
|
||||
if local.Title != "" {
|
||||
m.Title = local.Title
|
||||
}
|
||||
if local.OriginalName != "" {
|
||||
m.OriginalName = local.OriginalName
|
||||
}
|
||||
if local.AdultCode != "" {
|
||||
m.OriginalName = local.AdultCode
|
||||
}
|
||||
if local.Year > 0 {
|
||||
m.Year = local.Year
|
||||
}
|
||||
if local.Overview != "" {
|
||||
m.Overview = local.Overview
|
||||
}
|
||||
if local.Rating > 0 {
|
||||
m.Rating = local.Rating
|
||||
}
|
||||
if local.PosterURL != "" {
|
||||
m.PosterURL = local.PosterURL
|
||||
}
|
||||
if local.BackdropURL != "" {
|
||||
m.BackdropURL = local.BackdropURL
|
||||
}
|
||||
if local.TMDbID > 0 {
|
||||
m.TMDbID = local.TMDbID
|
||||
}
|
||||
if local.SeasonNum > 0 {
|
||||
m.SeasonNum = local.SeasonNum
|
||||
}
|
||||
if local.EpisodeNum > 0 {
|
||||
m.EpisodeNum = local.EpisodeNum
|
||||
}
|
||||
if local.Genres != "" {
|
||||
m.Genres = local.Genres
|
||||
}
|
||||
if local.Countries != "" {
|
||||
m.Countries = local.Countries
|
||||
}
|
||||
if local.Languages != "" {
|
||||
m.Languages = local.Languages
|
||||
}
|
||||
if local.NSFW {
|
||||
m.NSFW = true
|
||||
}
|
||||
if local.HasNFO || localHasDescriptiveMetadata(local) {
|
||||
m.ScrapeStatus = "matched"
|
||||
}
|
||||
}
|
||||
|
||||
func localHasDescriptiveMetadata(local *LocalMetadata) bool {
|
||||
if local == nil {
|
||||
return false
|
||||
}
|
||||
return local.Title != "" ||
|
||||
local.OriginalName != "" ||
|
||||
local.AdultCode != "" ||
|
||||
local.Year > 0 ||
|
||||
local.Overview != "" ||
|
||||
local.Rating > 0 ||
|
||||
local.TMDbID > 0 ||
|
||||
local.Genres != "" ||
|
||||
local.Countries != "" ||
|
||||
local.Languages != ""
|
||||
}
|
||||
|
||||
func (s *ScannerService) autoScrapeEnabled(ctx context.Context) bool {
|
||||
if s.repo == nil || s.repo.Setting == nil {
|
||||
return false
|
||||
}
|
||||
value, err := s.repo.Setting.Get(ctx, "scrape.auto_on_scan")
|
||||
if err != nil {
|
||||
s.log.Warn("read scrape.auto_on_scan failed", zap.Error(err))
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on", "enabled":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
+360
-21
@@ -3,9 +3,9 @@
|
||||
// ScraperService takes a Media row and tries to enrich it with metadata
|
||||
// from one or more providers. Selection is driven by the library type:
|
||||
//
|
||||
// library.type == "anime" -> Bangumi (fallback: TMDb)
|
||||
// library.type == "tv" -> TheTVDB (fallback: TMDb)
|
||||
// default -> TMDb
|
||||
// library.type == "anime" -> Bangumi (fallback: TMDb)
|
||||
// library.type == "tv" -> TheTVDB (fallback: TMDb)
|
||||
// default -> TMDb
|
||||
//
|
||||
// After the primary match we optionally upgrade poster / backdrop with
|
||||
// Fanart.tv when an API key is configured.
|
||||
@@ -35,6 +35,7 @@ type ScraperService struct {
|
||||
bangumi *BangumiProvider
|
||||
thetvdb *TheTVDBProvider
|
||||
fanart *FanartProvider
|
||||
adult *AdultProvider
|
||||
hub *Hub
|
||||
}
|
||||
|
||||
@@ -48,10 +49,15 @@ func NewScraperService(
|
||||
thetvdb *TheTVDBProvider,
|
||||
fanart *FanartProvider,
|
||||
hub *Hub,
|
||||
adult ...*AdultProvider,
|
||||
) *ScraperService {
|
||||
var adultProvider *AdultProvider
|
||||
if len(adult) > 0 {
|
||||
adultProvider = adult[0]
|
||||
}
|
||||
return &ScraperService{
|
||||
cfg: cfg, log: log, repo: repo,
|
||||
tmdb: tmdb, bangumi: bangumi, thetvdb: thetvdb, fanart: fanart, hub: hub,
|
||||
tmdb: tmdb, bangumi: bangumi, thetvdb: thetvdb, fanart: fanart, adult: adultProvider, hub: hub,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +88,24 @@ var noiseTokens = []string{
|
||||
"season",
|
||||
}
|
||||
|
||||
var noiseTokenSet = func() map[string]struct{} {
|
||||
set := make(map[string]struct{}, len(noiseTokens)+1)
|
||||
for _, token := range noiseTokens {
|
||||
set[token] = struct{}{}
|
||||
}
|
||||
set["dl"] = struct{}{}
|
||||
return set
|
||||
}()
|
||||
|
||||
// bracketedTag matches "[anything]" or "(anything)" segments.
|
||||
var bracketedTag = regexp.MustCompile(`[\[\(][^\]\)]*[\]\)]`)
|
||||
var multiWordNoise = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)\bweb[\s._-]*dl\b`),
|
||||
regexp.MustCompile(`(?i)\bblu[\s._-]*ray\b`),
|
||||
regexp.MustCompile(`(?i)\bdirectors[\s._-]*cut\b`),
|
||||
regexp.MustCompile(`(?i)\berai[\s._-]*raws\b`),
|
||||
regexp.MustCompile(`(?i)\bohys[\s._-]*raws\b`),
|
||||
}
|
||||
|
||||
// CleanQuery converts a filename like "Inception.2010.1080p.BluRay.x264.mkv"
|
||||
// into a TMDb-friendly title plus an optional year hint.
|
||||
@@ -105,8 +127,8 @@ func CleanQuery(raw string) (title string, year int) {
|
||||
lower = patEP.ReplaceAllString(lower, " ")
|
||||
lower = patCN.ReplaceAllString(lower, " ")
|
||||
|
||||
for _, t := range noiseTokens {
|
||||
lower = strings.ReplaceAll(lower, t, " ")
|
||||
for _, pat := range multiWordNoise {
|
||||
lower = pat.ReplaceAllString(lower, " ")
|
||||
}
|
||||
for _, sep := range []string{".", "_", "-", "[", "]", "(", ")", "×"} {
|
||||
lower = strings.ReplaceAll(lower, sep, " ")
|
||||
@@ -115,6 +137,9 @@ func CleanQuery(raw string) (title string, year int) {
|
||||
// 「2」「0」「v」之类残留干扰 TMDb 搜索。中文字符不算碎片。
|
||||
out := make([]string, 0, 8)
|
||||
for _, w := range strings.Fields(lower) {
|
||||
if _, ok := noiseTokenSet[w]; ok {
|
||||
continue
|
||||
}
|
||||
if len(w) <= 1 {
|
||||
r := []rune(w)
|
||||
if len(r) == 1 && r[0] < 128 {
|
||||
@@ -134,21 +159,51 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
||||
return err
|
||||
}
|
||||
|
||||
query := m.Title
|
||||
if query == "" {
|
||||
query, _ = CleanQuery(m.Path)
|
||||
} else {
|
||||
query, _ = CleanQuery(query)
|
||||
seriesLike := mediaIsEpisodic(m, lib)
|
||||
var local *LocalMetadata
|
||||
if found, err := ReadLocalMetadata(m.Path, lib.Path, seriesLike); err == nil && found != nil {
|
||||
local = found
|
||||
applyLocalMetadata(m, local)
|
||||
} else if err != nil {
|
||||
s.log.Warn("read local metadata before scrape failed", zap.String("media_id", m.ID), zap.Error(err))
|
||||
}
|
||||
|
||||
year := m.Year
|
||||
if year == 0 {
|
||||
_, year = CleanQuery(filepath.Base(m.Path))
|
||||
}
|
||||
|
||||
match := s.lookup(ctx, lib, query, year)
|
||||
if s.adult != nil && s.adult.Enabled() {
|
||||
if code := firstText(localAdultCode(local), AdultCodeFromMediaPath(m.Path), normalizeAdultCode(m.OriginalName), normalizeAdultCode(m.Title)); code != "" {
|
||||
if adultMatch, err := s.adult.Search(ctx, code); err == nil && adultMatch != nil {
|
||||
mergeLocalMetadataIntoMatch(adultMatch, local)
|
||||
return s.applyProviderMatch(ctx, m, lib, adultMatch)
|
||||
} else if err != nil {
|
||||
s.log.Debug("adult metadata search failed", zap.String("media_id", m.ID), zap.String("code", code), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates := scrapeQueryCandidates(m, lib)
|
||||
var query string
|
||||
match := (*Match)(nil)
|
||||
for _, candidate := range candidates {
|
||||
match = s.lookup(ctx, lib, candidate, year)
|
||||
query = candidate
|
||||
if match != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if match == nil {
|
||||
if local != nil {
|
||||
return s.applyLocalMetadataMatch(ctx, m, local)
|
||||
}
|
||||
_ = s.repo.DB.Model(&model.Media{}).Where("id = ?", m.ID).
|
||||
Update("scrape_status", "no_match").Error
|
||||
s.log.Info("metadata scrape no match",
|
||||
zap.String("media_id", m.ID),
|
||||
zap.String("query", query),
|
||||
zap.String("library_type", lib.Type))
|
||||
return nil
|
||||
}
|
||||
// Optional Fanart upgrade.
|
||||
@@ -162,7 +217,68 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
mergeLocalMetadataIntoMatch(match, local)
|
||||
|
||||
return s.applyProviderMatch(ctx, m, lib, match)
|
||||
}
|
||||
|
||||
func localAdultCode(local *LocalMetadata) string {
|
||||
if local == nil {
|
||||
return ""
|
||||
}
|
||||
return local.AdultCode
|
||||
}
|
||||
|
||||
func mergeLocalMetadataIntoMatch(match *Match, local *LocalMetadata) {
|
||||
if match == nil || local == nil {
|
||||
return
|
||||
}
|
||||
if local.Title != "" {
|
||||
match.Title = local.Title
|
||||
}
|
||||
if local.OriginalName != "" {
|
||||
match.OriginalName = local.OriginalName
|
||||
}
|
||||
if local.AdultCode != "" {
|
||||
match.OriginalName = local.AdultCode
|
||||
match.NSFW = true
|
||||
}
|
||||
if local.Overview != "" {
|
||||
match.Overview = local.Overview
|
||||
}
|
||||
if local.PosterURL != "" {
|
||||
match.PosterURL = local.PosterURL
|
||||
}
|
||||
if local.BackdropURL != "" {
|
||||
match.BackdropURL = local.BackdropURL
|
||||
}
|
||||
if local.Rating > 0 {
|
||||
match.Rating = local.Rating
|
||||
}
|
||||
if local.Year > 0 {
|
||||
match.Year = local.Year
|
||||
}
|
||||
if local.TMDbID > 0 {
|
||||
match.TMDbID = local.TMDbID
|
||||
}
|
||||
if local.BangumiID > 0 {
|
||||
match.BangumiID = local.BangumiID
|
||||
}
|
||||
if local.Genres != "" {
|
||||
match.Genres = splitNFOList(local.Genres)
|
||||
}
|
||||
if local.Countries != "" {
|
||||
match.Countries = splitNFOList(local.Countries)
|
||||
}
|
||||
if local.Languages != "" {
|
||||
match.Languages = splitNFOList(local.Languages)
|
||||
}
|
||||
if local.NSFW {
|
||||
match.NSFW = true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScraperService) applyProviderMatch(ctx context.Context, m *model.Media, lib *model.Library, match *Match) error {
|
||||
updates := map[string]any{
|
||||
"title": match.Title,
|
||||
"overview": match.Overview,
|
||||
@@ -172,12 +288,27 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
||||
"year": match.Year,
|
||||
"scrape_status": "matched",
|
||||
}
|
||||
if match.OriginalName != "" {
|
||||
updates["original_name"] = match.OriginalName
|
||||
}
|
||||
if match.TMDbID > 0 {
|
||||
updates["tmdb_id"] = match.TMDbID
|
||||
updates["tm_db_id"] = match.TMDbID
|
||||
}
|
||||
if match.BangumiID > 0 {
|
||||
updates["bangumi_id"] = match.BangumiID
|
||||
}
|
||||
if match.NSFW {
|
||||
updates["nsfw"] = true
|
||||
}
|
||||
if len(match.Genres) > 0 {
|
||||
updates["genres"] = strings.Join(match.Genres, ",")
|
||||
}
|
||||
if len(match.Countries) > 0 {
|
||||
updates["countries"] = strings.Join(match.Countries, ",")
|
||||
}
|
||||
if len(match.Languages) > 0 {
|
||||
updates["languages"] = strings.Join(match.Languages, ",")
|
||||
}
|
||||
|
||||
// Fetch extended metadata (languages, countries, genres) from TMDb
|
||||
if match.TMDbID > 0 && s.tmdb != nil && s.tmdb.Enabled() {
|
||||
@@ -210,15 +341,198 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
||||
Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if refreshed, err := s.repo.Media.FindByID(ctx, m.ID); err == nil && refreshed != nil {
|
||||
if path, err := WriteMediaNFO(refreshed); err != nil {
|
||||
s.log.Warn("write nfo after scrape failed", zap.String("media_id", m.ID), zap.Error(err))
|
||||
} else {
|
||||
s.log.Debug("write nfo after scrape", zap.String("media_id", m.ID), zap.String("path", path))
|
||||
}
|
||||
}
|
||||
s.hub.Publish("scrape", map[string]any{
|
||||
"media_id": m.ID,
|
||||
"title": match.Title,
|
||||
"tmdb_id": match.TMDbID,
|
||||
"bangumi_id": match.BangumiID,
|
||||
"source": map[bool]string{true: "adult"}[match.NSFW],
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ScraperService) applyLocalMetadataMatch(ctx context.Context, m *model.Media, local *LocalMetadata) error {
|
||||
next := *m
|
||||
applyLocalMetadata(&next, local)
|
||||
updates := map[string]any{
|
||||
"title": next.Title,
|
||||
"scrape_status": "matched",
|
||||
}
|
||||
if next.OriginalName != "" {
|
||||
updates["original_name"] = next.OriginalName
|
||||
}
|
||||
if next.Overview != "" {
|
||||
updates["overview"] = next.Overview
|
||||
}
|
||||
if next.PosterURL != "" {
|
||||
updates["poster_url"] = next.PosterURL
|
||||
}
|
||||
if next.BackdropURL != "" {
|
||||
updates["backdrop_url"] = next.BackdropURL
|
||||
}
|
||||
if next.Rating > 0 {
|
||||
updates["rating"] = next.Rating
|
||||
}
|
||||
if next.Year > 0 {
|
||||
updates["year"] = next.Year
|
||||
}
|
||||
if next.TMDbID > 0 {
|
||||
updates["tm_db_id"] = next.TMDbID
|
||||
}
|
||||
if next.BangumiID > 0 {
|
||||
updates["bangumi_id"] = next.BangumiID
|
||||
}
|
||||
if next.SeasonNum > 0 {
|
||||
updates["season_num"] = next.SeasonNum
|
||||
}
|
||||
if next.EpisodeNum > 0 {
|
||||
updates["episode_num"] = next.EpisodeNum
|
||||
}
|
||||
if next.Genres != "" {
|
||||
updates["genres"] = next.Genres
|
||||
}
|
||||
if next.Countries != "" {
|
||||
updates["countries"] = next.Countries
|
||||
}
|
||||
if next.Languages != "" {
|
||||
updates["languages"] = next.Languages
|
||||
}
|
||||
if next.NSFW {
|
||||
updates["nsfw"] = true
|
||||
}
|
||||
if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("id = ?", m.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
s.hub.Publish("scrape", map[string]any{
|
||||
"media_id": m.ID,
|
||||
"title": next.Title,
|
||||
"tmdb_id": next.TMDbID,
|
||||
"source": "local_nfo",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var out []string
|
||||
add := func(raw string) {
|
||||
cleaned, _ := CleanQuery(raw)
|
||||
if cleaned == "" {
|
||||
cleaned = strings.TrimSpace(raw)
|
||||
}
|
||||
for _, candidate := range titleCandidates(cleaned) {
|
||||
key := strings.ToLower(candidate)
|
||||
if _, ok := seen[key]; ok || candidate == "" {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, candidate)
|
||||
}
|
||||
}
|
||||
if lib != nil && mediaIsEpisodic(m, lib) {
|
||||
add(seriesFolderTitle(m.Path, lib.Path))
|
||||
}
|
||||
add(m.Title)
|
||||
add(m.Path)
|
||||
if len(out) == 0 {
|
||||
out = append(out, strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seriesFolderTitle(mediaPath, libraryRoot string) string {
|
||||
dir := filepath.Dir(mediaPath)
|
||||
if seasonFromDir(filepath.Base(dir)) > 0 {
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
if libraryRoot != "" && samePath(dir, filepath.Clean(libraryRoot)) {
|
||||
return ""
|
||||
}
|
||||
base := filepath.Base(dir)
|
||||
if base == "." || base == string(filepath.Separator) {
|
||||
return ""
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func seasonFromDir(name string) int {
|
||||
if m := patSeasonFolder.FindStringSubmatch(name); len(m) >= 3 {
|
||||
for _, group := range m[1:] {
|
||||
if group != "" {
|
||||
return mustAtoi(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func titleCandidates(title string) []string {
|
||||
title = strings.Join(strings.Fields(strings.TrimSpace(title)), " ")
|
||||
if title == "" {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, 2)
|
||||
if cjk := cjkTitleOnly(title); cjk != "" {
|
||||
out = append(out, cjk)
|
||||
if cjk != title {
|
||||
return out
|
||||
}
|
||||
}
|
||||
out = append(out, title)
|
||||
return out
|
||||
}
|
||||
|
||||
func cjkTitleOnly(title string) string {
|
||||
parts := make([]string, 0, 4)
|
||||
for _, field := range strings.Fields(title) {
|
||||
if containsCJK(field) {
|
||||
parts = append(parts, field)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func containsCJK(s string) bool {
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= '\u3400' && r <= '\u4dbf':
|
||||
return true
|
||||
case r >= '\u4e00' && r <= '\u9fff':
|
||||
return true
|
||||
case r >= '\uf900' && r <= '\ufaff':
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mediaIsEpisodic(m *model.Media, lib *model.Library) bool {
|
||||
if m != nil && (m.SeasonNum > 0 || m.EpisodeNum > 0) {
|
||||
return true
|
||||
}
|
||||
return librarySupportsSeasons(lib)
|
||||
}
|
||||
|
||||
func librarySupportsSeasons(lib *model.Library) bool {
|
||||
if lib == nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(lib.Type)) {
|
||||
case "tv", "anime", "variety", "show", "shows":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// lookup runs the provider chain. When the library is missing we fall
|
||||
// back to TMDb only.
|
||||
//
|
||||
@@ -245,7 +559,7 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query s
|
||||
s.log.Debug("bangumi search failed", zap.String("query", query), zap.Error(err))
|
||||
}
|
||||
}
|
||||
case "tv":
|
||||
case "tv", "variety", "show", "shows":
|
||||
if s.thetvdb != nil && s.thetvdb.Enabled() {
|
||||
if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil {
|
||||
return m
|
||||
@@ -256,7 +570,7 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query s
|
||||
}
|
||||
if s.tmdb != nil && s.tmdb.Enabled() {
|
||||
// anime / tv 先用 TMDb /search/tv(剧名通常是 TV 类目)。
|
||||
if kind == "anime" || kind == "tv" {
|
||||
if kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows" {
|
||||
if m, err := s.tmdb.SearchTV(ctx, query, year); err == nil && m != nil {
|
||||
return m
|
||||
} else if err != nil {
|
||||
@@ -272,15 +586,23 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query s
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnrichLibrary runs the provider chain for every "pending" media in a
|
||||
// library. It throttles to 4 RPS and publishes a summary event when done.
|
||||
// EnrichLibrary runs the provider chain for every pending media in a library.
|
||||
// When retryNoMatch is true it also retries rows previously marked no_match,
|
||||
// which is the expected behaviour for a manual "重新刮削" action. Scanner-driven
|
||||
// automatic enrichment keeps the default false path to avoid repeated scraping.
|
||||
//
|
||||
// Pending status includes both the canonical "pending" string and the
|
||||
// empty / NULL values, because MediaRepository.Upsert can wipe the GORM
|
||||
// default when re-running a scan over an already-existing row.
|
||||
func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string) (int, error) {
|
||||
func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string, retryNoMatch ...bool) (int, error) {
|
||||
var rows []model.Media
|
||||
q := s.repo.DB.Where("scrape_status IS NULL OR scrape_status = '' OR scrape_status = ?", "pending")
|
||||
statusFilter := "scrape_status IS NULL OR scrape_status = '' OR scrape_status = ?"
|
||||
statusArgs := []any{"pending"}
|
||||
if len(retryNoMatch) > 0 && retryNoMatch[0] {
|
||||
statusFilter += " OR scrape_status = ?"
|
||||
statusArgs = append(statusArgs, "no_match")
|
||||
}
|
||||
q := s.repo.DB.Where(statusFilter, statusArgs...)
|
||||
if libraryID != "" {
|
||||
q = q.Where("library_id = ?", libraryID)
|
||||
}
|
||||
@@ -288,6 +610,7 @@ func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string) (i
|
||||
return 0, err
|
||||
}
|
||||
matched := 0
|
||||
processed := 0
|
||||
for i := range rows {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -298,17 +621,30 @@ func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string) (i
|
||||
s.log.Warn("enrich failed", zap.String("media", rows[i].ID), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
processed++
|
||||
if s.mediaIsMatched(ctx, rows[i].ID) {
|
||||
matched++
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond) // ~4 RPS
|
||||
}
|
||||
s.hub.Publish("scrape", map[string]any{
|
||||
"library_id": libraryID,
|
||||
"finished": true,
|
||||
"matched": matched,
|
||||
"processed": processed,
|
||||
})
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func (s *ScraperService) mediaIsMatched(ctx context.Context, mediaID string) bool {
|
||||
var status string
|
||||
err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Select("scrape_status").
|
||||
Where("id = ?", mediaID).
|
||||
Scan(&status).Error
|
||||
return err == nil && status == "matched"
|
||||
}
|
||||
|
||||
// AnyEnabled reports whether at least one provider can run.
|
||||
func (s *ScraperService) AnyEnabled() bool {
|
||||
if s.tmdb != nil && s.tmdb.Enabled() {
|
||||
@@ -320,6 +656,9 @@ func (s *ScraperService) AnyEnabled() bool {
|
||||
if s.thetvdb != nil && s.thetvdb.Enabled() {
|
||||
return true
|
||||
}
|
||||
if s.adult != nil && s.adult.Enabled() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -328,7 +667,7 @@ func (s *ScraperService) AnyEnabled() bool {
|
||||
func (s *ScraperService) determineMediaType(lib *model.Library, match *Match) string {
|
||||
if lib != nil {
|
||||
switch lib.Type {
|
||||
case "tv", "anime":
|
||||
case "tv", "anime", "variety", "show", "shows":
|
||||
return "tv"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestCleanQuery(t *testing.T) {
|
||||
cases := []struct {
|
||||
@@ -13,6 +29,7 @@ func TestCleanQuery(t *testing.T) {
|
||||
{"interstellar.2014.4k.hdr.dts.atmos.mkv", "interstellar", 2014},
|
||||
{"My Movie 2022 [HDR] (1080p) [TGx].mp4", "my movie", 2022},
|
||||
{"NoYearOrTags.mkv", "noyearortags", 0},
|
||||
{"亏成首富从游戏开始 The Richest in Game - S01E11 - 4K.mp4", "亏成首富从游戏开始 the richest in game", 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
@@ -24,3 +41,215 @@ func TestCleanQuery(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrapeQueryCandidatesPreferSeriesFolderAndCJKTitle(t *testing.T) {
|
||||
lib := &model.Library{
|
||||
Path: `F:\downloads\国产剧`,
|
||||
Type: "movie",
|
||||
}
|
||||
media := &model.Media{
|
||||
Title: "亏成首富从游戏开始 the ri est in game",
|
||||
Path: `F:\downloads\国产剧\亏成首富从游戏开始 The Richest in Game\Season 01\亏成首富从游戏开始 The Richest in Game - S01E11 - 4K.mp4`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 11,
|
||||
}
|
||||
|
||||
got := scrapeQueryCandidates(media, lib)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("scrapeQueryCandidates returned no candidates")
|
||||
}
|
||||
if got[0] != "亏成首富从游戏开始" {
|
||||
t.Fatalf("first query candidate = %q, want Chinese series title", got[0])
|
||||
}
|
||||
for _, candidate := range got {
|
||||
if strings.Contains(candidate, "ri est") {
|
||||
t.Fatalf("query candidate kept substring-stripped title: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichOneWritesTMDbIDColumn(t *testing.T) {
|
||||
scraper, repos, closeServer := newTestScraper(t)
|
||||
defer closeServer()
|
||||
|
||||
lib := model.Library{Name: "番剧", Path: t.TempDir(), Type: "tv", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(lib.Path, "间谍过家家 - S02E01.mkv")
|
||||
if err := repos.DB.Create(&model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "间谍过家家",
|
||||
Path: mediaPath,
|
||||
SeasonNum: 2,
|
||||
EpisodeNum: 1,
|
||||
ScrapeStatus: "pending",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var media model.Media
|
||||
if err := repos.DB.First(&media, "path = ?", mediaPath).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got model.Media
|
||||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ScrapeStatus != "matched" || got.TMDbID != 12345 {
|
||||
t.Fatalf("unexpected scraped media: status=%q tmdb=%d", got.ScrapeStatus, got.TMDbID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichOnePrefersLocalMetadataWithoutProvider(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
log := zap.NewNop()
|
||||
scraper := NewScraperService(&config.Config{}, log, repos, nil, nil, nil, nil, NewHub(log))
|
||||
|
||||
root := t.TempDir()
|
||||
showDir := filepath.Join(root, "间谍过家家")
|
||||
seasonDir := filepath.Join(showDir, "Season 02")
|
||||
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(`<tvshow>
|
||||
<title>间谍过家家</title>
|
||||
<year>2022</year>
|
||||
<tmdbid>120089</tmdbid>
|
||||
<genre>Animation</genre>
|
||||
</tvshow>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(seasonDir, "间谍过家家 - S02E12.mkv")
|
||||
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<episodedetails>
|
||||
<title>企鹅公园</title>
|
||||
<showtitle>间谍过家家</showtitle>
|
||||
<season>2</season>
|
||||
<episode>12</episode>
|
||||
<plot>本地剧情</plot>
|
||||
</episodedetails>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lib := model.Library{Name: "番剧", Path: root, Type: "tv", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "bad title",
|
||||
Path: mediaPath,
|
||||
ScrapeStatus: "pending",
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got model.Media
|
||||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ScrapeStatus != "matched" || got.Title != "间谍过家家" || got.TMDbID != 120089 {
|
||||
t.Fatalf("unexpected local scrape: status=%q title=%q tmdb=%d", got.ScrapeStatus, got.Title, got.TMDbID)
|
||||
}
|
||||
if got.SeasonNum != 2 || got.EpisodeNum != 12 || got.Overview != "本地剧情" {
|
||||
t.Fatalf("unexpected local episode data: s=%d e=%d overview=%q", got.SeasonNum, got.EpisodeNum, got.Overview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualEnrichLibraryRetriesNoMatchAndCountsRealMatches(t *testing.T) {
|
||||
scraper, repos, closeServer := newTestScraper(t)
|
||||
defer closeServer()
|
||||
|
||||
lib := model.Library{Name: "番剧", Path: t.TempDir(), Type: "tv", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(lib.Path, "间谍过家家 - S02E02.mkv")
|
||||
if err := repos.DB.Create(&model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "间谍过家家",
|
||||
Path: mediaPath,
|
||||
SeasonNum: 2,
|
||||
EpisodeNum: 2,
|
||||
ScrapeStatus: "no_match",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if matched, err := scraper.EnrichLibrary(t.Context(), lib.ID); err != nil || matched != 0 {
|
||||
t.Fatalf("default EnrichLibrary matched=%d err=%v, want skipped no_match", matched, err)
|
||||
}
|
||||
if matched, err := scraper.EnrichLibrary(t.Context(), lib.ID, true); err != nil || matched != 1 {
|
||||
t.Fatalf("manual EnrichLibrary matched=%d err=%v, want one real match", matched, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestScraper(t *testing.T) (*ScraperService, *repository.Container, func()) {
|
||||
t.Helper()
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/search/tv"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"results": []map[string]any{{
|
||||
"id": 12345,
|
||||
"name": "间谍过家家",
|
||||
"overview": "测试简介",
|
||||
"poster_path": "/poster.jpg",
|
||||
"backdrop_path": "/backdrop.jpg",
|
||||
"first_air_date": "2022-04-09",
|
||||
"vote_average": 8.6,
|
||||
}},
|
||||
})
|
||||
case strings.HasPrefix(r.URL.Path, "/tv/12345"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"origin_country": []string{"JP"},
|
||||
"spoken_languages": []map[string]any{{
|
||||
"iso_639_1": "ja",
|
||||
}},
|
||||
"genres": []map[string]any{{
|
||||
"name": "Animation",
|
||||
}},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
upstream.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
|
||||
upstream.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.TMDbAPIKey = "test-key"
|
||||
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
||||
cfg.Secrets.TMDbImageProxy = upstream.URL + "/images"
|
||||
log := zap.NewNop()
|
||||
tmdb := NewTMDbProvider(cfg, log, nil)
|
||||
scraper := NewScraperService(cfg, log, repos, tmdb, nil, nil, nil, NewHub(log))
|
||||
|
||||
return scraper, repos, upstream.Close
|
||||
}
|
||||
|
||||
+111
-108
@@ -15,58 +15,58 @@ import (
|
||||
|
||||
// Container 持有在启动时初始化的每个服务。Handler 接收指向它的指针并选择相关字段。
|
||||
type Container struct {
|
||||
Cfg *config.Config
|
||||
Log *zap.Logger
|
||||
Repo *repository.Container
|
||||
WSHub *Hub
|
||||
SSEHub *SSEHub
|
||||
Auth *AuthService
|
||||
Media *MediaService
|
||||
Scan *ScannerService
|
||||
Stream *StreamService
|
||||
Transcoder *TranscoderService
|
||||
FFprobe *FFprobeService
|
||||
TMDb *TMDbProvider
|
||||
Bangumi *BangumiProvider
|
||||
TheTVDB *TheTVDBProvider
|
||||
Fanart *FanartProvider
|
||||
Scraper *ScraperService
|
||||
Discover *DiscoverService
|
||||
Playback *PlaybackService
|
||||
ImageProxy *ImageProxy
|
||||
Watcher *WatcherService
|
||||
Downloads *DownloadService
|
||||
Subscription *SubscriptionService
|
||||
Subtitle *SubtitleService
|
||||
Stats *StatsService
|
||||
Profile *ProfileService
|
||||
Audit *AuditService
|
||||
NFO *NFOService
|
||||
AI *AIService
|
||||
APIConfig *APIConfigService
|
||||
Crypto *CryptoService
|
||||
Duplicate *DuplicateService
|
||||
FileManager *FileManagerService
|
||||
DLNA *DLNAService
|
||||
Scheduler *SchedulerService
|
||||
Storage *StorageService
|
||||
Emby *EmbyService
|
||||
Backup *BackupService
|
||||
Notifier *NotifierService
|
||||
NotifyChannels *NotifyChannelService
|
||||
TelegramBot *TelegramBotService
|
||||
PlayProfiles *PlayProfileService
|
||||
Permissions *PermissionService
|
||||
StorageCfg *StorageConfigService
|
||||
Cfg *config.Config
|
||||
Log *zap.Logger
|
||||
Repo *repository.Container
|
||||
WSHub *Hub
|
||||
SSEHub *SSEHub
|
||||
Auth *AuthService
|
||||
Media *MediaService
|
||||
Scan *ScannerService
|
||||
Stream *StreamService
|
||||
Transcoder *TranscoderService
|
||||
FFprobe *FFprobeService
|
||||
TMDb *TMDbProvider
|
||||
Bangumi *BangumiProvider
|
||||
TheTVDB *TheTVDBProvider
|
||||
Fanart *FanartProvider
|
||||
Scraper *ScraperService
|
||||
Discover *DiscoverService
|
||||
Playback *PlaybackService
|
||||
ImageProxy *ImageProxy
|
||||
Watcher *WatcherService
|
||||
Downloads *DownloadService
|
||||
Subscription *SubscriptionService
|
||||
Subtitle *SubtitleService
|
||||
Stats *StatsService
|
||||
Profile *ProfileService
|
||||
Audit *AuditService
|
||||
NFO *NFOService
|
||||
AI *AIService
|
||||
APIConfig *APIConfigService
|
||||
Crypto *CryptoService
|
||||
Duplicate *DuplicateService
|
||||
FileManager *FileManagerService
|
||||
DLNA *DLNAService
|
||||
Scheduler *SchedulerService
|
||||
Storage *StorageService
|
||||
Emby *EmbyService
|
||||
Backup *BackupService
|
||||
Notifier *NotifierService
|
||||
NotifyChannels *NotifyChannelService
|
||||
TelegramBot *TelegramBotService
|
||||
PlayProfiles *PlayProfileService
|
||||
Permissions *PermissionService
|
||||
StorageCfg *StorageConfigService
|
||||
DownloadClients *DownloadClientService
|
||||
Assistant *AssistantService
|
||||
Organizer *OrganizerService
|
||||
Douban *DoubanProvider
|
||||
Token *TokenService
|
||||
ApiConfig *ApiConfigService
|
||||
DownloadMgr *DownloadManager
|
||||
Notify *NotifyService
|
||||
Site *SiteService
|
||||
Assistant *AssistantService
|
||||
Organizer *OrganizerService
|
||||
Douban *DoubanProvider
|
||||
Token *TokenService
|
||||
ApiConfig *ApiConfigService
|
||||
DownloadMgr *DownloadManager
|
||||
Notify *NotifyService
|
||||
Site *SiteService
|
||||
|
||||
stopCtx context.Context
|
||||
stopCancel context.CancelFunc
|
||||
@@ -74,6 +74,8 @@ type Container struct {
|
||||
|
||||
// New 构建服务容器。
|
||||
func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Container {
|
||||
ApplyRuntimeSettings(context.Background(), cfg, repos, log)
|
||||
|
||||
hub := NewHub(log)
|
||||
go hub.Run()
|
||||
|
||||
@@ -88,13 +90,12 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
bangumi := NewBangumiProvider(cfg, log)
|
||||
thetvdb := NewTheTVDBProvider(cfg, log)
|
||||
fanart := NewFanartProvider(cfg, log)
|
||||
scraper := NewScraperService(cfg, log, repos, tmdb, bangumi, thetvdb, fanart, hub)
|
||||
adult := NewAdultProvider(log, apiConfig)
|
||||
scraper := NewScraperService(cfg, log, repos, tmdb, bangumi, thetvdb, fanart, hub, adult)
|
||||
organizer := NewOrganizerService(cfg, log, repos)
|
||||
downloads := NewDownloadService(log, repos, hub, organizer)
|
||||
discover := NewDiscoverService(log, tmdb)
|
||||
transcoder := NewTranscoderService(cfg, log, repos, hub)
|
||||
scanner := NewScannerService(cfg, log, repos, hub, probe, scraper)
|
||||
subscription := NewSubscriptionService(log, repos, downloads, hub)
|
||||
watcher := NewWatcherService(log, repos, scanner)
|
||||
nfo := NewNFOService(log, repos)
|
||||
ai := NewAIService(cfg, log)
|
||||
@@ -120,71 +121,73 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
apiConfigSvc := NewApiConfigService(cfg, log, repos, crypto)
|
||||
downloadMgr := NewDownloadManager(log, repos, crypto)
|
||||
notifySvc := NewNotifyService(log, repos, crypto)
|
||||
|
||||
|
||||
// 构建 FlareSolverr URL(如果启用)
|
||||
flareSolverrURL := ""
|
||||
if cfg.FlareSolverr.Enabled && cfg.FlareSolverr.URL != "" {
|
||||
flareSolverrURL = cfg.FlareSolverr.URL
|
||||
}
|
||||
siteSvc := NewSiteService(log, repos, flareSolverrURL)
|
||||
downloads := NewDownloadService(log, repos, hub, organizer, siteSvc)
|
||||
subscription := NewSubscriptionService(cfg, log, repos, downloads, siteSvc, hub)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &Container{
|
||||
Cfg: cfg,
|
||||
Log: log,
|
||||
Repo: repos,
|
||||
WSHub: hub,
|
||||
SSEHub: sseHub,
|
||||
Auth: NewAuthService(cfg, log, repos, tokenSvc, permissions),
|
||||
Media: NewMediaService(cfg, log, repos),
|
||||
Scan: scanner,
|
||||
Stream: NewStreamService(cfg, log, repos, transcoder),
|
||||
Transcoder: transcoder,
|
||||
FFprobe: probe,
|
||||
TMDb: tmdb,
|
||||
Bangumi: bangumi,
|
||||
TheTVDB: thetvdb,
|
||||
Fanart: fanart,
|
||||
Scraper: scraper,
|
||||
Discover: discover,
|
||||
Playback: NewPlaybackService(log, repos),
|
||||
ImageProxy: NewImageProxy(cfg, log),
|
||||
Watcher: watcher,
|
||||
Downloads: downloads,
|
||||
Subscription: subscription,
|
||||
Subtitle: NewSubtitleService(log, repos),
|
||||
Stats: NewStatsService(log, repos),
|
||||
Profile: NewProfileService(log, repos),
|
||||
Audit: NewAuditService(log, repos),
|
||||
NFO: nfo,
|
||||
AI: ai,
|
||||
APIConfig: apiConfig,
|
||||
Crypto: crypto,
|
||||
Duplicate: duplicate,
|
||||
FileManager: filemanager,
|
||||
DLNA: dlna,
|
||||
Scheduler: scheduler,
|
||||
Storage: storage,
|
||||
Emby: emby,
|
||||
Backup: backup,
|
||||
Notifier: notifier,
|
||||
NotifyChannels: notifyChannels,
|
||||
TelegramBot: telegramBot,
|
||||
PlayProfiles: playProfiles,
|
||||
Permissions: permissions,
|
||||
StorageCfg: storageCfg,
|
||||
Cfg: cfg,
|
||||
Log: log,
|
||||
Repo: repos,
|
||||
WSHub: hub,
|
||||
SSEHub: sseHub,
|
||||
Auth: NewAuthService(cfg, log, repos, tokenSvc, permissions),
|
||||
Media: NewMediaService(cfg, log, repos),
|
||||
Scan: scanner,
|
||||
Stream: NewStreamService(cfg, log, repos, transcoder),
|
||||
Transcoder: transcoder,
|
||||
FFprobe: probe,
|
||||
TMDb: tmdb,
|
||||
Bangumi: bangumi,
|
||||
TheTVDB: thetvdb,
|
||||
Fanart: fanart,
|
||||
Scraper: scraper,
|
||||
Discover: discover,
|
||||
Playback: NewPlaybackService(log, repos),
|
||||
ImageProxy: NewImageProxy(cfg, log),
|
||||
Watcher: watcher,
|
||||
Downloads: downloads,
|
||||
Subscription: subscription,
|
||||
Subtitle: NewSubtitleService(log, repos),
|
||||
Stats: NewStatsService(log, repos),
|
||||
Profile: NewProfileService(log, repos),
|
||||
Audit: NewAuditService(log, repos),
|
||||
NFO: nfo,
|
||||
AI: ai,
|
||||
APIConfig: apiConfig,
|
||||
Crypto: crypto,
|
||||
Duplicate: duplicate,
|
||||
FileManager: filemanager,
|
||||
DLNA: dlna,
|
||||
Scheduler: scheduler,
|
||||
Storage: storage,
|
||||
Emby: emby,
|
||||
Backup: backup,
|
||||
Notifier: notifier,
|
||||
NotifyChannels: notifyChannels,
|
||||
TelegramBot: telegramBot,
|
||||
PlayProfiles: playProfiles,
|
||||
Permissions: permissions,
|
||||
StorageCfg: storageCfg,
|
||||
DownloadClients: downloadClients,
|
||||
Assistant: assistant,
|
||||
Organizer: organizer,
|
||||
Douban: douban,
|
||||
Token: tokenSvc,
|
||||
ApiConfig: apiConfigSvc,
|
||||
DownloadMgr: downloadMgr,
|
||||
Notify: notifySvc,
|
||||
Site: siteSvc,
|
||||
stopCtx: ctx,
|
||||
stopCancel: cancel,
|
||||
Assistant: assistant,
|
||||
Organizer: organizer,
|
||||
Douban: douban,
|
||||
Token: tokenSvc,
|
||||
ApiConfig: apiConfigSvc,
|
||||
DownloadMgr: downloadMgr,
|
||||
Notify: notifySvc,
|
||||
Site: siteSvc,
|
||||
stopCtx: ctx,
|
||||
stopCancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+148
-2
@@ -9,6 +9,12 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -24,11 +30,149 @@ import (
|
||||
|
||||
// SiteService manages PT/BT site configurations.
|
||||
type SiteService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
flareSolverrURL string
|
||||
}
|
||||
|
||||
// ResolveDownloadURL converts tracker-specific search result URLs into a URL
|
||||
// that a downloader can fetch directly. M-Team, NexusPHP and similar sites
|
||||
// often expose a signed/detail endpoint in search results; qBittorrent cannot
|
||||
// call those APIs with the configured site credentials, so subscriptions need
|
||||
// the same resolution path as the manual download button.
|
||||
func (s *SiteService) ResolveDownloadURL(ctx context.Context, raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw
|
||||
}
|
||||
matched := s.matchSiteForURL(ctx, raw)
|
||||
if matched == nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return raw
|
||||
}
|
||||
id := u.Query().Get("id")
|
||||
if id == "" {
|
||||
return raw
|
||||
}
|
||||
adapter := GetAdapterForType(matched.Type)
|
||||
if adapter == nil {
|
||||
return raw
|
||||
}
|
||||
cfg := s.siteModelToConfig(matched)
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
resolveCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
resolved, err := adapter.GetDownloadURL(resolveCtx, cfg, id)
|
||||
if err != nil || resolved == "" {
|
||||
s.log.Warn("resolve PT download URL failed",
|
||||
zap.String("site", matched.Name),
|
||||
zap.String("raw", raw),
|
||||
zap.Error(err))
|
||||
return raw
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func (s *SiteService) FetchTorrentFile(ctx context.Context, raw string) ([]byte, string, error) {
|
||||
matched := s.matchSiteForURL(ctx, raw)
|
||||
if matched == nil {
|
||||
return nil, "", errors.New("no matching PT site for torrent URL")
|
||||
}
|
||||
cfg := s.siteModelToConfig(matched)
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
req, err := buildRequest(ctx, http.MethodGet, raw, cfg, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/x-bittorrent,application/octet-stream,*/*")
|
||||
client := newHTTPClient(cfg, timeout)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, "", fmt.Errorf("torrent fetch: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
const maxTorrentSize = 32 << 20
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxTorrentSize+1))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, "", errors.New("torrent fetch: empty body")
|
||||
}
|
||||
if len(data) > maxTorrentSize {
|
||||
return nil, "", errors.New("torrent fetch: body too large")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") {
|
||||
return nil, "", errors.New("torrent fetch: upstream returned HTML")
|
||||
}
|
||||
if torrentInfoHash(data) == "" {
|
||||
return nil, "", errors.New("torrent fetch: upstream did not return a valid torrent")
|
||||
}
|
||||
return data, torrentFilename(raw, resp.Header.Get("Content-Disposition")), nil
|
||||
}
|
||||
|
||||
func (s *SiteService) matchSiteForURL(ctx context.Context, raw string) *model.Site {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return nil
|
||||
}
|
||||
host := strings.ToLower(u.Host)
|
||||
|
||||
sites, err := s.List(ctx)
|
||||
if err != nil || len(sites) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range sites {
|
||||
if siteHostMatches(host, sites[i].URL) || siteHostMatches(host, sites[i].RSSURL) {
|
||||
return &sites[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func siteHostMatches(host, raw string) bool {
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
siteHost := strings.ToLower(u.Host)
|
||||
return strings.EqualFold(siteHost, host) || strings.HasSuffix(host, "."+siteHost)
|
||||
}
|
||||
|
||||
func torrentFilename(rawURL, disposition string) string {
|
||||
if disposition != "" {
|
||||
if _, params, err := mime.ParseMediaType(disposition); err == nil {
|
||||
if filename := strings.TrimSpace(params["filename"]); filename != "" {
|
||||
return filename
|
||||
}
|
||||
}
|
||||
}
|
||||
if u, err := url.Parse(rawURL); err == nil {
|
||||
if name := strings.TrimSpace(path.Base(u.Path)); name != "" && name != "." && name != "/" {
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".torrent") {
|
||||
name += ".torrent"
|
||||
}
|
||||
return name
|
||||
}
|
||||
}
|
||||
return "download.torrent"
|
||||
}
|
||||
|
||||
// NewSiteService is the constructor.
|
||||
func NewSiteService(log *zap.Logger, repo *repository.Container, flareSolverrURL string) *SiteService {
|
||||
return &SiteService{log: log, repo: repo, flareSolverrURL: flareSolverrURL}
|
||||
@@ -164,6 +308,7 @@ type SearchResult struct {
|
||||
Title string `json:"title"`
|
||||
TorrentURL string `json:"torrent_url"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
Seeders int `json:"seeders"`
|
||||
Leechers int `json:"leechers"`
|
||||
@@ -235,6 +380,7 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul
|
||||
Title: item.Title,
|
||||
TorrentURL: item.DetailURL,
|
||||
DownloadURL: item.DownloadURL,
|
||||
Category: item.Category,
|
||||
Size: item.Size,
|
||||
Seeders: item.Seeders,
|
||||
Leechers: item.Leechers,
|
||||
|
||||
@@ -19,6 +19,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -103,10 +104,42 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
|
||||
stat, _ := f.Stat()
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if r.URL.RawQuery != "" {
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
playlist := appendQueryToHLSSegments(string(data), r.URL.RawQuery)
|
||||
_, err = io.WriteString(w, playlist)
|
||||
return err
|
||||
}
|
||||
http.ServeContent(w, r, stat.Name(), stat.ModTime(), f)
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendQueryToHLSSegments(playlist, rawQuery string) string {
|
||||
if strings.TrimSpace(rawQuery) == "" {
|
||||
return playlist
|
||||
}
|
||||
lines := strings.SplitAfter(playlist, "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.Contains(trimmed, "?") {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(trimmed), ".ts") {
|
||||
lineEnding := ""
|
||||
if strings.HasSuffix(line, "\r\n") {
|
||||
lineEnding = "\r\n"
|
||||
} else if strings.HasSuffix(line, "\n") {
|
||||
lineEnding = "\n"
|
||||
}
|
||||
lines[i] = strings.TrimRight(line, "\r\n") + "?" + rawQuery + lineEnding
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "")
|
||||
}
|
||||
|
||||
// ServeHLSSegment writes a single .ts segment from the on-disk cache.
|
||||
func (s *StreamService) ServeHLSSegment(w http.ResponseWriter, r *http.Request, mediaID, segment string) error {
|
||||
// Only allow segments that look like seg_NNNNN.ts so we cannot be tricked
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppendQueryToHLSSegments(t *testing.T) {
|
||||
in := "#EXTM3U\n#EXTINF:4.0,\nseg_00000.ts\n#EXTINF:4.0,\nseg_00001.ts?old=1\n"
|
||||
got := appendQueryToHLSSegments(in, "token=abc")
|
||||
if !strings.Contains(got, "seg_00000.ts?token=abc") {
|
||||
t.Fatalf("missing tokenized segment: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "seg_00001.ts?old=1") {
|
||||
t.Fatalf("existing query should be preserved: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -13,30 +13,39 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// SubscriptionService runs the polling loop.
|
||||
type SubscriptionService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
downloads *DownloadService
|
||||
site *SiteService
|
||||
hub *Hub
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
// NewSubscriptionService is the constructor.
|
||||
func NewSubscriptionService(log *zap.Logger, repo *repository.Container, downloads *DownloadService, hub *Hub) *SubscriptionService {
|
||||
func NewSubscriptionService(cfg *config.Config, log *zap.Logger, repo *repository.Container, downloads *DownloadService, site *SiteService, hub *Hub) *SubscriptionService {
|
||||
return &SubscriptionService{
|
||||
log: log, repo: repo, downloads: downloads, hub: hub,
|
||||
stop: make(chan struct{}),
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repo: repo,
|
||||
downloads: downloads,
|
||||
site: site,
|
||||
hub: hub,
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +78,17 @@ func (s *SubscriptionService) Create(ctx context.Context, sub *model.Subscriptio
|
||||
if sub.Name == "" || sub.FeedURL == "" {
|
||||
return errors.New("name and feed_url required")
|
||||
}
|
||||
return s.repo.Subscription.Create(ctx, sub)
|
||||
enabled := sub.Enabled
|
||||
if err := s.repo.Subscription.Create(ctx, sub); err != nil {
|
||||
return err
|
||||
}
|
||||
if !enabled {
|
||||
if err := s.repo.DB.WithContext(ctx).Model(sub).Update("enabled", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
sub.Enabled = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns every subscription rule.
|
||||
@@ -133,6 +152,10 @@ func (s *SubscriptionService) runAll(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscription) (int, error) {
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(sub.FeedURL)), "site-search://") {
|
||||
return s.runSiteSearch(ctx, sub)
|
||||
}
|
||||
|
||||
feed, err := s.fetch(ctx, sub.FeedURL)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -166,9 +189,15 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
|
||||
if download == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := s.downloads.AddDownload(ctx, sub.UserID, download, ""); err != nil {
|
||||
mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, item.Title, "")
|
||||
savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory)
|
||||
if _, err := s.downloads.AddDownload(ctx, sub.UserID, download, savePath); err != nil {
|
||||
s.log.Warn("subscription enqueue failed",
|
||||
zap.String("title", item.Title), zap.Error(err))
|
||||
zap.String("title", item.Title),
|
||||
zap.String("media_type", mediaType),
|
||||
zap.String("media_category", mediaCategory),
|
||||
zap.String("save_path", savePath),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
queued++
|
||||
@@ -192,6 +221,119 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
|
||||
return queued, nil
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subscription) (int, error) {
|
||||
if s.site == nil {
|
||||
return 0, errors.New("site search service unavailable")
|
||||
}
|
||||
keyword := siteSearchKeyword(sub)
|
||||
if keyword == "" {
|
||||
return 0, errors.New("site-search subscription keyword required")
|
||||
}
|
||||
|
||||
results, err := s.site.Search(ctx, keyword)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
now := time.Now()
|
||||
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
guidKey := fmt.Sprintf("subscription.%s.seen", sub.ID)
|
||||
seenRaw, _ := s.repo.Setting.Get(ctx, guidKey)
|
||||
seen := splitNonEmpty(seenRaw)
|
||||
seenSet := make(map[string]struct{}, len(seen))
|
||||
for _, g := range seen {
|
||||
seenSet[g] = struct{}{}
|
||||
}
|
||||
|
||||
var lastEnqueueErr error
|
||||
for _, item := range results {
|
||||
download := strings.TrimSpace(item.DownloadURL)
|
||||
if download == "" {
|
||||
download = strings.TrimSpace(item.TorrentURL)
|
||||
}
|
||||
if download == "" {
|
||||
continue
|
||||
}
|
||||
guid := download
|
||||
if _, ok := seenSet[guid]; ok {
|
||||
continue
|
||||
}
|
||||
if s.downloads != nil && s.downloads.TorrentExistsByName(ctx, item.Title) {
|
||||
seen = append(seen, guid)
|
||||
if len(seen) > 200 {
|
||||
seen = seen[len(seen)-200:]
|
||||
}
|
||||
_ = s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n"))
|
||||
now := time.Now()
|
||||
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
|
||||
s.hub.Publish("subscription", map[string]any{
|
||||
"id": sub.ID,
|
||||
"name": sub.Name,
|
||||
"queued": 0,
|
||||
"keyword": keyword,
|
||||
"resource": item.Title,
|
||||
"existing": true,
|
||||
})
|
||||
return 0, nil
|
||||
}
|
||||
realURL := s.site.ResolveDownloadURL(ctx, download)
|
||||
mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, item.Title, item.Category)
|
||||
savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory)
|
||||
if _, err := s.downloads.AddDownload(ctx, sub.UserID, realURL, savePath); err != nil {
|
||||
lastEnqueueErr = err
|
||||
s.log.Warn("site-search subscription enqueue failed",
|
||||
zap.String("subscription", sub.Name),
|
||||
zap.String("title", item.Title),
|
||||
zap.String("site_category", item.Category),
|
||||
zap.String("media_type", mediaType),
|
||||
zap.String("media_category", mediaCategory),
|
||||
zap.String("save_path", savePath),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
seen = append(seen, guid)
|
||||
if len(seen) > 200 {
|
||||
seen = seen[len(seen)-200:]
|
||||
}
|
||||
_ = s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n"))
|
||||
now := time.Now()
|
||||
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
|
||||
s.hub.Publish("subscription", map[string]any{
|
||||
"id": sub.ID,
|
||||
"name": sub.Name,
|
||||
"queued": 1,
|
||||
"keyword": keyword,
|
||||
"resource": item.Title,
|
||||
})
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
|
||||
if lastEnqueueErr != nil {
|
||||
return 0, fmt.Errorf("找到 PT 资源但加入下载器失败: %w", lastEnqueueErr)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func siteSearchKeyword(sub *model.Subscription) string {
|
||||
if sub == nil {
|
||||
return ""
|
||||
}
|
||||
if u, err := url.Parse(sub.FeedURL); err == nil {
|
||||
if keyword := strings.TrimSpace(u.Query().Get("keyword")); keyword != "" {
|
||||
return keyword
|
||||
}
|
||||
}
|
||||
if keyword := strings.TrimSpace(sub.Filter); keyword != "" {
|
||||
return keyword
|
||||
}
|
||||
return strings.TrimSpace(sub.Name)
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) fetch(ctx context.Context, feedURL string) (*rssFeed, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -77,7 +77,7 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti
|
||||
candidates = append(candidates, filepath.Join(dir, sub))
|
||||
}
|
||||
|
||||
var tracks []SubtitleTrack
|
||||
tracks := make([]SubtitleTrack, 0)
|
||||
for _, c := range candidates {
|
||||
entries, err := os.ReadDir(c)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,36 +1,44 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
func TestSrtToVTT(t *testing.T) {
|
||||
in := "1\n00:00:01,000 --> 00:00:02,500\nHello world\n\n"
|
||||
out := srtToVTT(in)
|
||||
if !strings.HasPrefix(out, "WEBVTT") {
|
||||
t.Fatalf("missing WEBVTT prefix: %q", out)
|
||||
func TestSubtitleDiscoverNoTracksReturnsEmptySlice(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "00:00:01.000 --> 00:00:02.500") {
|
||||
t.Fatalf("comma timecode not converted: %q", out)
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "Hello world") {
|
||||
t.Fatalf("dialogue lost: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripASSTags(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"{\\an8}hello", "hello"},
|
||||
{"plain", "plain"},
|
||||
{"a{\\fad(0,500)}b{\\b1}c", "abc"},
|
||||
dir := t.TempDir()
|
||||
media := model.Media{
|
||||
Title: "No Subtitles",
|
||||
Path: filepath.Join(dir, "No Subtitles.mkv"),
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := stripASSTags(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("stripASSTags(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
if err := db.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewSubtitleService(zap.NewNop(), repository.New(db))
|
||||
tracks, err := svc.Discover(t.Context(), media.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tracks == nil {
|
||||
t.Fatal("tracks is nil, want empty slice")
|
||||
}
|
||||
if len(tracks) != 0 {
|
||||
t.Fatalf("len(tracks) = %d, want 0", len(tracks))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +32,9 @@ type TheTVDBProvider struct {
|
||||
log *zap.Logger
|
||||
client *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
tokenExp time.Time
|
||||
mu sync.Mutex
|
||||
token string
|
||||
tokenExp time.Time
|
||||
}
|
||||
|
||||
// NewTheTVDBProvider is the constructor.
|
||||
@@ -42,7 +42,7 @@ func NewTheTVDBProvider(cfg *config.Config, log *zap.Logger) *TheTVDBProvider {
|
||||
return &TheTVDBProvider{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
client: NewExternalHTTPClient(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,11 +110,11 @@ func (t *TheTVDBProvider) SearchSeries(ctx context.Context, query string) (*Matc
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Overview string `json:"overview"`
|
||||
Image string `json:"image_url"`
|
||||
Year string `json:"year"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Overview string `json:"overview"`
|
||||
Image string `json:"image_url"`
|
||||
Year string `json:"year"`
|
||||
}
|
||||
type page struct {
|
||||
Data []entry `json:"data"`
|
||||
|
||||
+19
-27
@@ -7,8 +7,8 @@
|
||||
//
|
||||
// We only call the two endpoints the scrape pipeline actually needs:
|
||||
//
|
||||
// GET /search/movie?query=...&year=...
|
||||
// GET /movie/{id}?language=zh-CN
|
||||
// GET /search/movie?query=...&year=...
|
||||
// GET /movie/{id}?language=zh-CN
|
||||
//
|
||||
// TV / anime support follows the same pattern; for the bootstrap we expose
|
||||
// a single SearchMovie path so that the home page and library gallery can
|
||||
@@ -60,18 +60,8 @@ func NewTMDbProvider(cfg *config.Config, log *zap.Logger, apiConfig *APIConfigSe
|
||||
imgCDN: img,
|
||||
// 默认 8s 超时:首页同时调 trending + popular,15s 太久会让用户感觉
|
||||
// 卡死。如果 TMDb 真有问题,handler 层会快速降级返回空列表。
|
||||
// 同时让 client 显式读 HTTP(S)_PROXY 环境变量——这是 GFW 内部署最低
|
||||
// 成本能拉到 TMDb 的方式。
|
||||
client: &http.Client{
|
||||
Timeout: 8 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
MaxIdleConns: 16,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
TLSHandshakeTimeout: 8 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
},
|
||||
},
|
||||
// 同时让 client 读取 HTTP(S)_PROXY 与 Windows 本机系统代理。
|
||||
client: NewExternalHTTPClient(8 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,17 +124,19 @@ func (t *TMDbProvider) resolveBaseURL(ctx context.Context) string {
|
||||
// across providers; provider-specific IDs sit side-by-side so the scraper
|
||||
// orchestrator can write them all into a single update.
|
||||
type Match struct {
|
||||
TMDbID int `json:"tmdb_id"`
|
||||
BangumiID int `json:"bangumi_id"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
PosterURL string `json:"poster_url"`
|
||||
BackdropURL string `json:"backdrop_url"`
|
||||
Year int `json:"year"`
|
||||
Rating float32 `json:"rating"`
|
||||
Languages []string `json:"languages,omitempty"`
|
||||
Countries []string `json:"countries,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
TMDbID int `json:"tmdb_id"`
|
||||
BangumiID int `json:"bangumi_id"`
|
||||
Title string `json:"title"`
|
||||
OriginalName string `json:"original_name,omitempty"`
|
||||
Overview string `json:"overview"`
|
||||
PosterURL string `json:"poster_url"`
|
||||
BackdropURL string `json:"backdrop_url"`
|
||||
Year int `json:"year"`
|
||||
Rating float32 `json:"rating"`
|
||||
Languages []string `json:"languages,omitempty"`
|
||||
Countries []string `json:"countries,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
NSFW bool `json:"nsfw,omitempty"`
|
||||
}
|
||||
|
||||
// SearchMovie issues `/search/movie` and returns the best match, or nil
|
||||
@@ -326,7 +318,7 @@ func (t *TMDbProvider) GetDetails(ctx context.Context, tmdbID int, mediaType str
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type movieResult struct {
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
ProductionCountries []struct {
|
||||
Iso3166_1 string `json:"iso_3166_1"`
|
||||
} `json:"production_countries"`
|
||||
@@ -336,7 +328,7 @@ func (t *TMDbProvider) GetDetails(ctx context.Context, tmdbID int, mediaType str
|
||||
Genres []genre `json:"genres"`
|
||||
}
|
||||
type tvResult struct {
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
SpokenLanguages []struct {
|
||||
Iso639_1 string `json:"iso_639_1"`
|
||||
} `json:"spoken_languages"`
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
//
|
||||
// Encoder selection (read once at startup from the config):
|
||||
//
|
||||
// transcoder.encoder = "" | "nvenc" | "qsv" | "vaapi"
|
||||
// transcoder.encoder = "" | "nvenc" | "qsv" | "vaapi"
|
||||
//
|
||||
// "" software libx264 (default; runs anywhere)
|
||||
// nvenc h264_nvenc (NVIDIA GPU, requires --gpus all on Docker)
|
||||
// qsv h264_qsv (Intel iGPU, requires /dev/dri:/dev/dri)
|
||||
// vaapi h264_vaapi (Mesa/Intel VAAPI, requires /dev/dri:/dev/dri
|
||||
// plus the kernel module loaded)
|
||||
// "" software libx264 (default; runs anywhere)
|
||||
// nvenc h264_nvenc (NVIDIA GPU, requires --gpus all on Docker)
|
||||
// qsv h264_qsv (Intel iGPU, requires /dev/dri:/dev/dri)
|
||||
// vaapi h264_vaapi (Mesa/Intel VAAPI, requires /dev/dri:/dev/dri
|
||||
// plus the kernel module loaded)
|
||||
//
|
||||
// Concurrency model:
|
||||
// - Each Media has at most one active ffmpeg job.
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -40,8 +41,8 @@ import (
|
||||
|
||||
// TranscoderService orchestrates background ffmpeg transcodes.
|
||||
type TranscoderService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
hub *Hub
|
||||
|
||||
@@ -94,6 +95,9 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
||||
if _, err := os.Stat(m.Path); err != nil {
|
||||
return "", ErrMediaNotFound
|
||||
}
|
||||
if _, err := t.resolveFFmpegPath(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
if _, ok := t.jobs[mediaID]; ok {
|
||||
@@ -191,9 +195,18 @@ func (t *TranscoderService) Active() []ActiveJob {
|
||||
}
|
||||
|
||||
func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source string) {
|
||||
bin := t.cfg.App.FFmpegPath
|
||||
if bin == "" {
|
||||
bin = "ffmpeg"
|
||||
bin, err := t.resolveFFmpegPath()
|
||||
if err != nil {
|
||||
t.log.Warn("ffmpeg unavailable", zap.String("media_id", job.mediaID), zap.Error(err))
|
||||
t.mu.Lock()
|
||||
delete(t.jobs, job.mediaID)
|
||||
t.mu.Unlock()
|
||||
t.hub.Publish("transcode", map[string]any{
|
||||
"media_id": job.mediaID,
|
||||
"status": "error",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
playlist := filepath.Join(job.outputDir, "index.m3u8")
|
||||
@@ -233,6 +246,70 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source s
|
||||
})
|
||||
}
|
||||
|
||||
func (t *TranscoderService) resolveFFmpegPath() (string, error) {
|
||||
var lastErr error
|
||||
for _, bin := range executableCandidates(strings.TrimSpace(t.cfg.App.FFmpegPath), "ffmpeg") {
|
||||
if err := validateFFmpegForTranscode(context.Background(), bin, t.cfg.Transcoder.Encoder); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
t.cfg.App.FFmpegPath = bin
|
||||
return bin, nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
return "", fmt.Errorf("no usable ffmpeg found for HLS transcode: %w", lastErr)
|
||||
}
|
||||
return "", fmt.Errorf("ffmpeg not found in PATH or common local app directories; configure app.ffmpeg_path to an existing local ffmpeg")
|
||||
}
|
||||
|
||||
func validateFFmpegForTranscode(ctx context.Context, bin, encoder string) error {
|
||||
required := requiredVideoEncoder(encoder)
|
||||
out, err := commandOutput(ctx, 8*time.Second, bin, "-hide_banner", "-encoders")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s cannot list encoders: %w", bin, err)
|
||||
}
|
||||
if !hasFFmpegListEntry(string(out), required) {
|
||||
return fmt.Errorf("%s does not provide required encoder %s", bin, required)
|
||||
}
|
||||
|
||||
out, err = commandOutput(ctx, 8*time.Second, bin, "-hide_banner", "-muxers")
|
||||
if err != nil || !hasFFmpegListEntry(string(out), "hls") {
|
||||
out, err = commandOutput(ctx, 8*time.Second, bin, "-hide_banner", "-formats")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s cannot list muxers/formats: %w", bin, err)
|
||||
}
|
||||
}
|
||||
if !hasFFmpegListEntry(string(out), "hls") {
|
||||
return fmt.Errorf("%s does not provide hls muxer", bin)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requiredVideoEncoder(encoder string) string {
|
||||
switch encoder {
|
||||
case "nvenc":
|
||||
return "h264_nvenc"
|
||||
case "qsv":
|
||||
return "h264_qsv"
|
||||
case "vaapi":
|
||||
return "h264_vaapi"
|
||||
default:
|
||||
return "libx264"
|
||||
}
|
||||
}
|
||||
|
||||
func hasFFmpegListEntry(output, name string) bool {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
for _, field := range fields {
|
||||
if field == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildFFmpegArgs assembles the ffmpeg command line for the configured
|
||||
// encoder. The function is package-level so the unit test can pin its
|
||||
// behaviour without spawning a real ffmpeg process.
|
||||
@@ -317,8 +394,6 @@ func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []st
|
||||
"-f", "hls",
|
||||
"-hls_time", fmt.Sprintf("%d", segDur),
|
||||
"-hls_list_size", "0",
|
||||
"-hls_segment_type", "mpegts",
|
||||
"-hls_flags", "independent_segments",
|
||||
"-hls_segment_filename", segments,
|
||||
playlist,
|
||||
)
|
||||
|
||||
@@ -14,10 +14,10 @@ func TestBuildFFmpegArgs(t *testing.T) {
|
||||
base.App.VAAPIDevice = "/dev/dri/renderD128"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
encoder string
|
||||
expectVCodec string
|
||||
expectInArgs []string
|
||||
name string
|
||||
encoder string
|
||||
expectVCodec string
|
||||
expectInArgs []string
|
||||
expectNotPresetIfBlank bool
|
||||
}{
|
||||
{"software", "", "libx264", []string{"-preset", "veryfast", "-c:v", "libx264"}, false},
|
||||
@@ -44,3 +44,27 @@ func TestBuildFFmpegArgs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredVideoEncoder(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "libx264",
|
||||
"nvenc": "h264_nvenc",
|
||||
"qsv": "h264_qsv",
|
||||
"vaapi": "h264_vaapi",
|
||||
}
|
||||
for encoder, want := range cases {
|
||||
if got := requiredVideoEncoder(encoder); got != want {
|
||||
t.Fatalf("requiredVideoEncoder(%q) = %q, want %q", encoder, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFFmpegListEntry(t *testing.T) {
|
||||
out := " V..... libx264 libx264 H.264 / AVC\n A..... aac"
|
||||
if !hasFFmpegListEntry(out, "libx264") {
|
||||
t.Fatal("expected libx264 entry")
|
||||
}
|
||||
if hasFFmpegListEntry(out, "x264") {
|
||||
t.Fatal("must match whole ffmpeg list entries only")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ const HomePage = lazy(() => import('./pages/HomePage').then((m) => ({ default: m
|
||||
const LibraryPage = lazy(() =>
|
||||
import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage })),
|
||||
)
|
||||
const LibrariesPage = lazy(() =>
|
||||
import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage })),
|
||||
)
|
||||
const SearchPage = lazy(() =>
|
||||
import('./pages/SearchPage').then((m) => ({ default: m.SearchPage })),
|
||||
)
|
||||
@@ -116,6 +119,7 @@ export default function App() {
|
||||
}
|
||||
>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="libraries" element={<LibrariesPage />} />
|
||||
<Route path="library/:id" element={<LibraryPage />} />
|
||||
<Route path="discover" element={<DiscoverPage />} />
|
||||
<Route path="search" element={<SearchPage />} />
|
||||
|
||||
+19
-1
@@ -10,6 +10,21 @@ export interface SearchIntent {
|
||||
language?: string
|
||||
}
|
||||
|
||||
export interface ExternalMediaResult {
|
||||
source: string
|
||||
media_type?: string
|
||||
title: string
|
||||
overview?: string
|
||||
poster_url?: string
|
||||
backdrop_url?: string
|
||||
year?: number
|
||||
rating?: number
|
||||
tmdb_id?: number
|
||||
bangumi_id?: number
|
||||
douban_id?: string
|
||||
subscribe_keyword: string
|
||||
}
|
||||
|
||||
export const aiAPI = {
|
||||
status: () =>
|
||||
api
|
||||
@@ -18,7 +33,10 @@ export const aiAPI = {
|
||||
|
||||
smartSearch: (query: string) =>
|
||||
api
|
||||
.post<{ intent: SearchIntent; items: Media[] }>('/ai/search', { query })
|
||||
.post<{ intent: SearchIntent; items: Media[]; external_items: ExternalMediaResult[] }>(
|
||||
'/ai/search',
|
||||
{ query },
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
recommend: () => api.get<{ titles: string[] }>('/ai/recommend').then((r) => r.data.titles),
|
||||
|
||||
+22
-3
@@ -4,13 +4,24 @@ import type { Media } from '../types'
|
||||
// TMDb-derived "Match" rows used by trending/popular rails. We re-use the
|
||||
// Media interface — only TMDb id / poster / overview are populated.
|
||||
export interface DiscoverItem extends Partial<Media> {
|
||||
tmdb_id: number
|
||||
source?: string
|
||||
media_type?: string
|
||||
tmdb_id?: number
|
||||
douban_id?: string
|
||||
bangumi_id?: number
|
||||
title: string
|
||||
poster_url?: string
|
||||
backdrop_url?: string
|
||||
overview?: string
|
||||
year: number
|
||||
rating: number
|
||||
year?: number
|
||||
rating?: number
|
||||
subscribe_keyword?: string
|
||||
}
|
||||
|
||||
export interface DiscoverSection {
|
||||
key: string
|
||||
label: string
|
||||
provider?: string
|
||||
}
|
||||
|
||||
// 后端在 TMDb 不可达 / API key 缺失时统一返回 { items: [], error: "..." }
|
||||
@@ -32,4 +43,12 @@ export const discoverAPI = {
|
||||
items: r.data.items ?? [],
|
||||
error: r.data.error,
|
||||
})),
|
||||
sections: () =>
|
||||
api.get<{ sections: DiscoverSection[] }>('/discover/sections').then((r) => r.data.sections),
|
||||
feed: (sectionKeys: string[]) =>
|
||||
api
|
||||
.get<Record<string, DiscoverItem[] | null>>('/discover/feed', {
|
||||
params: { sections: sectionKeys.join(',') },
|
||||
})
|
||||
.then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -11,16 +11,23 @@ export interface DuplicateReport {
|
||||
total_scanned: number
|
||||
groups_found: number
|
||||
items_marked: number
|
||||
missing_removed?: number
|
||||
groups: DuplicateGroup[]
|
||||
}
|
||||
|
||||
export const duplicatesAPI = {
|
||||
list: (libraryID = '') =>
|
||||
api
|
||||
.get<DuplicateReport>('/duplicates', {
|
||||
params: libraryID ? { library_id: libraryID } : undefined,
|
||||
})
|
||||
.then((r) => ({ ...r.data, groups: r.data.groups ?? [] })),
|
||||
scan: (libraryID = '') =>
|
||||
api
|
||||
.post<DuplicateReport>('/duplicates/scan', null, {
|
||||
params: libraryID ? { library_id: libraryID } : undefined,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
.then((r) => ({ ...r.data, groups: r.data.groups ?? [] })),
|
||||
unmark: (libraryID = '') =>
|
||||
api
|
||||
.post<{ unmarked: number }>('/duplicates/unmark', null, {
|
||||
|
||||
@@ -5,7 +5,15 @@ export const subscriptionsAPI = {
|
||||
list: () =>
|
||||
api.get<{ items: Subscription[] }>('/subscriptions').then((r) => r.data.items),
|
||||
|
||||
create: (input: { name: string; feed_url: string; filter?: string; enabled?: boolean }) =>
|
||||
create: (input: {
|
||||
name: string
|
||||
feed_url: string
|
||||
filter?: string
|
||||
media_type?: string
|
||||
media_category?: string
|
||||
save_path?: string
|
||||
enabled?: boolean
|
||||
}) =>
|
||||
api.post<Subscription>('/subscriptions', input).then((r) => r.data),
|
||||
|
||||
remove: (id: string) => api.delete(`/subscriptions/${id}`).then((r) => r.data),
|
||||
|
||||
@@ -12,8 +12,8 @@ export interface SubtitleTrack {
|
||||
export const subtitlesAPI = {
|
||||
list: (mediaId: string) =>
|
||||
api
|
||||
.get<{ tracks: SubtitleTrack[] }>(`/media/${mediaId}/subtitles`)
|
||||
.then((r) => r.data.tracks),
|
||||
.get<{ tracks: SubtitleTrack[] | null }>(`/media/${mediaId}/subtitles`)
|
||||
.then((r) => r.data.tracks ?? []),
|
||||
|
||||
url: (mediaId: string, path: string) => {
|
||||
const token = useAuthStore.getState().token ?? ''
|
||||
|
||||
@@ -2,35 +2,27 @@
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
Activity, Bell, Cast, Clock, CloudDownload, Compass, Copy, Film,
|
||||
FolderTree, Globe, HardDrive, Heart, Home, GalleryHorizontalEnd,
|
||||
Link2, ListChecks, ListMusic, LogOut, MessageSquare, Rss, Search,
|
||||
Server, Settings, Sliders, Sparkles, Cloud, Trash2, UserCog, Wrench,
|
||||
Activity, Bell, Clock, CloudDownload, Compass, Film,
|
||||
Cast, Globe, HardDrive, Heart, Home, Image,
|
||||
ListMusic, LogOut, Rss, Search,
|
||||
Settings, Sliders, Sparkles, UserCog, Wrench,
|
||||
Library as LibraryIcon, User as UserIcon, ChevronDown, Menu, X
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { AppFooter } from './AppFooter'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { Library } from '../types'
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
|
||||
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false)
|
||||
const [searchFocused, setSearchFocused] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Automatically fetch libraries
|
||||
useEffect(() => {
|
||||
libraryAPI.list().then(setLibraries).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
// Auto-collapse sidebar on smaller tablet screens, and auto-hide drawer on path change
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
@@ -99,76 +91,48 @@ export function Layout() {
|
||||
<SectionHeader label="影音中心" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<div className="space-y-1">
|
||||
<SidebarLink to="/" icon={<Home size={18} />} label="系统首页" end collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/libraries" icon={<LibraryIcon size={18} />} label="媒体库" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/poster-wall" icon={<Image size={18} />} label="海报墙" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/discover" icon={<Compass size={18} />} label="精彩发现" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/search" icon={<Search size={18} />} label="智能搜索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="我的收藏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/poster-wall" icon={<GalleryHorizontalEnd size={18} />} label="影音海报墙" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 影视助理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 投屏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 助理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Group: Libraries */}
|
||||
{/* Navigation Group: Personal */}
|
||||
<div>
|
||||
<SectionHeader label="媒体片库" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<SectionHeader label="个人空间" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<div className="space-y-1">
|
||||
{libraries.length === 0 && (isSidebarOpen || isMobileDrawerOpen) && (
|
||||
<div className="px-4 py-2 text-xs text-gray-500 italic">暂无媒体片库</div>
|
||||
)}
|
||||
{libraries.map((lib) => (
|
||||
<SidebarLink
|
||||
key={lib.id}
|
||||
to={`/library/${lib.id}`}
|
||||
icon={<LibraryIcon size={18} />}
|
||||
label={lib.name}
|
||||
collapsed={!isSidebarOpen && !isMobileDrawerOpen}
|
||||
/>
|
||||
))}
|
||||
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="我的收藏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/profile" icon={<UserIcon size={18} />} label="账号信息" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Group: Automation */}
|
||||
<div>
|
||||
<SectionHeader label="自动化运维" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<SectionHeader label="下载订阅" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<div className="space-y-1">
|
||||
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载中心" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 串流" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/download-clients" icon={<Sliders size={18} />} label="下载器管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="订阅管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/site-search" icon={<Search size={18} />} label="站点检索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Group: Account Profile */}
|
||||
<div>
|
||||
<SectionHeader label="个人资料" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<div className="space-y-1">
|
||||
<SidebarLink to="/profile" icon={<UserIcon size={18} />} label="账号信息" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/play-profiles" icon={<UserCog size={18} />} label="观影 Profile" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Group: Admin Dashboard */}
|
||||
{user?.role === 'admin' && (
|
||||
<div>
|
||||
<SectionHeader label="系统后台" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<SectionHeader label="统一管理" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<div className="space-y-1">
|
||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="后台主页" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行监控" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/notify-channels" icon={<Bell size={18} />} label="通知配置" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/download-clients" icon={<Server size={18} />} label="下载客户端" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时机制" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储分析" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/storage-config" icon={<Cloud size={18} />} label="外部挂载" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件管家" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="排重清理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/strm" icon={<Link2 size={18} />} label="STRM 关联" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="运维工具" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/assistant" icon={<MessageSquare size={18} />} label="AI 智能助教" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统参数" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="媒体与用户" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点与下载器" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="整理与维护" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储与文件" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统设置" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -289,10 +253,15 @@ export function Layout() {
|
||||
</Link>
|
||||
|
||||
{/* Notification alert bubble */}
|
||||
<button className="relative rounded-xl border border-gray-200 p-2.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-all">
|
||||
<Link
|
||||
to="/notify-channels"
|
||||
title="通知配置"
|
||||
aria-label="打开通知配置"
|
||||
className="relative rounded-xl border border-gray-200 p-2.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-all"
|
||||
>
|
||||
<Bell size={18} />
|
||||
<span className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-brand-500 ring-2 ring-white animate-pulse" />
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
{/* Horizontal divider lines */}
|
||||
<span className="h-6 w-px bg-gray-200" />
|
||||
@@ -454,4 +423,4 @@ function SidebarLink({ to, icon, label, end, collapsed }: SidebarLinkProps) {
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
type ShortcutItem = {
|
||||
to: string
|
||||
title: string
|
||||
description: string
|
||||
badge?: string
|
||||
}
|
||||
|
||||
type ManagementShortcutsProps = {
|
||||
title: string
|
||||
description?: string
|
||||
items: ShortcutItem[]
|
||||
}
|
||||
|
||||
export function ManagementShortcuts({ title, description, items }: ManagementShortcutsProps) {
|
||||
return (
|
||||
<section className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-bold text-ink-600">{title}</h2>
|
||||
{description && <p className="mt-1 text-sm text-ink-50">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className="group rounded-2xl border border-gray-200 bg-gray-50/70 p-4 transition hover:-translate-y-0.5 hover:border-primary-400/50 hover:bg-white hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-sm font-bold text-ink-600">{item.title}</h3>
|
||||
{item.badge && (
|
||||
<span className="shrink-0 rounded-full bg-primary-400/10 px-2 py-0.5 text-[10px] font-bold text-brand-500">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-5 text-ink-50">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight
|
||||
size={16}
|
||||
className="mt-0.5 shrink-0 text-sand-500 transition group-hover:translate-x-0.5 group-hover:text-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loader2, Search, Sparkles, Wand2 } from 'lucide-react'
|
||||
import { Loader2, Rss, Search, Sparkles, Wand2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { aiAPI, type SearchIntent } from '../api/ai'
|
||||
import { aiAPI, type ExternalMediaResult, type SearchIntent } from '../api/ai'
|
||||
import { imageURL } from '../api/client'
|
||||
import { subscriptionsAPI } from '../api/subscriptions'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { Media } from '../types'
|
||||
|
||||
@@ -26,6 +28,8 @@ export function AIAssistantPage() {
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [intent, setIntent] = useState<SearchIntent | null>(null)
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [externalItems, setExternalItems] = useState<ExternalMediaResult[]>([])
|
||||
const [subscribing, setSubscribing] = useState('')
|
||||
|
||||
const [recs, setRecs] = useState<string[] | null>(null)
|
||||
const [recommending, setRecommending] = useState(false)
|
||||
@@ -43,11 +47,13 @@ export function AIAssistantPage() {
|
||||
setSearching(true)
|
||||
setIntent(null)
|
||||
setItems([])
|
||||
setExternalItems([])
|
||||
try {
|
||||
const r = await aiAPI.smartSearch(query.trim())
|
||||
setIntent(r.intent)
|
||||
setItems(r.items)
|
||||
if (r.items.length === 0) toast('未找到匹配项')
|
||||
setExternalItems(r.external_items ?? [])
|
||||
if (r.items.length === 0 && (r.external_items ?? []).length === 0) toast('未找到匹配项')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
@@ -187,6 +193,74 @@ export function AIAssistantPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{externalItems.length > 0 && (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{externalItems.map((item) => {
|
||||
const keyword = item.subscribe_keyword || item.title
|
||||
const key = `${item.source}:${keyword}`
|
||||
return (
|
||||
<article key={key} className="rounded-2xl border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="h-24 w-16 shrink-0 overflow-hidden rounded-xl bg-white">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={imageURL(item.poster_url)}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex flex-wrap gap-2 text-[10px] uppercase text-brand-500">
|
||||
<span>{item.source}</span>
|
||||
{item.media_type && <span>{item.media_type}</span>}
|
||||
{item.year ? <span>{item.year}</span> : null}
|
||||
</div>
|
||||
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
|
||||
{item.overview || `订阅关键词:${keyword}`}
|
||||
</p>
|
||||
<button
|
||||
disabled={subscribing === key}
|
||||
onClick={async () => {
|
||||
setSubscribing(key)
|
||||
try {
|
||||
const feed = `site-search://search?keyword=${encodeURIComponent(keyword)}&source=${encodeURIComponent(item.source)}`
|
||||
const sub = await subscriptionsAPI.create({
|
||||
name: `${item.title} 自动订阅`,
|
||||
feed_url: feed,
|
||||
filter: keyword,
|
||||
media_type: item.media_type,
|
||||
enabled: true,
|
||||
})
|
||||
const run = await subscriptionsAPI.runNow(sub.id)
|
||||
toast.success(
|
||||
run.queued > 0
|
||||
? `已订阅并加入 ${run.queued} 个下载`
|
||||
: '已订阅,暂未在 PT 站点找到可下载资源',
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'订阅失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSubscribing('')
|
||||
}
|
||||
}}
|
||||
className="mt-2 rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10 disabled:opacity-50"
|
||||
>
|
||||
<Rss size={12} className="mr-1 inline" />
|
||||
{subscribing === key ? '订阅中…' : '订阅并搜索 PT'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Recommendations */}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { adminAPI } from '../api/admin'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library, User } from '../types'
|
||||
import { APIConfigsPanel } from '../components/APIConfigsPanel'
|
||||
import { ManagementShortcuts } from '../components/ManagementShortcuts'
|
||||
|
||||
export function AdminPage() {
|
||||
const [tab, setTab] = useState<'library' | 'users' | 'api'>('library')
|
||||
@@ -17,6 +18,16 @@ export function AdminPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">管理后台</h1>
|
||||
<ManagementShortcuts
|
||||
title="统一管理入口"
|
||||
description="侧栏保持精简,完整管理能力统一从这里进入。"
|
||||
items={[
|
||||
{ to: '/sites', title: '站点管理', description: '维护 PT 站点、认证方式和检索配置' },
|
||||
{ to: '/download-clients', title: '下载器管理', description: '配置 qBittorrent 等下载器连接', badge: '下载' },
|
||||
{ to: '/tools', title: '整理与维护', description: '媒体整理、任务维护和辅助工具' },
|
||||
{ to: '/storage', title: '存储与文件', description: '查看占用、清理重复项和管理文件' },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2 border-b border-gray-200">
|
||||
{tabs.map((k) => (
|
||||
<button
|
||||
@@ -88,6 +99,7 @@ function LibraryPanel() {
|
||||
<select className="input-base" value={type} onChange={(e) => setType(e.target.value)}>
|
||||
<option value="movie">电影</option>
|
||||
<option value="tv">电视剧</option>
|
||||
<option value="variety">综艺</option>
|
||||
<option value="anime">动漫</option>
|
||||
<option value="music">音乐</option>
|
||||
</select>
|
||||
@@ -117,7 +129,7 @@ function LibraryPanel() {
|
||||
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
onClick={async () => {
|
||||
const r = await libraryAPI.scan(l.id)
|
||||
toast.success(`扫描完成,新增 ${r.added}`)
|
||||
toast.success(`扫描完成,新增 ${r.added},更新 ${r.updated ?? 0}`)
|
||||
}}
|
||||
>
|
||||
扫描
|
||||
|
||||
+169
-128
@@ -1,140 +1,152 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Sparkles, AlertTriangle, ExternalLink, Wifi } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { AlertTriangle, Sparkles } from 'lucide-react'
|
||||
|
||||
import { discoverAPI, type DiscoverItem } from '../api/discover'
|
||||
import { discoverAPI, type DiscoverItem, type DiscoverSection } from '../api/discover'
|
||||
import { imageURL } from '../api/client'
|
||||
|
||||
// 判断后端返回的错误是不是"未配置 API key"。其它(网络超时/被墙/上游 5xx)
|
||||
// 都归为"网络/上游故障",避免误导用户去再配一次 key。
|
||||
function isMissingKey(err?: string): boolean {
|
||||
if (!err) return false
|
||||
const low = err.toLowerCase()
|
||||
return low.includes('api key') || low.includes('apikey') || low.includes('not configured')
|
||||
}
|
||||
const defaultSections = [
|
||||
'tmdb_trending_day',
|
||||
'douban_hot_movie',
|
||||
'douban_hot_tv',
|
||||
'bangumi_calendar',
|
||||
]
|
||||
|
||||
function isNetworkError(err?: string): boolean {
|
||||
if (!err) return false
|
||||
const low = err.toLowerCase()
|
||||
return (
|
||||
low.includes('deadline exceeded') ||
|
||||
low.includes('timeout') ||
|
||||
low.includes('no such host') ||
|
||||
low.includes('connection refused') ||
|
||||
low.includes('eof') ||
|
||||
low.includes('tls') ||
|
||||
low.includes('reset')
|
||||
)
|
||||
}
|
||||
const storageKey = 'mediastation.discover.sections'
|
||||
|
||||
export function DiscoverPage() {
|
||||
const [trending, setTrending] = useState<DiscoverItem[]>([])
|
||||
const [popular, setPopular] = useState<DiscoverItem[]>([])
|
||||
const [trendingErr, setTrendingErr] = useState<string | undefined>()
|
||||
const [popularErr, setPopularErr] = useState<string | undefined>()
|
||||
const [sections, setSections] = useState<DiscoverSection[]>([])
|
||||
const [selected, setSelected] = useState<string[]>(defaultSections)
|
||||
const [rows, setRows] = useState<Record<string, DiscoverItem[]>>({})
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
discoverAPI.trending().catch((err) => ({
|
||||
items: [] as DiscoverItem[],
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})),
|
||||
discoverAPI.popular().catch((err) => ({
|
||||
items: [] as DiscoverItem[],
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})),
|
||||
])
|
||||
.then(([t, p]) => {
|
||||
setTrending(t.items)
|
||||
setTrendingErr(t.error)
|
||||
setPopular(p.items)
|
||||
setPopularErr(p.error)
|
||||
discoverAPI
|
||||
.sections()
|
||||
.then((items) => {
|
||||
setSections(items)
|
||||
const saved = readSavedSections(items)
|
||||
setSelected(saved.length > 0 ? saved : defaultSections)
|
||||
})
|
||||
.catch(() => {
|
||||
setSections(defaultSectionDefs)
|
||||
setSelected(defaultSections)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const anyErr = trendingErr || popularErr
|
||||
const missingKey = isMissingKey(anyErr)
|
||||
const networkErr = !missingKey && isNetworkError(anyErr)
|
||||
const otherErr = !missingKey && !networkErr && anyErr
|
||||
useEffect(() => {
|
||||
if (selected.length === 0) {
|
||||
setRows({})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError('')
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(selected))
|
||||
discoverAPI
|
||||
.feed(selected)
|
||||
.then((feed) => {
|
||||
const next: Record<string, DiscoverItem[]> = {}
|
||||
for (const key of selected) {
|
||||
next[key] = feed[key] ?? []
|
||||
}
|
||||
setRows(next)
|
||||
})
|
||||
.catch((err) => {
|
||||
setRows({})
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [selected])
|
||||
|
||||
const sectionMap = useMemo(
|
||||
() => new Map(sections.map((section) => [section.key, section])),
|
||||
[sections],
|
||||
)
|
||||
const hasContent = selected.some((key) => (rows[key] ?? []).length > 0)
|
||||
|
||||
const toggleSection = (key: string) => {
|
||||
setSelected((current) => {
|
||||
if (current.includes(key)) {
|
||||
return current.filter((item) => item !== key)
|
||||
}
|
||||
return [...current, key]
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 px-4 py-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<header className="flex items-center gap-4 mb-8">
|
||||
<div className="p-3 rounded-2xl bg-gradient-to-br from-primary-500/20 to-primary-600/10 border border-primary-500/20">
|
||||
<Sparkles className="h-8 w-8 text-brand-500" />
|
||||
<div className="mx-auto max-w-7xl space-y-8 px-4 py-6">
|
||||
<header className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded-2xl border border-primary-500/20 bg-gradient-to-br from-primary-500/20 to-primary-600/10 p-3">
|
||||
<Sparkles className="h-8 w-8 text-brand-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-4xl font-bold tracking-tight text-ink-600">
|
||||
发现
|
||||
</h1>
|
||||
<p className="mt-1 text-base text-ink-50">
|
||||
多源推荐:TMDb / 豆瓣 / Bangumi,可按需组合显示
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-4xl font-bold text-ink-600 tracking-tight">发现</h1>
|
||||
<p className="mt-1 text-base text-ink-50">
|
||||
来自 TMDb 的当日热门与流行榜单
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sections.map((section) => {
|
||||
const active = selected.includes(section.key)
|
||||
return (
|
||||
<button
|
||||
key={section.key}
|
||||
type="button"
|
||||
onClick={() => toggleSection(section.key)}
|
||||
className={
|
||||
'rounded-full border px-3 py-1.5 text-xs font-semibold transition ' +
|
||||
(active
|
||||
? 'border-primary-400 bg-primary-400/15 text-brand-500'
|
||||
: 'border-gray-200 bg-white text-gray-500 hover:border-primary-300 hover:text-ink-600')
|
||||
}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading && <DiscoverSkeleton />}
|
||||
|
||||
{/* TMDb API Key 未配置 */}
|
||||
{!loading && missingKey && (
|
||||
<div className="rounded-2xl bg-amber-500/10 border border-amber-500/20 p-6 text-center space-y-4">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center">
|
||||
<AlertTriangle className="h-8 w-8 text-amber-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-ink-600">TMDb API Key 未配置</h3>
|
||||
<p className="text-sm text-ink-50 max-w-md mx-auto">
|
||||
您需要在管理后台填入 TMDb API Key 才能查看发现内容。
|
||||
</p>
|
||||
<Link
|
||||
to="/admin"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-primary-500/20 text-brand-500 hover:bg-primary-500/30 transition-colors font-medium"
|
||||
>
|
||||
前往管理后台
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
{!loading && error && (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-red-500/20 bg-red-500/10 p-4">
|
||||
<AlertTriangle className="h-5 w-5 flex-shrink-0 text-red-400" />
|
||||
<p className="text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 网络无法访问 TMDb */}
|
||||
{!loading && networkErr && (
|
||||
<div className="rounded-2xl bg-orange-500/10 border border-orange-500/20 p-6 text-center space-y-4">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<Wifi className="h-8 w-8 text-orange-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-ink-600">无法连接到 TMDb</h3>
|
||||
<p className="text-sm text-ink-50 max-w-lg mx-auto">
|
||||
服务器到 <code className="font-mono text-orange-300">api.themoviedb.org</code> 的连接超时。
|
||||
通常是因为部署机器没有走代理。可以在系统环境变量里设置
|
||||
<code className="font-mono text-orange-300 mx-1">HTTPS_PROXY</code>,
|
||||
或在「外部 API」配置里填写自建反代地址(tmdb_api_proxy / tmdb_image_proxy)。
|
||||
</p>
|
||||
<details className="text-xs text-sand-500 max-w-lg mx-auto text-left">
|
||||
<summary className="cursor-pointer hover:text-ink-50">查看原始错误</summary>
|
||||
<pre className="mt-2 p-2 rounded bg-black/40 overflow-x-auto whitespace-pre-wrap">{anyErr}</pre>
|
||||
</details>
|
||||
{!loading && selected.length === 0 && (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-10 text-center text-sand-500">
|
||||
至少选择一个推荐源,小宇宙才会开始转动。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 其它错误 */}
|
||||
{!loading && otherErr && (
|
||||
<div className="rounded-2xl bg-red-500/10 border border-red-500/20 p-4 flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-red-400 flex-shrink-0" />
|
||||
<p className="text-red-300">{otherErr}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Rows */}
|
||||
{!loading && !missingKey && (
|
||||
{!loading && !error && selected.length > 0 && (
|
||||
<div className="space-y-10">
|
||||
{trending.length > 0 && <ContentRow title="今日趋势" items={trending} />}
|
||||
{popular.length > 0 && <ContentRow title="热门电影" items={popular} />}
|
||||
{selected.map((key) => {
|
||||
const items = rows[key] ?? []
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<ContentRow
|
||||
key={key}
|
||||
title={sectionMap.get(key)?.label ?? key}
|
||||
items={items}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* TMDB 配置 OK 但本次没拿到任何条目(极少见) */}
|
||||
{!networkErr && trending.length === 0 && popular.length === 0 && !otherErr && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-sand-500">暂无发现内容</p>
|
||||
{!hasContent && (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-10 text-center">
|
||||
<p className="text-sand-500">
|
||||
当前选择的推荐源暂未返回内容,可切换豆瓣 / Bangumi 或检查网络代理。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -146,10 +158,10 @@ export function DiscoverPage() {
|
||||
function ContentRow({ title, items }: { title: string; items: DiscoverItem[] }) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="font-display text-2xl font-semibold text-ink-600 pl-1">{title}</h2>
|
||||
<h2 className="pl-1 font-display text-2xl font-semibold text-ink-600">{title}</h2>
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{items.map((item) => (
|
||||
<DiscoverCard key={item.tmdb_id} item={item} />
|
||||
{items.map((item, index) => (
|
||||
<DiscoverCard key={discoverKey(item, index)} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -157,35 +169,39 @@ function ContentRow({ title, items }: { title: string; items: DiscoverItem[] })
|
||||
}
|
||||
|
||||
function DiscoverCard({ item }: { item: DiscoverItem }) {
|
||||
const source = item.source || (item.bangumi_id ? 'bangumi' : item.douban_id ? 'douban' : 'tmdb')
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-xl border border-gray-200 bg-gray-50 hover:border-primary-500/30 transition-all duration-300">
|
||||
<div className="aspect-[2/3] w-full bg-surface-900 relative overflow-hidden">
|
||||
<div className="group relative overflow-hidden rounded-xl border border-gray-200 bg-gray-50 transition-all duration-300 hover:border-primary-500/30">
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-900">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={imageURL(item.poster_url)}
|
||||
alt={item.title}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-gray-500 text-xs">
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-gray-500">
|
||||
无海报
|
||||
</div>
|
||||
)}
|
||||
{item.rating > 0 && (
|
||||
<div className="absolute top-1.5 right-1.5 rounded-xl bg-black/70 backdrop-blur-sm px-1.5 py-0.5 text-[11px] font-semibold text-yellow-400 border border-yellow-400/30">
|
||||
★ {item.rating.toFixed(1)}
|
||||
<div className="absolute left-1.5 top-1.5 rounded-xl border border-white/20 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-white backdrop-blur-sm">
|
||||
{source}
|
||||
</div>
|
||||
{(item.rating ?? 0) > 0 && (
|
||||
<div className="absolute right-1.5 top-1.5 rounded-xl border border-yellow-400/30 bg-black/70 px-1.5 py-0.5 text-[11px] font-semibold text-yellow-400 backdrop-blur-sm">
|
||||
★ {(item.rating ?? 0).toFixed(1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2.5 py-2 space-y-0.5">
|
||||
<p className="text-xs font-medium text-ink-600 truncate group-hover:text-brand-500 transition-colors">
|
||||
<div className="space-y-0.5 px-2.5 py-2">
|
||||
<p className="truncate text-xs font-medium text-ink-600 transition-colors group-hover:text-brand-500">
|
||||
{item.title}
|
||||
</p>
|
||||
{item.year > 0 && (
|
||||
<p className="text-[11px] text-sand-500">{item.year}</p>
|
||||
)}
|
||||
<p className="text-[11px] text-sand-500">
|
||||
{[item.media_type, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' · ') || '推荐'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -194,12 +210,12 @@ function DiscoverCard({ item }: { item: DiscoverItem }) {
|
||||
function DiscoverSkeleton() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{[1, 2].map((section) => (
|
||||
{[1, 2, 3].map((section) => (
|
||||
<section key={section} className="space-y-4">
|
||||
<div className="h-8 w-48 rounded-xl bg-gray-100 animate-pulse" />
|
||||
<div className="h-8 w-48 animate-pulse rounded-xl bg-gray-100" />
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
|
||||
<div key={i} className="aspect-[2/3] rounded-xl bg-gray-100 animate-pulse" />
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((item) => (
|
||||
<div key={item} className="aspect-[2/3] animate-pulse rounded-xl bg-gray-100" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -207,3 +223,28 @@ function DiscoverSkeleton() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function discoverKey(item: DiscoverItem, index: number): string {
|
||||
return `${item.source || 'source'}:${item.tmdb_id || item.douban_id || item.bangumi_id || item.title}:${index}`
|
||||
}
|
||||
|
||||
function readSavedSections(sections: DiscoverSection[]): string[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
const allowed = new Set(sections.map((section) => section.key))
|
||||
return parsed.filter((key) => typeof key === 'string' && allowed.has(key))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSectionDefs: DiscoverSection[] = [
|
||||
{ key: 'tmdb_trending_day', label: 'TMDb 今日趋势', provider: 'tmdb' },
|
||||
{ key: 'tmdb_popular_movie', label: 'TMDb 热门电影', provider: 'tmdb' },
|
||||
{ key: 'douban_hot_movie', label: '豆瓣热门电影', provider: 'douban' },
|
||||
{ key: 'douban_hot_tv', label: '豆瓣热门剧集', provider: 'douban' },
|
||||
{ key: 'bangumi_calendar', label: 'Bangumi 每日放送', provider: 'bangumi' },
|
||||
]
|
||||
|
||||
@@ -28,12 +28,17 @@ export function DuplicatesPage() {
|
||||
libraryAPI.list().then(setLibs)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
duplicatesAPI.list(libID).then(setReport).catch(() => setReport(null))
|
||||
}, [libID])
|
||||
|
||||
const scan = async () => {
|
||||
setScanning(true)
|
||||
try {
|
||||
const r = await duplicatesAPI.scan(libID)
|
||||
setReport(r)
|
||||
toast.success(`扫描完成: ${r.groups_found} 组重复, ${r.items_marked} 项标记`)
|
||||
const cleaned = r.missing_removed ? `, 清理 ${r.missing_removed} 条失效记录` : ''
|
||||
toast.success(`扫描完成: ${r.groups_found} 组重复, ${r.items_marked} 项标记${cleaned}`)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
@@ -89,7 +94,13 @@ export function DuplicatesPage() {
|
||||
<p className="text-ink-50">扫描了 {report.total_scanned} 项,未发现重复。</p>
|
||||
)}
|
||||
|
||||
{report && report.groups.map((g) => (
|
||||
{report && report.missing_removed ? (
|
||||
<p className="rounded-2xl border border-amber-300/50 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
已清理 {report.missing_removed} 条文件不存在的媒体记录,统计容量会在刷新后恢复正常。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{report && (report.groups ?? []).map((g) => (
|
||||
<section key={g.hash} className="glass-panel space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-mono text-xs text-sand-500">{g.hash}</p>
|
||||
|
||||
+104
-71
@@ -15,9 +15,10 @@ import { groupSeries } from '../utils/groupSeries'
|
||||
type LibraryRow = { library: Library; cards: ReturnType<typeof groupSeries> }
|
||||
|
||||
const TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
movie: <Film size={18} />, tv: <Tv size={18} />, anime: <PlayCircle size={18} />, music: <Music size={18} />,
|
||||
movie: <Film size={18} />, tv: <Tv size={18} />, variety: <Tv size={18} />, anime: <PlayCircle size={18} />, music: <Music size={18} />,
|
||||
}
|
||||
const TYPE_LABELS: Record<string, string> = { movie: '电影', tv: '电视剧', anime: '动漫', music: '音乐' }
|
||||
const TYPE_LABELS: Record<string, string> = { movie: '电影', tv: '电视剧', variety: '综艺', anime: '动漫', music: '音乐' }
|
||||
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
||||
|
||||
export function HomePage() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
@@ -58,19 +59,18 @@ export function HomePage() {
|
||||
}, [loading, libraries.length])
|
||||
const fallbackCards = useMemo(() => groupSeries(fallback).slice(0, 16), [fallback])
|
||||
|
||||
// Pick a featured movie/show for the premium editorial billboard
|
||||
const featuredItem = useMemo(() => {
|
||||
if (history.length > 0 && history[0].media) {
|
||||
return history[0].media;
|
||||
}
|
||||
if (rows.length > 0 && rows[0].cards.length > 0) {
|
||||
return rows[0].cards[0].rep;
|
||||
}
|
||||
if (fallback.length > 0) {
|
||||
return fallback[0];
|
||||
}
|
||||
return null;
|
||||
}, [history, rows, fallback]);
|
||||
const candidates = [
|
||||
...(history.map((h) => h.media).filter(Boolean) as Media[]),
|
||||
...rows.flatMap((row) => row.cards.map((card) => card.rep)),
|
||||
...fallbackCards.map((card) => card.rep),
|
||||
...fallback,
|
||||
]
|
||||
return candidates.find(hasArtwork) ?? candidates[0] ?? null
|
||||
}, [history, rows, fallbackCards, fallback])
|
||||
const featuredVisual = featuredItem?.backdrop_url || featuredItem?.poster_url || ''
|
||||
const featuredPoster = featuredItem?.poster_url || featuredItem?.backdrop_url || ''
|
||||
const featuredMark = (featuredItem?.title || 'MS').trim().slice(0, 4).toUpperCase()
|
||||
|
||||
const empty = !loading && history.length === 0 && rows.every((r) => r.cards.length === 0) && fallbackCards.length === 0
|
||||
|
||||
@@ -109,71 +109,104 @@ export function HomePage() {
|
||||
<div className="space-y-12">
|
||||
{/* ─── Premium Swiss-Editorial Billboard Hero ─── */}
|
||||
{featuredItem && (
|
||||
<section className="relative overflow-hidden rounded-3xl bg-white border border-gray-200/90 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)]">
|
||||
{/* Background Poster Cover & Soft Faded Backdrop */}
|
||||
<section className="relative overflow-hidden rounded-[2rem] bg-white border border-gray-200/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)]">
|
||||
{/* Background Artwork */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
{featuredItem.poster_url ? (
|
||||
<img
|
||||
src={imageURL(featuredItem.poster_url)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover object-top opacity-5 scale-105 blur-sm"
|
||||
<div className="h-full w-full bg-[radial-gradient(circle_at_80%_20%,rgba(212,175,55,0.22),transparent_34%),linear-gradient(135deg,#fff7ed,#f8fafc_52%,#eef2ff)]" />
|
||||
{featuredVisual && (
|
||||
<img
|
||||
src={imageURL(featuredVisual)}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full scale-105 object-cover object-center opacity-[0.34] blur-[1px]"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(event) => { event.currentTarget.style.display = 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-50" />
|
||||
)}
|
||||
{/* Soft Bright Masking */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-white via-white/95 to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white via-transparent to-transparent" />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(90deg,#ffffff_0%,rgba(255,255,255,0.96)_37%,rgba(255,255,255,0.62)_68%,rgba(255,255,255,0.2)_100%)]" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_82%_50%,rgba(201,149,74,0.24),transparent_32%)]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-white to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Billboard Content */}
|
||||
<div className="relative z-10 px-8 py-14 sm:px-12 md:py-16 lg:py-20 max-w-3xl space-y-5">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-brand-50 px-3.5 py-1.5 text-xs font-bold uppercase tracking-widest text-[#c9954a] border border-brand-100/40">
|
||||
<Sparkles size={12} fill="currentColor" />
|
||||
<span>本周力荐 / Featured</span>
|
||||
<div className="relative z-10 grid gap-8 px-6 py-8 sm:px-8 md:grid-cols-[minmax(0,1fr)_280px] md:px-12 md:py-12 lg:grid-cols-[minmax(0,1fr)_340px] lg:px-14 lg:py-14">
|
||||
<div className="flex min-w-0 flex-col justify-center space-y-5">
|
||||
<div className="inline-flex w-fit items-center gap-2 rounded-full bg-white/82 px-3.5 py-1.5 text-xs font-bold uppercase tracking-widest text-[#a8732d] border border-[#ead6b6] shadow-sm backdrop-blur">
|
||||
<Sparkles size={12} fill="currentColor" />
|
||||
<span>本周力荐 / Featured</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="inline-flex max-w-full items-center gap-2 rounded-2xl bg-gray-950 px-3 py-2 text-white shadow-lg shadow-gray-950/10">
|
||||
<span className="h-2 w-2 rounded-full bg-[#d4af37]" />
|
||||
<span className="truncate text-xs font-black tracking-[0.26em]">{featuredMark}</span>
|
||||
</div>
|
||||
<h1 className="font-display text-3xl sm:text-4xl md:text-5xl font-extrabold tracking-tight leading-tight text-gray-950">
|
||||
{featuredItem.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{featuredItem.overview ? (
|
||||
<p className="max-w-2xl text-gray-600 text-sm sm:text-base leading-relaxed line-clamp-3 font-semibold">
|
||||
{featuredItem.overview}
|
||||
</p>
|
||||
) : (
|
||||
<p className="max-w-2xl text-gray-500 text-sm italic">
|
||||
家庭私人媒体中心收藏。极高视听品质,支持多端原生无损解码及HLS转码播放。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Metadata Badges */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 font-bold">
|
||||
{featuredItem.year > 0 && (
|
||||
<span className="bg-white/85 px-2.5 py-1 rounded-xl text-gray-900 border border-gray-200 shadow-sm">{featuredItem.year} 年</span>
|
||||
)}
|
||||
{featuredItem.video_codec && (
|
||||
<span className="rounded-lg bg-[#fff8e7] px-2 py-1 text-[#9a6a1e] border border-[#ead6b6] uppercase font-bold text-[10px]">
|
||||
{featuredItem.video_codec}
|
||||
</span>
|
||||
)}
|
||||
{featuredItem.container && (
|
||||
<span className="rounded-lg bg-white/80 px-2 py-1 text-gray-700 border border-gray-200 uppercase font-mono text-[10px]">
|
||||
{featuredItem.container}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex flex-wrap items-center gap-4 pt-2">
|
||||
<Link to={`/media/${featuredItem.id}`} className="inline-flex items-center justify-center gap-2 rounded-xl bg-[#111827] px-6 py-3.5 text-sm font-bold text-white shadow-lg shadow-gray-900/15 hover:bg-[#1f2937] hover:-translate-y-0.5 transition-all">
|
||||
<Play size={16} fill="currentColor" />
|
||||
<span>立即播放</span>
|
||||
</Link>
|
||||
<Link to="/discover" className="btn-outline bg-white/80 px-5 py-3.5 text-sm font-bold text-gray-700 border border-gray-200 hover:border-gray-300">
|
||||
<span>发现更多精彩</span>
|
||||
<ArrowRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="font-display text-3xl sm:text-4xl md:text-5xl font-extrabold tracking-tight leading-tight text-gray-900">
|
||||
{featuredItem.title}
|
||||
</h1>
|
||||
|
||||
{featuredItem.overview ? (
|
||||
<p className="text-gray-500 text-sm sm:text-base leading-relaxed line-clamp-3 font-semibold">
|
||||
{featuredItem.overview}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm italic">
|
||||
家庭私人媒体中心收藏。极高视听品质,支持多端原生无损解码及HLS转码播放。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Metadata Badges */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 font-bold">
|
||||
{featuredItem.year > 0 && (
|
||||
<span className="bg-gray-100 px-2.5 py-1 rounded-xl text-gray-900 border border-gray-200">{featuredItem.year} 年</span>
|
||||
)}
|
||||
{featuredItem.video_codec && (
|
||||
<span className="rounded-lg bg-brand-50 px-2 py-1 text-brand-700 border border-brand-100 uppercase font-bold text-[10px]">
|
||||
{featuredItem.video_codec}
|
||||
</span>
|
||||
)}
|
||||
{featuredItem.container && (
|
||||
<span className="rounded-lg bg-gray-100 px-2 py-1 text-gray-700 border border-gray-200 uppercase font-mono text-[10px]">
|
||||
{featuredItem.container}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex flex-wrap items-center gap-4 pt-4">
|
||||
<Link to={`/media/${featuredItem.id}`} className="inline-flex items-center justify-center gap-2 rounded-xl bg-[#111827] px-6 py-3.5 text-sm font-bold text-white shadow-sm hover:bg-[#1f2937] hover:-translate-y-0.5 transition-all">
|
||||
<Play size={16} fill="currentColor" />
|
||||
<span>立即播放</span>
|
||||
</Link>
|
||||
<Link to="/discover" className="btn-outline px-5 py-3.5 text-sm font-bold text-gray-700 border border-gray-200 hover:border-gray-300">
|
||||
<span>发现更多精彩</span>
|
||||
<ArrowRight size={16} />
|
||||
</Link>
|
||||
<div className="relative order-first mx-auto flex w-full max-w-[220px] items-center md:order-none md:max-w-[260px] lg:max-w-[310px]">
|
||||
<div className="absolute -right-6 top-5 h-32 w-32 rounded-full bg-[#d4af37]/20 blur-3xl" />
|
||||
<div className="absolute -left-5 bottom-8 h-28 w-28 rounded-full bg-brand-200/30 blur-3xl" />
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden rounded-[1.7rem] border border-white/70 bg-white p-2 shadow-[0_32px_80px_rgba(15,23,42,0.20)]">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center rounded-[1.25rem] bg-[linear-gradient(135deg,#f9fafb,#fff7ed)] text-center">
|
||||
<Film className="mb-4 h-12 w-12 text-[#c9954a]" />
|
||||
<span className="px-6 font-display text-3xl font-black tracking-tight text-gray-950">{featuredItem.title}</span>
|
||||
</div>
|
||||
{featuredPoster && (
|
||||
<img
|
||||
src={imageURL(featuredPoster)}
|
||||
alt={featuredItem.title}
|
||||
className="absolute inset-2 h-[calc(100%-1rem)] w-[calc(100%-1rem)] rounded-[1.25rem] object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(event) => { event.currentTarget.style.display = 'none' }}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-x-4 bottom-4 rounded-2xl bg-white/88 px-4 py-3 shadow-lg backdrop-blur">
|
||||
<p className="truncate text-xs font-black tracking-[0.22em] text-[#a8732d]">MEDIASTATION PICK</p>
|
||||
<p className="truncate text-sm font-extrabold text-gray-950">{featuredItem.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Film, Library as LibraryIcon, Music, PlayCircle, Tv } from 'lucide-react'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library } from '../types'
|
||||
|
||||
const TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
movie: <Film size={20} />,
|
||||
tv: <Tv size={20} />,
|
||||
anime: <PlayCircle size={20} />,
|
||||
variety: <Tv size={20} />,
|
||||
music: <Music size={20} />,
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
movie: '电影',
|
||||
tv: '剧集',
|
||||
anime: '动漫',
|
||||
variety: '综艺',
|
||||
music: '音乐',
|
||||
}
|
||||
|
||||
export function LibrariesPage() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
libraryAPI.list().then(setLibraries).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">媒体库</h1>
|
||||
<p className="mt-1 text-sm text-ink-50">所有电影、剧集、番剧、综艺分类统一在这里查看。</p>
|
||||
</div>
|
||||
|
||||
{libraries.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-sand-200 bg-white py-24 text-center">
|
||||
<LibraryIcon className="mb-4 h-12 w-12 text-gray-400" />
|
||||
<p className="text-sm text-ink-50">暂无媒体库,请到管理后台添加目录。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{libraries.map((lib, index) => (
|
||||
<motion.div
|
||||
key={lib.id}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
>
|
||||
<Link
|
||||
to={`/library/${lib.id}`}
|
||||
className="group block rounded-3xl border border-sand-200 bg-white p-5 shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
|
||||
>
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-50 text-brand-600">
|
||||
{TYPE_ICONS[lib.type] ?? <LibraryIcon size={20} />}
|
||||
</div>
|
||||
<span className="rounded-full bg-sand-100 px-3 py-1 text-xs font-semibold text-sand-600">
|
||||
{TYPE_LABELS[lib.type] ?? lib.type}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-display text-xl font-bold text-ink-600 group-hover:text-brand-600">
|
||||
{lib.name}
|
||||
</h2>
|
||||
<p className="mt-2 line-clamp-2 break-all text-xs text-ink-50">{lib.path}</p>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import type { Library, Media } from '../types'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import { imageURL } from '../api/client'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { getSeriesKey, groupSeries, type SeriesCard } from '../utils/groupSeries'
|
||||
import { getSeriesKey, groupSeries, isEpisodeLike, seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
|
||||
export function LibraryPage() {
|
||||
const { id = '' } = useParams()
|
||||
@@ -25,8 +25,10 @@ export function LibraryPage() {
|
||||
|
||||
// 剧集模式:选中某个剧集后展开详情
|
||||
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
|
||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null)
|
||||
|
||||
const isSeries = library?.type === 'tv' || library?.type === 'anime'
|
||||
const hasEpisodicItems = useMemo(() => items.some(isEpisodeLike), [items])
|
||||
const isSeries = library?.type === 'tv' || library?.type === 'anime' || library?.type === 'variety' || hasEpisodicItems
|
||||
|
||||
// 折叠后的剧集卡片
|
||||
const seriesCards = useMemo(() => {
|
||||
@@ -53,6 +55,11 @@ export function LibraryPage() {
|
||||
.map(([season, episodes]) => ({ season, episodes }))
|
||||
}, [selectedSeries, items])
|
||||
|
||||
const visibleEpisodes = useMemo(() => {
|
||||
if (selectedSeason == null) return selectedEpisodes[0]?.episodes ?? []
|
||||
return selectedEpisodes.find((s) => s.season === selectedSeason)?.episodes ?? []
|
||||
}, [selectedEpisodes, selectedSeason])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
libraryAPI.list().then((all) => {
|
||||
@@ -64,8 +71,8 @@ export function LibraryPage() {
|
||||
useEffect(() => {
|
||||
if (!id || !library) return
|
||||
setLoading(true)
|
||||
// tv/anime 拉取较多集数以支持前端分组
|
||||
const limit = library.type === 'tv' || library.type === 'anime' ? 1000 : 200
|
||||
// 所有库都可能包含季集型内容(综艺/纪录片/课程等),统一拉取较大页数用于前端分组。
|
||||
const limit = 2000
|
||||
libraryAPI
|
||||
.listMedia(id, 1, limit)
|
||||
.then((d) => {
|
||||
@@ -76,8 +83,10 @@ export function LibraryPage() {
|
||||
}, [id, library])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return
|
||||
if (!isSeries) {
|
||||
setSelectedSeries(null)
|
||||
setSelectedSeason(null)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -89,13 +98,23 @@ export function LibraryPage() {
|
||||
|
||||
const next = seriesCards.find((card) => card.key === key)
|
||||
if (next) setSelectedSeries(next)
|
||||
}, [isSeries, searchParams, seriesCards])
|
||||
}, [isSeries, loading, searchParams, seriesCards])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSeries || selectedEpisodes.length === 0) {
|
||||
setSelectedSeason(null)
|
||||
return
|
||||
}
|
||||
if (selectedSeason == null || !selectedEpisodes.some((s) => s.season === selectedSeason)) {
|
||||
setSelectedSeason(selectedEpisodes[0].season)
|
||||
}
|
||||
}, [selectedSeries, selectedEpisodes, selectedSeason])
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanning(true)
|
||||
try {
|
||||
const r = await libraryAPI.scan(id)
|
||||
toast.success(`扫描完成:新增 ${r.added} 项`)
|
||||
toast.success(`扫描完成:新增 ${r.added} 项,更新 ${r.updated ?? 0} 项`)
|
||||
setLibrary((l) => (l ? { ...l } : l))
|
||||
} catch {
|
||||
toast.error('扫描失败')
|
||||
@@ -121,6 +140,7 @@ export function LibraryPage() {
|
||||
|
||||
const clearSelectedSeries = () => {
|
||||
setSelectedSeries(null)
|
||||
setSelectedSeason(null)
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.delete('series')
|
||||
setSearchParams(next)
|
||||
@@ -144,7 +164,7 @@ export function LibraryPage() {
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">
|
||||
{library?.name ?? '媒体库'}
|
||||
{!isSeries && <span className="text-sand-500"> ({total})</span>}
|
||||
<span className="text-sand-500"> ({isSeries ? seriesCards.length : total})</span>
|
||||
</h1>
|
||||
{library && <p className="text-sm text-ink-50">{library.type} · {library.path}</p>}
|
||||
</div>
|
||||
@@ -200,7 +220,7 @@ export function LibraryPage() {
|
||||
返回列表
|
||||
</button>
|
||||
<h2 className="font-display text-2xl font-bold text-ink-600 truncate">
|
||||
{selectedSeries.rep.title}
|
||||
{seriesTitle(selectedSeries.rep)}
|
||||
</h2>
|
||||
<span className="text-sm text-sand-500">共 {selectedSeries.count} 集</span>
|
||||
</div>
|
||||
@@ -221,12 +241,11 @@ export function LibraryPage() {
|
||||
|
||||
{/* 从第一集开始 */}
|
||||
{(() => {
|
||||
const firstEps = selectedEpisodes
|
||||
.flatMap((s) => s.episodes)
|
||||
.sort((a, b) =>
|
||||
(a.season_num || 0) - (b.season_num || 0)
|
||||
|| (a.episode_num || 0) - (b.episode_num || 0),
|
||||
)
|
||||
const firstEps = [...(visibleEpisodes.length > 0 ? visibleEpisodes : selectedEpisodes.flatMap((s) => s.episodes))]
|
||||
firstEps.sort((a, b) =>
|
||||
(a.season_num || 0) - (b.season_num || 0)
|
||||
|| (a.episode_num || 0) - (b.episode_num || 0),
|
||||
)
|
||||
const first = firstEps.length > 0 ? firstEps[0] : null
|
||||
return first ? (
|
||||
<Link to={`/play/${first.id}`} className="btn-primary inline-flex">
|
||||
@@ -240,37 +259,52 @@ export function LibraryPage() {
|
||||
|
||||
{/* 季 / 集列表 */}
|
||||
<div className="space-y-6">
|
||||
{selectedEpisodes.map(({ season, episodes }) => (
|
||||
<div key={season}>
|
||||
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{selectedEpisodes.map(({ season, episodes }) => (
|
||||
<button
|
||||
key={season}
|
||||
onClick={() => setSelectedSeason(season)}
|
||||
className={
|
||||
'rounded-xl border px-4 py-2 text-sm font-semibold transition ' +
|
||||
(selectedSeason === season
|
||||
? 'border-brand-300 bg-brand-50 text-brand-700'
|
||||
: 'border-sand-200 bg-white text-ink-100 hover:border-brand-200 hover:text-brand-600')
|
||||
}
|
||||
>
|
||||
第 {season} 季 · {episodes.length} 集
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{episodes.map((ep) => (
|
||||
<Link
|
||||
key={ep.id}
|
||||
to={`/play/${ep.id}`}
|
||||
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
|
||||
{ep.episode_num || '—'}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-ink-600">
|
||||
{ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : ep.title}
|
||||
</p>
|
||||
<p className="text-xs text-sand-500">
|
||||
{ep.duration_sec > 0
|
||||
? `${Math.floor(ep.duration_sec / 60)} 分钟`
|
||||
: formatSize(ep.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<Play size={14} className="shrink-0 text-gray-500 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
|
||||
第 {selectedSeason ?? selectedEpisodes[0]?.season ?? 1} 季
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{visibleEpisodes.map((ep) => (
|
||||
<Link
|
||||
key={ep.id}
|
||||
to={`/play/${ep.id}`}
|
||||
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
|
||||
{ep.episode_num || '—'}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-ink-600">
|
||||
{ep.original_name || (ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : ep.title)}
|
||||
</p>
|
||||
<p className="text-xs text-sand-500">
|
||||
{ep.duration_sec > 0
|
||||
? `${Math.floor(ep.duration_sec / 60)} 分钟`
|
||||
: formatSize(ep.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<Play size={14} className="shrink-0 text-gray-500 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -35,6 +35,8 @@ export function PlayerPage() {
|
||||
const [media, setMedia] = useState<Media | null>(null)
|
||||
const [mode, setMode] = useState<Mode>('direct')
|
||||
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||
const [playerError, setPlayerError] = useState('')
|
||||
|
||||
// Load metadata and pick a default mode.
|
||||
useEffect(() => {
|
||||
@@ -44,10 +46,11 @@ export function PlayerPage() {
|
||||
const forced = params.get('mode') as Mode | null
|
||||
const auto = pickMode(m)
|
||||
setMode(forced ?? auto)
|
||||
setPlayerError('')
|
||||
})
|
||||
subtitlesAPI
|
||||
.list(id)
|
||||
.then(setSubs)
|
||||
.then((tracks) => setSubs(tracks ?? []))
|
||||
.catch(() => setSubs([]))
|
||||
}, [id, params])
|
||||
|
||||
@@ -66,7 +69,9 @@ export function PlayerPage() {
|
||||
hls.attachMedia(video)
|
||||
hls.on(HlsCtor.Events.ERROR, (_, data) => {
|
||||
if (data.fatal) {
|
||||
toast.error('HLS 播放失败,尝试切换到直接播放')
|
||||
setHlsUnavailable(true)
|
||||
setPlayerError('HLS 转码不可用,正在尝试直接播放原始文件。若出现有画面无声音,通常是 MKV/AC3/EAC3 音轨需要配置本机 ffmpeg 转码为 AAC。')
|
||||
toast.error('HLS 转码失败,尝试切换到直接播放')
|
||||
setMode('direct')
|
||||
params.set('mode', 'direct')
|
||||
setParams(params, { replace: true })
|
||||
@@ -76,17 +81,26 @@ export function PlayerPage() {
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = url
|
||||
} else {
|
||||
toast.error('当前浏览器不支持 HLS,降级到直接播放')
|
||||
setHlsUnavailable(true)
|
||||
setPlayerError('当前浏览器不支持 HLS,正在尝试直接播放。')
|
||||
toast.error('当前浏览器不支持 HLS,降级到直接播放')
|
||||
setMode('direct')
|
||||
}
|
||||
void video.play().catch(() => undefined)
|
||||
}).catch(() => {
|
||||
setHlsUnavailable(true)
|
||||
setPlayerError('HLS 播放组件加载失败,正在尝试直接播放。')
|
||||
setMode('direct')
|
||||
})
|
||||
} else {
|
||||
video.src = streamURL(media.id)
|
||||
if (hlsUnavailable && needsTranscodeForBrowser(media)) {
|
||||
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
|
||||
}
|
||||
void video.play().catch(() => undefined)
|
||||
}
|
||||
return teardownHls
|
||||
}, [media, mode, params, setParams])
|
||||
}, [hlsUnavailable, media, mode, params, setParams])
|
||||
|
||||
// Persist resume position every 10 seconds while playing.
|
||||
useEffect(() => {
|
||||
@@ -113,11 +127,14 @@ export function PlayerPage() {
|
||||
// ESC = back.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') navigate(-1)
|
||||
if (e.key === 'Escape') {
|
||||
const target = media?.id || id
|
||||
navigate(target ? `/media/${target}` : '/', { replace: true })
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [navigate])
|
||||
}, [id, media?.id, navigate])
|
||||
|
||||
const teardownHls = () => {
|
||||
if (hlsRef.current) {
|
||||
@@ -126,35 +143,42 @@ export function PlayerPage() {
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="-m-6 flex min-h-screen flex-col bg-black md:-m-8">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="absolute left-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-ink-600 backdrop-blur transition hover:bg-black/80"
|
||||
>
|
||||
<ArrowLeft size={16} /> 返回
|
||||
</button>
|
||||
const goBack = () => {
|
||||
const target = media?.id || id
|
||||
navigate(target ? `/media/${target}` : '/', { replace: true })
|
||||
}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||
setMode(next)
|
||||
params.set('mode', next)
|
||||
setParams(params, { replace: true })
|
||||
}}
|
||||
className="absolute right-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-ink-600 backdrop-blur transition hover:bg-black/80"
|
||||
title="切换播放模式"
|
||||
>
|
||||
{mode === 'hls' ? (
|
||||
<>
|
||||
<RefreshCw size={14} /> HLS 转码中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={14} /> 直接播放
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
return (
|
||||
<div className="relative -m-6 flex min-h-screen flex-col overflow-hidden bg-black md:-m-8">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between p-4 sm:p-6">
|
||||
<button
|
||||
onClick={goBack}
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
|
||||
>
|
||||
<ArrowLeft size={16} /> 返回
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||
setMode(next)
|
||||
params.set('mode', next)
|
||||
setParams(params, { replace: true })
|
||||
}}
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
|
||||
title="切换播放模式"
|
||||
>
|
||||
{mode === 'hls' ? (
|
||||
<>
|
||||
<RefreshCw size={14} /> HLS 转码中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={14} /> 直接播放
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
{media ? (
|
||||
@@ -168,11 +192,17 @@ export function PlayerPage() {
|
||||
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
|
||||
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
|
||||
if (mode === 'direct') {
|
||||
toast.error('直接播放失败,切换到 HLS 转码')
|
||||
setMode('hls')
|
||||
params.set('mode', 'hls')
|
||||
setParams(params, { replace: true })
|
||||
if (hlsUnavailable) {
|
||||
setPlayerError('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
|
||||
toast.error('直接播放失败,HLS 转码不可用')
|
||||
} else {
|
||||
toast.error('直接播放失败,切换到 HLS 转码')
|
||||
setMode('hls')
|
||||
params.set('mode', 'hls')
|
||||
setParams(params, { replace: true })
|
||||
}
|
||||
} else {
|
||||
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
|
||||
toast.error('视频播放失败,请检查文件是否存在')
|
||||
}
|
||||
}}
|
||||
@@ -191,6 +221,11 @@ export function PlayerPage() {
|
||||
) : (
|
||||
<p className="text-sand-500">加载中…</p>
|
||||
)}
|
||||
{playerError ? (
|
||||
<div className="absolute bottom-20 left-1/2 w-[min(92vw,720px)] -translate-x-1/2 rounded-2xl border border-white/15 bg-black/75 px-5 py-4 text-sm text-white shadow-2xl backdrop-blur">
|
||||
{playerError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -201,11 +236,15 @@ const directVideoCodecs = ['h264', 'avc', 'avc1']
|
||||
const directAudioCodecs = ['aac', 'mp3', 'opus']
|
||||
|
||||
function pickMode(m: Media): Mode {
|
||||
return needsTranscodeForBrowser(m) ? 'hls' : 'direct'
|
||||
}
|
||||
|
||||
function needsTranscodeForBrowser(m: Media): boolean {
|
||||
const c = (m.container ?? '').toLowerCase()
|
||||
const v = (m.video_codec ?? '').toLowerCase()
|
||||
const a = (m.audio_codec ?? '').toLowerCase()
|
||||
const containerOK = directContainers.some((x) => c.includes(x))
|
||||
const videoOK = !v || directVideoCodecs.some((x) => v.includes(x))
|
||||
const audioOK = !a || directAudioCodecs.some((x) => a.includes(x))
|
||||
return containerOK && videoOK && audioOK ? 'direct' : 'hls'
|
||||
return !(containerOK && videoOK && audioOK)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ChangeEvent, FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { Rss, Sparkles } from 'lucide-react'
|
||||
|
||||
import { aiAPI, type SearchIntent } from '../api/ai'
|
||||
import { aiAPI, type ExternalMediaResult, type SearchIntent } from '../api/ai'
|
||||
import { imageURL } from '../api/client'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { subscriptionsAPI } from '../api/subscriptions'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { Media } from '../types'
|
||||
|
||||
@@ -16,6 +18,8 @@ export function SearchPage() {
|
||||
const [aiAvailable, setAiAvailable] = useState(false)
|
||||
const [intent, setIntent] = useState<SearchIntent | null>(null)
|
||||
const [hasSearched, setHasSearched] = useState(false)
|
||||
const [externalItems, setExternalItems] = useState<ExternalMediaResult[]>([])
|
||||
const [subscribing, setSubscribing] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
aiAPI
|
||||
@@ -37,6 +41,7 @@ export function SearchPage() {
|
||||
.search(query, 60)
|
||||
.then((d) => {
|
||||
setItems(d.items ?? [])
|
||||
setExternalItems([])
|
||||
setIntent(null)
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -66,6 +71,7 @@ export function SearchPage() {
|
||||
try {
|
||||
const data = await aiAPI.smartSearch(q)
|
||||
setItems(data.items ?? [])
|
||||
setExternalItems(data.external_items ?? [])
|
||||
setIntent(data.intent)
|
||||
} catch (err) {
|
||||
const msg =
|
||||
@@ -86,15 +92,14 @@ export function SearchPage() {
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">搜索</h1>
|
||||
<button
|
||||
disabled={!aiAvailable}
|
||||
className={
|
||||
'neon-button !px-3 !py-1 !text-xs ' +
|
||||
(aiOn ? '!border-accent-400 !bg-accent-400/20 !text-accent-400' : '')
|
||||
}
|
||||
onClick={() => setAiOn((on) => !on)}
|
||||
title={aiAvailable ? '启用 AI 智能搜索' : '请先在 ai.* 中配置 API Key'}
|
||||
title={aiAvailable ? '启用 AI 智能搜索' : '使用本地规则 + 外部数据源搜索'}
|
||||
>
|
||||
<Sparkles size={12} /> {aiOn ? 'AI 已开启' : 'AI 智能搜索'}
|
||||
<Sparkles size={12} /> {aiOn ? '智能搜索已开启' : '智能搜索'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -156,12 +161,112 @@ export function SearchPage() {
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<>
|
||||
<div className="text-sm font-semibold text-ink-100">本地媒体库</div>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{externalItems.length > 0 && (
|
||||
<ExternalResults
|
||||
items={externalItems}
|
||||
busyKey={subscribing}
|
||||
onSubscribe={async (item) => {
|
||||
const keyword = item.subscribe_keyword || item.title
|
||||
const key = `${item.source}:${keyword}`
|
||||
setSubscribing(key)
|
||||
try {
|
||||
const feed = `site-search://search?keyword=${encodeURIComponent(keyword)}&source=${encodeURIComponent(item.source)}`
|
||||
const sub = await subscriptionsAPI.create({
|
||||
name: `${item.title} 自动订阅`,
|
||||
feed_url: feed,
|
||||
filter: keyword,
|
||||
media_type: item.media_type,
|
||||
enabled: true,
|
||||
})
|
||||
const run = await subscriptionsAPI.runNow(sub.id)
|
||||
toast.success(
|
||||
run.queued > 0
|
||||
? `已订阅并加入 ${run.queued} 个下载`
|
||||
: '已订阅,暂未在 PT 站点找到可下载资源',
|
||||
)
|
||||
} catch (err) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'订阅失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSubscribing('')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExternalResults({
|
||||
items,
|
||||
busyKey,
|
||||
onSubscribe,
|
||||
}: {
|
||||
items: ExternalMediaResult[]
|
||||
busyKey: string
|
||||
onSubscribe: (item: ExternalMediaResult) => Promise<void>
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-semibold text-ink-600">外部数据源</h2>
|
||||
<p className="text-xs text-ink-50">
|
||||
来自 TMDb / 豆瓣 / Bangumi。订阅后会定期搜索已配置 PT 站点,并只入队最佳新资源。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => {
|
||||
const keyword = item.subscribe_keyword || item.title
|
||||
const key = `${item.source}:${keyword}`
|
||||
return (
|
||||
<article key={key} className="glass-panel flex gap-3 !p-3">
|
||||
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-xl bg-gray-100">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={imageURL(item.poster_url)}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full bg-primary-400/10 px-2 py-0.5 text-[10px] uppercase text-brand-500">
|
||||
{item.source}
|
||||
</span>
|
||||
{item.media_type && <span className="text-xs text-sand-500">{item.media_type}</span>}
|
||||
{item.year ? <span className="text-xs text-sand-500">{item.year}</span> : null}
|
||||
{item.rating ? <span className="text-xs text-amber-500">★ {item.rating.toFixed(1)}</span> : null}
|
||||
</div>
|
||||
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
|
||||
{item.overview || `订阅关键词:${keyword}`}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => onSubscribe(item)}
|
||||
disabled={busyKey === key}
|
||||
className="mt-3 rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10 disabled:opacity-50"
|
||||
>
|
||||
<Rss size={12} className="mr-1 inline" />
|
||||
{busyKey === key ? '订阅中…' : '订阅并搜索 PT'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Globe, Plus, Trash2, Wifi, RefreshCw, X, Edit3, CheckCircle, XCircle, H
|
||||
|
||||
import { sitesAPI } from '../api/sites'
|
||||
import type { Site } from '../types'
|
||||
import { ManagementShortcuts } from '../components/ManagementShortcuts'
|
||||
|
||||
// ── 站点类型映射 ──
|
||||
const SITE_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -235,6 +236,17 @@ export function SitesPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ManagementShortcuts
|
||||
title="站点与下载链路"
|
||||
description="把站点、搜索、订阅和下载器放在同一工作流里,避免功能入口被隐藏。"
|
||||
items={[
|
||||
{ to: '/download-clients', title: '下载器管理', description: '添加、测试和维护下载器连接', badge: '必需' },
|
||||
{ to: '/site-search', title: '站点检索', description: '跨 PT 站点搜索资源并创建下载任务' },
|
||||
{ to: '/subscriptions', title: '订阅管理', description: '管理追剧追番和自动下载规则' },
|
||||
{ to: '/downloads', title: '下载中心', description: '查看下载任务状态和历史记录' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 页头 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">站点管理</h1>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Activity, Cpu, Database, Film, HardDrive, Users } from 'lucide-react'
|
||||
import { statsAPI } from '../api/stats'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { StatsSnapshot } from '../types'
|
||||
import { groupSeries } from '../utils/groupSeries'
|
||||
|
||||
// fmtBytes is a tiny helper shared by the dashboard cards.
|
||||
function fmtBytes(n: number): string {
|
||||
@@ -54,6 +55,7 @@ export function StatsPage() {
|
||||
snap.hardware.disk_total > 0
|
||||
? (snap.hardware.disk_used / snap.hardware.disk_total) * 100
|
||||
: 0
|
||||
const recentlyAddedCards = groupSeries(snap.recently_added)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -91,12 +93,17 @@ export function StatsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{snap.recently_added.length > 0 && (
|
||||
{recentlyAddedCards.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-xl font-semibold text-ink-600">最近入库</h2>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{snap.recently_added.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
{recentlyAddedCards.map((s) => (
|
||||
<MediaCard
|
||||
key={s.key}
|
||||
media={s.rep}
|
||||
count={s.count}
|
||||
linkTo={s.count > 1 ? `/library/${s.rep.library_id}?series=${encodeURIComponent(s.key)}` : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Database, HardDrive, PieChart } from 'lucide-react'
|
||||
|
||||
import { storageAPI, type StorageBreakdown } from '../api/storage'
|
||||
import { ManagementShortcuts } from '../components/ManagementShortcuts'
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (!n) return '0 B'
|
||||
@@ -50,6 +51,17 @@ export function StoragePage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ManagementShortcuts
|
||||
title="存储与文件入口"
|
||||
description="存储统计只负责看数据,文件管理、排重、回收与配置入口集中放在这里。"
|
||||
items={[
|
||||
{ to: '/files', title: '文件管理', description: '浏览服务器文件并执行基础文件操作' },
|
||||
{ to: '/storage-config', title: '存储配置', description: '维护媒体存储路径和容量策略' },
|
||||
{ to: '/duplicates', title: '重复清理', description: '扫描重复媒体并进行安全清理', badge: '清理' },
|
||||
{ to: '/recycle', title: '回收站', description: '查看已删除资源并执行恢复或释放空间' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<section className="grid gap-4 sm:grid-cols-3">
|
||||
<Tile icon={<Database size={20} />} label="总占用" value={fmtBytes(data.total_bytes)} />
|
||||
<Tile icon={<PieChart size={20} />} label="媒体库" value={`${data.by_library.length}`} />
|
||||
|
||||
@@ -10,6 +10,9 @@ export function SubscriptionsPage() {
|
||||
const [name, setName] = useState('')
|
||||
const [feed, setFeed] = useState('')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [mediaType, setMediaType] = useState('')
|
||||
const [mediaCategory, setMediaCategory] = useState('')
|
||||
const [savePath, setSavePath] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const refresh = () =>
|
||||
@@ -25,11 +28,21 @@ export function SubscriptionsPage() {
|
||||
const onCreate = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await subscriptionsAPI.create({ name, feed_url: feed, filter })
|
||||
await subscriptionsAPI.create({
|
||||
name,
|
||||
feed_url: feed,
|
||||
filter,
|
||||
media_type: mediaType || undefined,
|
||||
media_category: mediaCategory || undefined,
|
||||
save_path: savePath || undefined,
|
||||
})
|
||||
toast.success('已创建订阅')
|
||||
setName('')
|
||||
setFeed('')
|
||||
setFilter('')
|
||||
setMediaType('')
|
||||
setMediaCategory('')
|
||||
setSavePath('')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
@@ -42,10 +55,10 @@ export function SubscriptionsPage() {
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">RSS 订阅</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列。
|
||||
定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列;启用智能分类后会按媒体类型和二级分类写入下载目录。
|
||||
</p>
|
||||
|
||||
<form onSubmit={onCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_1fr_auto]">
|
||||
<form onSubmit={onCreate} className="glass-panel grid gap-3 md:grid-cols-4">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
@@ -66,7 +79,26 @@ export function SubscriptionsPage() {
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="neon-button">
|
||||
<select className="input-base" value={mediaType} onChange={(e) => setMediaType(e.target.value)}>
|
||||
<option value="">自动识别类型</option>
|
||||
<option value="movie">电影</option>
|
||||
<option value="tv">电视剧</option>
|
||||
<option value="anime">动漫</option>
|
||||
<option value="variety">综艺</option>
|
||||
</select>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="二级分类覆盖(如 综艺/日番,可选)"
|
||||
value={mediaCategory}
|
||||
onChange={(e) => setMediaCategory(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input-base md:col-span-2"
|
||||
placeholder="下载根目录覆盖(可选,默认使用下载器保存路径)"
|
||||
value={savePath}
|
||||
onChange={(e) => setSavePath(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="neon-button md:col-span-1">
|
||||
<Plus size={16} /> 添加
|
||||
</button>
|
||||
</form>
|
||||
@@ -82,6 +114,7 @@ export function SubscriptionsPage() {
|
||||
<th className="py-2">名称</th>
|
||||
<th>RSS</th>
|
||||
<th>过滤器</th>
|
||||
<th>分类</th>
|
||||
<th>最近运行</th>
|
||||
<th className="text-right">操作</th>
|
||||
</tr>
|
||||
@@ -94,6 +127,9 @@ export function SubscriptionsPage() {
|
||||
{s.feed_url}
|
||||
</td>
|
||||
<td className="text-ink-100">{s.filter || '—'}</td>
|
||||
<td className="text-ink-100">
|
||||
{[s.media_type, s.media_category].filter(Boolean).join(' / ') || '自动'}
|
||||
</td>
|
||||
<td className="text-sand-500">
|
||||
{s.last_run_at ? new Date(s.last_run_at).toLocaleString() : '—'}
|
||||
</td>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { adminAPI } from '../api/admin'
|
||||
import { libraryAPI, mediaAPI } from '../api/library'
|
||||
import { toolsAPI } from '../api/tools'
|
||||
import type { Library, Media, Setting } from '../types'
|
||||
import { ManagementShortcuts } from '../components/ManagementShortcuts'
|
||||
|
||||
// ToolsPage gathers admin-only one-off operations that don't belong on a
|
||||
// dedicated screen of their own:
|
||||
@@ -29,6 +30,18 @@ export function ToolsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ManagementShortcuts
|
||||
title="运维与自动化入口"
|
||||
description="把整理、任务、通知和高级辅助功能统一放回工具台。"
|
||||
items={[
|
||||
{ to: '/strm', title: 'STRM 生成', description: '生成 STRM 文件供外部播放器或媒体服务使用' },
|
||||
{ to: '/scheduler', title: '定时任务', description: '查看和维护自动扫描、刮削与订阅任务' },
|
||||
{ to: '/tasks', title: '任务队列', description: '跟踪后台任务执行状态和失败原因' },
|
||||
{ to: '/notify-channels', title: '通知渠道', description: '配置并测试消息通知渠道' },
|
||||
{ to: '/assistant', title: 'AI 对话台', description: '进入管理员辅助诊断和问答界面' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<OrganizePanel />
|
||||
<NotifyPanel />
|
||||
</div>
|
||||
|
||||
@@ -145,7 +145,10 @@ export interface ScanResult {
|
||||
library_id: string
|
||||
visited: number
|
||||
added: number
|
||||
updated?: number
|
||||
probed: number
|
||||
local_metadata?: number
|
||||
removed?: number
|
||||
}
|
||||
|
||||
export interface Setting {
|
||||
@@ -170,6 +173,9 @@ export interface Subscription {
|
||||
name: string
|
||||
feed_url: string
|
||||
filter: string
|
||||
media_type?: string
|
||||
media_category?: string
|
||||
save_path?: string
|
||||
enabled: boolean
|
||||
last_run_at?: string
|
||||
created_at: string
|
||||
|
||||
@@ -9,9 +9,9 @@ import type { Media } from '../types'
|
||||
*
|
||||
* 折叠键优先级(命中第一个就分组):
|
||||
*
|
||||
* 1. tmdb_id (刮削匹配后最稳定)
|
||||
* 2. bangumi_id (番剧)
|
||||
* 3. series_id (后端某些场景下会预先聚合)
|
||||
* 1. series_id (后端某些场景下会预先聚合)
|
||||
* 2. 有季/集信息时用 library_id + 节目名(综艺每集常有不同 TMDB episode id)
|
||||
* 3. tmdb_id / bangumi_id(电影或无季集条目)
|
||||
* 4. library_id + title (fallback:同库同名视为同一剧)
|
||||
*
|
||||
* 同一组内取最早 created_at 的那条作为代表卡片,并带 count 表示集数。
|
||||
@@ -19,10 +19,43 @@ import type { Media } from '../types'
|
||||
export type SeriesCard = { key: string; rep: Media; count: number }
|
||||
|
||||
export function getSeriesKey(media: Media): string {
|
||||
if (media.series_id) return `series:${media.series_id}`
|
||||
if (isEpisodeLike(media)) {
|
||||
return `lib:${media.library_id}|show:${normalizeTitle(seriesTitle(media))}`
|
||||
}
|
||||
if (media.tmdb_id && media.tmdb_id > 0) return `tmdb:${media.tmdb_id}`
|
||||
if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}`
|
||||
if (media.series_id) return `series:${media.series_id}`
|
||||
return `lib:${media.library_id}|${(media.title ?? '').toLowerCase().trim()}`
|
||||
return `lib:${media.library_id}|${normalizeTitle(media.title)}`
|
||||
}
|
||||
|
||||
export function isEpisodeLike(media: Media): boolean {
|
||||
return (media.season_num ?? 0) > 0 || (media.episode_num ?? 0) > 0
|
||||
}
|
||||
|
||||
export function seriesTitle(media: Media): string {
|
||||
const fromPath = seriesTitleFromPath(media.path)
|
||||
return fromPath || media.title || media.original_name || '未命名节目'
|
||||
}
|
||||
|
||||
function normalizeTitle(value?: string): string {
|
||||
return (value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/\s*\((?:19|20)\d{2}\)\s*/g, ' ')
|
||||
.replace(/\s*\[(?:tmdb|tmdbid)[=-]\d+\]\s*/g, ' ')
|
||||
.replace(/[\s._-]+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function seriesTitleFromPath(path?: string): string {
|
||||
if (!path) return ''
|
||||
const parts = path.split(/[\\/]+/).filter(Boolean)
|
||||
if (parts.length < 2) return ''
|
||||
let dirIndex = parts.length - 2
|
||||
if (/^(?:s\d{1,2}|season\s*\d{1,2}|第\s*\d{1,2}\s*季)$/i.test(parts[dirIndex])) {
|
||||
dirIndex -= 1
|
||||
}
|
||||
if (dirIndex < 0) return ''
|
||||
return normalizeTitle(parts[dirIndex])
|
||||
}
|
||||
|
||||
export function groupSeries(items: Media[]): SeriesCard[] {
|
||||
|
||||
Reference in New Issue
Block a user