diff --git a/.tmp-deploy-data/.jwt_secret b/.tmp-deploy-data/.jwt_secret new file mode 100644 index 0000000..b20cfbd --- /dev/null +++ b/.tmp-deploy-data/.jwt_secret @@ -0,0 +1 @@ +39f30642a462624f2aa03896465943891e2d28db9f9a03aca94fd9af58d0c108 \ No newline at end of file diff --git a/.tmp-deploy-server.pid b/.tmp-deploy-server.pid new file mode 100644 index 0000000..9a672fa --- /dev/null +++ b/.tmp-deploy-server.pid @@ -0,0 +1 @@ +28604 diff --git a/config.example.yaml b/config.example.yaml index bf80046..f909438 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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: "未分类" diff --git a/internal/config/config.go b/internal/config/config.go index 35ce029..d8d611c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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") diff --git a/internal/handler/admin.go b/internal/handler/admin.go index e07bb9b..653da90 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -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) } } diff --git a/internal/handler/ai.go b/internal/handler/ai.go index 61720f7..02b1ae0 100644 --- a/internal/handler/ai.go +++ b/internal/handler/ai.go @@ -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, }) } } diff --git a/internal/handler/discover_extra.go b/internal/handler/discover_extra.go index 5e50df4..521f0de 100644 --- a/internal/handler/discover_extra.go +++ b/internal/handler/discover_extra.go @@ -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 } } diff --git a/internal/handler/downloads.go b/internal/handler/downloads.go index 0fccc21..cca8043 100644 --- a/internal/handler/downloads.go +++ b/internal/handler/downloads.go @@ -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 diff --git a/internal/handler/duplicate.go b/internal/handler/duplicate.go index 407f672..2c9e3ca 100644 --- a/internal/handler/duplicate.go +++ b/internal/handler/duplicate.go @@ -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") diff --git a/internal/handler/emby.go b/internal/handler/emby.go index 44fbb4c..bd2b8ac 100644 --- a/internal/handler/emby.go +++ b/internal/handler/emby.go @@ -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)) diff --git a/internal/handler/emby_test.go b/internal/handler/emby_test.go new file mode 100644 index 0000000..d539b1d --- /dev/null +++ b/internal/handler/emby_test.go @@ -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"]) + } +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 777debf..c88ca51 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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. diff --git a/internal/handler/streaming.go b/internal/handler/streaming.go index cddb614..340b720 100644 --- a/internal/handler/streaming.go +++ b/internal/handler/streaming.go @@ -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"}) } diff --git a/internal/handler/subscription_extra.go b/internal/handler/subscription_extra.go index 19bd297..9d30af2 100644 --- a/internal/handler/subscription_extra.go +++ b/internal/handler/subscription_extra.go @@ -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 diff --git a/internal/handler/subscriptions.go b/internal/handler/subscriptions.go index 3d24c58..c432bb3 100644 --- a/internal/handler/subscriptions.go +++ b/internal/handler/subscriptions.go @@ -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()}) diff --git a/internal/handler/subtitles.go b/internal/handler/subtitles.go index 94a5a58..dc08cc4 100644 --- a/internal/handler/subtitles.go +++ b/internal/handler/subtitles.go @@ -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}) } } diff --git a/internal/middleware/emby_auth.go b/internal/middleware/emby_auth.go index 556d3bc..792212f 100644 --- a/internal/middleware/emby_auth.go +++ b/internal/middleware/emby_auth.go @@ -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 请求头 -// 3. ?token= URL 参数 -// 4. (仅 AuthenticateByName 端点)POST body 中的 Username+Password +// 1. X-Emby-Token / X-MediaBrowser-Token 请求头 +// 2. Authorization: Bearer / MediaBrowser Token="" 请求头 +// 3. X-Emby-Authorization: MediaBrowser 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 或 Emby - 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 { diff --git a/internal/middleware/emby_auth_test.go b/internal/middleware/emby_auth_test.go new file mode 100644 index 0000000..c08f5df --- /dev/null +++ b/internal/middleware/emby_auth_test.go @@ -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 +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index f5afe3b..a76048a 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -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 diff --git a/internal/model/api_config.go b/internal/model/api_config.go index 3409909..2af6f99 100644 --- a/internal/model/api_config.go +++ b/internal/model/api_config.go @@ -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}, } } diff --git a/internal/model/model.go b/internal/model/model.go index 54de927..4854357 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -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"` } diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 3de1829..0845573 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -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 } diff --git a/internal/service/adult_scraper.go b/internal/service/adult_scraper.go new file mode 100644 index 0000000..5d2c569 --- /dev/null +++ b/internal/service/adult_scraper.go @@ -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)]*>(.*?)`) + adultTagPattern = regexp.MustCompile(`(?is)<[^>]+>`) + adultAnchorPattern = regexp.MustCompile(`(?is)]*)>(.*?)`) + adultImagePattern = regexp.MustCompile(`(?is)]*)>`) + adultJavBusCoverPattern = regexp.MustCompile(`(?is)class="bigImage"[^>]*href="([^"]+)"`) + adultSamplePattern = regexp.MustCompile(`(?is)]+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 +} diff --git a/internal/service/adult_scraper_test.go b/internal/service/adult_scraper_test.go new file mode 100644 index 0000000..ad445fd --- /dev/null +++ b/internal/service/adult_scraper_test.go @@ -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 := ` +

SSIS-001 测试标题

+ + +4.7 +
日期 2024-05-01
+` + + 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) + } +} diff --git a/internal/service/api_config.go b/internal/service/api_config.go index a8ce618..5a2b431 100644 --- a/internal/service/api_config.go +++ b/internal/service/api_config.go @@ -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 diff --git a/internal/service/api_config_svc.go b/internal/service/api_config_svc.go index 94a96f2..82b2c6f 100644 --- a/internal/service/api_config_svc.go +++ b/internal/service/api_config_svc.go @@ -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() diff --git a/internal/service/bangumi.go b/internal/service/bangumi.go index 12c2a36..2cee34d 100644 --- a/internal/service/bangumi.go +++ b/internal/service/bangumi.go @@ -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), } } diff --git a/internal/service/discover.go b/internal/service/discover.go index 31eb1ac..20b2ee2 100644 --- a/internal/service/discover.go +++ b/internal/service/discover.go @@ -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 +} diff --git a/internal/service/douban.go b/internal/service/douban.go index f482dc7..65cfaa1 100644 --- a/internal/service/douban.go +++ b/internal/service/douban.go @@ -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 } diff --git a/internal/service/downloads.go b/internal/service/downloads.go index 376879b..052c0d2 100644 --- a/internal/service/downloads.go +++ b/internal/service/downloads.go @@ -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) { diff --git a/internal/service/duplicate.go b/internal/service/duplicate.go index 0efdb38..1b545b4 100644 --- a/internal/service/duplicate.go +++ b/internal/service/duplicate.go @@ -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. diff --git a/internal/service/episode_parser.go b/internal/service/episode_parser.go index 9772a91..b9d6215 100644 --- a/internal/service/episode_parser.go +++ b/internal/service/episode_parser.go @@ -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 diff --git a/internal/service/episode_parser_test.go b/internal/service/episode_parser_test.go index 5d491b4..d93f69f 100644 --- a/internal/service/episode_parser_test.go +++ b/internal/service/episode_parser_test.go @@ -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 { diff --git a/internal/service/external_search.go b/internal/service/external_search.go new file mode 100644 index 0000000..d9e8e91 --- /dev/null +++ b/internal/service/external_search.go @@ -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 +} diff --git a/internal/service/external_search_test.go b/internal/service/external_search_test.go new file mode 100644 index 0000000..56fced2 --- /dev/null +++ b/internal/service/external_search_test.go @@ -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)) + } +} diff --git a/internal/service/fanart.go b/internal/service/fanart.go index 22f0e75..41f1320 100644 --- a/internal/service/fanart.go +++ b/internal/service/fanart.go @@ -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) diff --git a/internal/service/ffmpeg_auto_install.go b/internal/service/ffmpeg_auto_install.go index 94348aa..1f5b2c7 100644 --- a/internal/service/ffmpeg_auto_install.go +++ b/internal/service/ffmpeg_auto_install.go @@ -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 { diff --git a/internal/service/ffprobe.go b/internal/service/ffprobe.go index 5d5e5b3..3922229 100644 --- a/internal/service/ffprobe.go +++ b/internal/service/ffprobe.go @@ -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 +} diff --git a/internal/service/ffprobe_test.go b/internal/service/ffprobe_test.go new file mode 100644 index 0000000..7404f30 --- /dev/null +++ b/internal/service/ffprobe_test.go @@ -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) + } +} diff --git a/internal/service/image_proxy.go b/internal/service/image_proxy.go index 5e77433..79fd4f1 100644 --- a/internal/service/image_proxy.go +++ b/internal/service/image_proxy.go @@ -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 { diff --git a/internal/service/image_proxy_test.go b/internal/service/image_proxy_test.go new file mode 100644 index 0000000..3896335 --- /dev/null +++ b/internal/service/image_proxy_test.go @@ -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)) + } +} diff --git a/internal/service/local_metadata.go b/internal/service/local_metadata.go new file mode 100644 index 0000000..2003573 --- /dev/null +++ b/internal/service/local_metadata.go @@ -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)) +} diff --git a/internal/service/local_metadata_test.go b/internal/service/local_metadata_test.go new file mode 100644 index 0000000..76e17de --- /dev/null +++ b/internal/service/local_metadata_test.go @@ -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 := ` + + 盗梦空间 + Inception + 2010 + 梦境盗窃。 + 8.8 + 27205 + 科幻 + 动作 +` + 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(`正确剧名2024123`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(nfoPath(mediaPath), []byte(`第三集23本集简介`), 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(`哈哈哈哈哈综艺`), 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 := ` + + 成人影片标题 + SSIS-001 + SSIS-001 + 2024-05-01 + 本地简介 + SSIS-001-poster.jpg + SSIS-001-fanart.jpg + 测试片商 + 剧情 + 中文字幕 + 演员A +` + 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(`本地番号电影MIDV-123`), 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(`本地剧名2025`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(nfoPath(mediaPath), []byte(`本地第三集23`), 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) + } +} diff --git a/internal/service/media.go b/internal/service/media.go index 7d3be81..32a450c 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -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 } diff --git a/internal/service/media_classifier.go b/internal/service/media_classifier.go new file mode 100644 index 0000000..51de8a7 --- /dev/null +++ b/internal/service/media_classifier.go @@ -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 +} diff --git a/internal/service/media_classifier_test.go b/internal/service/media_classifier_test.go new file mode 100644 index 0000000..b60f78d --- /dev/null +++ b/internal/service/media_classifier_test.go @@ -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) + } +} diff --git a/internal/service/media_tools.go b/internal/service/media_tools.go new file mode 100644 index 0000000..d440dd9 --- /dev/null +++ b/internal/service/media_tools.go @@ -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 +} diff --git a/internal/service/nfo.go b/internal/service/nfo.go index 998b3a9..cc5b3c3 100644 --- a/internal/service/nfo.go +++ b/internal/service/nfo.go @@ -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 +} diff --git a/internal/service/organizer.go b/internal/service/organizer.go index b115e19..9d51b04 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -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. diff --git a/internal/service/organizer_test.go b/internal/service/organizer_test.go new file mode 100644 index 0000000..7d1c183 --- /dev/null +++ b/internal/service/organizer_test.go @@ -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 := `第三集23` + 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) + } +} diff --git a/internal/service/proxy.go b/internal/service/proxy.go new file mode 100644 index 0000000..4eeed5a --- /dev/null +++ b/internal/service/proxy.go @@ -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) +} diff --git a/internal/service/proxy_other.go b/internal/service/proxy_other.go new file mode 100644 index 0000000..db8adb6 --- /dev/null +++ b/internal/service/proxy_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package service + +import ( + "net/http" + "net/url" +) + +func systemProxyForRequest(_ *http.Request) (*url.URL, error) { + return nil, nil +} diff --git a/internal/service/proxy_test.go b/internal/service/proxy_test.go new file mode 100644 index 0000000..720660a --- /dev/null +++ b/internal/service/proxy_test.go @@ -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) + } + }) + } +} diff --git a/internal/service/proxy_windows.go b/internal/service/proxy_windows.go new file mode 100644 index 0000000..832de18 --- /dev/null +++ b/internal/service/proxy_windows.go @@ -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 == "" && !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 +} diff --git a/internal/service/qbittorrent.go b/internal/service/qbittorrent.go index cfaedf7..766237f 100644 --- a/internal/service/qbittorrent.go +++ b/internal/service/qbittorrent.go @@ -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) diff --git a/internal/service/qbittorrent_test.go b/internal/service/qbittorrent_test.go new file mode 100644 index 0000000..805b2eb --- /dev/null +++ b/internal/service/qbittorrent_test.go @@ -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 + } + } +} diff --git a/internal/service/runtime_settings.go b/internal/service/runtime_settings.go new file mode 100644 index 0000000..438ddbe --- /dev/null +++ b/internal/service/runtime_settings.go @@ -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 + } +} diff --git a/internal/service/scanner.go b/internal/service/scanner.go index 8aba30c..903ad33 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -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 + } +} diff --git a/internal/service/scraper.go b/internal/service/scraper.go index 14a9c2a..2b047d2 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -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" } } diff --git a/internal/service/scraper_test.go b/internal/service/scraper_test.go index 0d865b2..5ecfd57 100644 --- a/internal/service/scraper_test.go +++ b/internal/service/scraper_test.go @@ -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(` +间谍过家家 +2022 +120089 +Animation +`), 0o644); err != nil { + t.Fatal(err) + } + mediaPath := filepath.Join(seasonDir, "间谍过家家 - S02E12.mkv") + if err := os.WriteFile(nfoPath(mediaPath), []byte(` +企鹅公园 +间谍过家家 +2 +12 +本地剧情 +`), 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 +} diff --git a/internal/service/service.go b/internal/service/service.go index f599e9d..8179ef2 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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, } } diff --git a/internal/service/site.go b/internal/service/site.go index f584057..a9d7da7 100644 --- a/internal/service/site.go +++ b/internal/service/site.go @@ -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, diff --git a/internal/service/stream.go b/internal/service/stream.go index ed6ad17..a5f8576 100644 --- a/internal/service/stream.go +++ b/internal/service/stream.go @@ -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 diff --git a/internal/service/stream_test.go b/internal/service/stream_test.go new file mode 100644 index 0000000..6506c92 --- /dev/null +++ b/internal/service/stream_test.go @@ -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) + } +} diff --git a/internal/service/subscription.go b/internal/service/subscription.go index 8072cf6..b730f86 100644 --- a/internal/service/subscription.go +++ b/internal/service/subscription.go @@ -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 { diff --git a/internal/service/subtitle.go b/internal/service/subtitle.go index 45fc11e..16179ba 100644 --- a/internal/service/subtitle.go +++ b/internal/service/subtitle.go @@ -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 { diff --git a/internal/service/subtitle_test.go b/internal/service/subtitle_test.go index f180f06..bfe40db 100644 --- a/internal/service/subtitle_test.go +++ b/internal/service/subtitle_test.go @@ -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)) } } diff --git a/internal/service/thetvdb.go b/internal/service/thetvdb.go index 41eb3ac..85df523 100644 --- a/internal/service/thetvdb.go +++ b/internal/service/thetvdb.go @@ -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"` diff --git a/internal/service/tmdb.go b/internal/service/tmdb.go index 1ffb666..b08941b 100644 --- a/internal/service/tmdb.go +++ b/internal/service/tmdb.go @@ -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"` diff --git a/internal/service/transcoder.go b/internal/service/transcoder.go index fde276b..0f8f1dc 100644 --- a/internal/service/transcoder.go +++ b/internal/service/transcoder.go @@ -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, ) diff --git a/internal/service/transcoder_test.go b/internal/service/transcoder_test.go index 402432a..f95f03a 100644 --- a/internal/service/transcoder_test.go +++ b/internal/service/transcoder_test.go @@ -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") + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 7103585..2cc371f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } > } /> + } /> } /> } /> } /> diff --git a/web/src/api/ai.ts b/web/src/api/ai.ts index adbd13d..81aada8 100644 --- a/web/src/api/ai.ts +++ b/web/src/api/ai.ts @@ -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), diff --git a/web/src/api/discover.ts b/web/src/api/discover.ts index 50b8947..c9a51bd 100644 --- a/web/src/api/discover.ts +++ b/web/src/api/discover.ts @@ -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 { - 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>('/discover/feed', { + params: { sections: sectionKeys.join(',') }, + }) + .then((r) => r.data), } diff --git a/web/src/api/duplicates.ts b/web/src/api/duplicates.ts index 3c0f239..434180f 100644 --- a/web/src/api/duplicates.ts +++ b/web/src/api/duplicates.ts @@ -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('/duplicates', { + params: libraryID ? { library_id: libraryID } : undefined, + }) + .then((r) => ({ ...r.data, groups: r.data.groups ?? [] })), scan: (libraryID = '') => api .post('/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, { diff --git a/web/src/api/subscriptions.ts b/web/src/api/subscriptions.ts index b69bf7e..540e795 100644 --- a/web/src/api/subscriptions.ts +++ b/web/src/api/subscriptions.ts @@ -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('/subscriptions', input).then((r) => r.data), remove: (id: string) => api.delete(`/subscriptions/${id}`).then((r) => r.data), diff --git a/web/src/api/subtitles.ts b/web/src/api/subtitles.ts index 304d3a5..e9d668c 100644 --- a/web/src/api/subtitles.ts +++ b/web/src/api/subtitles.ts @@ -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 ?? '' diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 64312d3..5d6d505 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -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([]) 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() {
} label="系统首页" end collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> + } label="媒体库" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> + } label="海报墙" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> } label="精彩发现" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> } label="智能搜索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="我的收藏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="播放列表" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="观看历史" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="影音海报墙" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="AI 影视助理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> + } label="DLNA 投屏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> + } label="AI 助理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
- {/* Navigation Group: Libraries */} + {/* Navigation Group: Personal */}
- +
- {libraries.length === 0 && (isSidebarOpen || isMobileDrawerOpen) && ( -
暂无媒体片库
- )} - {libraries.map((lib) => ( - } - label={lib.name} - collapsed={!isSidebarOpen && !isMobileDrawerOpen} - /> - ))} + } label="我的收藏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> + } label="播放列表" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> + } label="观看历史" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> + } label="账号信息" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
{/* Navigation Group: Automation */}
- +
} label="下载中心" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="RSS 订阅" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="DLNA 串流" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> + } label="下载器管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> + } label="订阅管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> } label="站点检索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
- {/* Navigation Group: Account Profile */} -
- -
- } label="账号信息" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="观影 Profile" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> -
-
- {/* Navigation Group: Admin Dashboard */} {user?.role === 'admin' && (
- +
- } label="后台主页" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="实时任务" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="运行监控" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="站点管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="通知配置" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="下载客户端" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="定时机制" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="存储分析" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="外部挂载" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="文件管家" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="排重清理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="STRM 关联" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="运维工具" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="AI 智能助教" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="系统参数" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> - } label="回收站" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> + } label="媒体与用户" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> + } label="站点与下载器" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> + } label="整理与维护" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> + } label="存储与文件" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> + } label="运行状态" collapsed={!isSidebarOpen && !isMobileDrawerOpen} /> + } label="系统设置" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
)} @@ -289,10 +253,15 @@ export function Layout() { {/* Notification alert bubble */} - + {/* Horizontal divider lines */} @@ -454,4 +423,4 @@ function SidebarLink({ to, icon, label, end, collapsed }: SidebarLinkProps) { )} ) -} \ No newline at end of file +} diff --git a/web/src/components/ManagementShortcuts.tsx b/web/src/components/ManagementShortcuts.tsx new file mode 100644 index 0000000..7fc8e38 --- /dev/null +++ b/web/src/components/ManagementShortcuts.tsx @@ -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 ( +
+
+
+

{title}

+ {description &&

{description}

} +
+
+
+ {items.map((item) => ( + +
+
+
+

{item.title}

+ {item.badge && ( + + {item.badge} + + )} +
+

+ {item.description} +

+
+ +
+ + ))} +
+
+ ) +} diff --git a/web/src/pages/AIAssistantPage.tsx b/web/src/pages/AIAssistantPage.tsx index 6753189..2c9ca9f 100644 --- a/web/src/pages/AIAssistantPage.tsx +++ b/web/src/pages/AIAssistantPage.tsx @@ -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(null) const [items, setItems] = useState([]) + const [externalItems, setExternalItems] = useState([]) + const [subscribing, setSubscribing] = useState('') const [recs, setRecs] = useState(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() { ))} )} + + {externalItems.length > 0 && ( +
+ {externalItems.map((item) => { + const keyword = item.subscribe_keyword || item.title + const key = `${item.source}:${keyword}` + return ( +
+
+
+ {item.poster_url ? ( + {item.title} + ) : null} +
+
+
+ {item.source} + {item.media_type && {item.media_type}} + {item.year ? {item.year} : null} +
+

{item.title}

+

+ {item.overview || `订阅关键词:${keyword}`} +

+ +
+
+
+ ) + })} +
+ )} {/* Recommendations */} diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx index 3f784d2..f272ab7 100644 --- a/web/src/pages/AdminPage.tsx +++ b/web/src/pages/AdminPage.tsx @@ -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 (

管理后台

+
{tabs.map((k) => ( + ) + })}
{loading && } - {/* TMDb API Key 未配置 */} - {!loading && missingKey && ( -
-
- -
-

TMDb API Key 未配置

-

- 您需要在管理后台填入 TMDb API Key 才能查看发现内容。 -

- - 前往管理后台 - - + {!loading && error && ( +
+ +

{error}

)} - {/* 网络无法访问 TMDb */} - {!loading && networkErr && ( -
-
- -
-

无法连接到 TMDb

-

- 服务器到 api.themoviedb.org 的连接超时。 - 通常是因为部署机器没有走代理。可以在系统环境变量里设置 - HTTPS_PROXY, - 或在「外部 API」配置里填写自建反代地址(tmdb_api_proxy / tmdb_image_proxy)。 -

-
- 查看原始错误 -
{anyErr}
-
+ {!loading && selected.length === 0 && ( +
+ 至少选择一个推荐源,小宇宙才会开始转动。
)} - {/* 其它错误 */} - {!loading && otherErr && ( -
- -

{otherErr}

-
- )} - - {/* Content Rows */} - {!loading && !missingKey && ( + {!loading && !error && selected.length > 0 && (
- {trending.length > 0 && } - {popular.length > 0 && } + {selected.map((key) => { + const items = rows[key] ?? [] + if (items.length === 0) return null + return ( + + ) + })} - {/* TMDB 配置 OK 但本次没拿到任何条目(极少见) */} - {!networkErr && trending.length === 0 && popular.length === 0 && !otherErr && ( -
-

暂无发现内容

+ {!hasContent && ( +
+

+ 当前选择的推荐源暂未返回内容,可切换豆瓣 / Bangumi 或检查网络代理。 +

)}
@@ -146,10 +158,10 @@ export function DiscoverPage() { function ContentRow({ title, items }: { title: string; items: DiscoverItem[] }) { return (
-

{title}

+

{title}

- {items.map((item) => ( - + {items.map((item, index) => ( + ))}
@@ -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 ( -
-
+
+
{item.poster_url ? ( {item.title} ) : ( -
+
无海报
)} - {item.rating > 0 && ( -
- ★ {item.rating.toFixed(1)} +
+ {source} +
+ {(item.rating ?? 0) > 0 && ( +
+ ★ {(item.rating ?? 0).toFixed(1)}
)}
-
-

+

+

{item.title}

- {item.year > 0 && ( -

{item.year}

- )} +

+ {[item.media_type, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' · ') || '推荐'} +

) @@ -194,12 +210,12 @@ function DiscoverCard({ item }: { item: DiscoverItem }) { function DiscoverSkeleton() { return (
- {[1, 2].map((section) => ( + {[1, 2, 3].map((section) => (
-
+
- {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => ( -
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((item) => ( +
))}
@@ -207,3 +223,28 @@ function DiscoverSkeleton() {
) } + +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' }, +] diff --git a/web/src/pages/DuplicatesPage.tsx b/web/src/pages/DuplicatesPage.tsx index 6ebf063..ee5efee 100644 --- a/web/src/pages/DuplicatesPage.tsx +++ b/web/src/pages/DuplicatesPage.tsx @@ -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() {

扫描了 {report.total_scanned} 项,未发现重复。

)} - {report && report.groups.map((g) => ( + {report && report.missing_removed ? ( +

+ 已清理 {report.missing_removed} 条文件不存在的媒体记录,统计容量会在刷新后恢复正常。 +

+ ) : null} + + {report && (report.groups ?? []).map((g) => (

{g.hash}

diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index ee2ebeb..35e4adf 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -15,9 +15,10 @@ import { groupSeries } from '../utils/groupSeries' type LibraryRow = { library: Library; cards: ReturnType } const TYPE_ICONS: Record = { - movie: , tv: , anime: , music: , + movie: , tv: , variety: , anime: , music: , } -const TYPE_LABELS: Record = { movie: '电影', tv: '电视剧', anime: '动漫', music: '音乐' } +const TYPE_LABELS: Record = { movie: '电影', tv: '电视剧', variety: '综艺', anime: '动漫', music: '音乐' } +const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url) export function HomePage() { const [libraries, setLibraries] = useState([]) @@ -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() {
{/* ─── Premium Swiss-Editorial Billboard Hero ─── */} {featuredItem && ( -
- {/* Background Poster Cover & Soft Faded Backdrop */} +
+ {/* Background Artwork */}
- {featuredItem.poster_url ? ( - + {featuredVisual && ( + { event.currentTarget.style.display = 'none' }} /> - ) : ( -
)} - {/* Soft Bright Masking */} -
-
+
+
+
{/* Billboard Content */} -
-
- - 本周力荐 / Featured +
+
+
+ + 本周力荐 / Featured +
+ +
+
+ + {featuredMark} +
+

+ {featuredItem.title} +

+
+ + {featuredItem.overview ? ( +

+ {featuredItem.overview} +

+ ) : ( +

+ 家庭私人媒体中心收藏。极高视听品质,支持多端原生无损解码及HLS转码播放。 +

+ )} + + {/* Metadata Badges */} +
+ {featuredItem.year > 0 && ( + {featuredItem.year} 年 + )} + {featuredItem.video_codec && ( + + {featuredItem.video_codec} + + )} + {featuredItem.container && ( + + {featuredItem.container} + + )} +
+ + {/* Buttons */} +
+ + + 立即播放 + + + 发现更多精彩 + + +
-

- {featuredItem.title} -

- - {featuredItem.overview ? ( -

- {featuredItem.overview} -

- ) : ( -

- 家庭私人媒体中心收藏。极高视听品质,支持多端原生无损解码及HLS转码播放。 -

- )} - - {/* Metadata Badges */} -
- {featuredItem.year > 0 && ( - {featuredItem.year} 年 - )} - {featuredItem.video_codec && ( - - {featuredItem.video_codec} - - )} - {featuredItem.container && ( - - {featuredItem.container} - - )} -
- - {/* Buttons */} -
- - - 立即播放 - - - 发现更多精彩 - - +
+
+
+
+
+ + {featuredItem.title} +
+ {featuredPoster && ( + {featuredItem.title} { event.currentTarget.style.display = 'none' }} + /> + )} +
+

MEDIASTATION PICK

+

{featuredItem.title}

+
+
diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx new file mode 100644 index 0000000..c3303a2 --- /dev/null +++ b/web/src/pages/LibrariesPage.tsx @@ -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 = { + movie: , + tv: , + anime: , + variety: , + music: , +} + +const TYPE_LABELS: Record = { + movie: '电影', + tv: '剧集', + anime: '动漫', + variety: '综艺', + music: '音乐', +} + +export function LibrariesPage() { + const [libraries, setLibraries] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + libraryAPI.list().then(setLibraries).finally(() => setLoading(false)) + }, []) + + if (loading) { + return

媒体库加载中…

+ } + + return ( +
+
+

媒体库

+

所有电影、剧集、番剧、综艺分类统一在这里查看。

+
+ + {libraries.length === 0 ? ( +
+ +

暂无媒体库,请到管理后台添加目录。

+
+ ) : ( +
+ {libraries.map((lib, index) => ( + + +
+
+ {TYPE_ICONS[lib.type] ?? } +
+ + {TYPE_LABELS[lib.type] ?? lib.type} + +
+

+ {lib.name} +

+

{lib.path}

+ +
+ ))} +
+ )} +
+ ) +} diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index d73fef7..c11de7d 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -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(null) + const [selectedSeason, setSelectedSeason] = useState(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() {

{library?.name ?? '媒体库'} - {!isSeries && ({total})} + ({isSeries ? seriesCards.length : total})

{library &&

{library.type} · {library.path}

}
@@ -200,7 +220,7 @@ export function LibraryPage() { 返回列表

- {selectedSeries.rep.title} + {seriesTitle(selectedSeries.rep)}

共 {selectedSeries.count} 集
@@ -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 ? ( @@ -240,37 +259,52 @@ export function LibraryPage() { {/* 季 / 集列表 */}
- {selectedEpisodes.map(({ season, episodes }) => ( -
-

+
+ {selectedEpisodes.map(({ season, episodes }) => ( +

-
- {episodes.map((ep) => ( - -
- {ep.episode_num || '—'} -
-
-

- {ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : ep.title} -

-

- {ep.duration_sec > 0 - ? `${Math.floor(ep.duration_sec / 60)} 分钟` - : formatSize(ep.size_bytes)} -

-
- - - ))} -
+ + ))} +
+ +
+

+ 第 {selectedSeason ?? selectedEpisodes[0]?.season ?? 1} 季 +

+
+ {visibleEpisodes.map((ep) => ( + +
+ {ep.episode_num || '—'} +
+
+

+ {ep.original_name || (ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : ep.title)} +

+

+ {ep.duration_sec > 0 + ? `${Math.floor(ep.duration_sec / 60)} 分钟` + : formatSize(ep.size_bytes)} +

+
+ + + ))}
- ))} +
)} diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 5942097..38c941c 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -35,6 +35,8 @@ export function PlayerPage() { const [media, setMedia] = useState(null) const [mode, setMode] = useState('direct') const [subs, setSubs] = useState([]) + 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 ( -
- + const goBack = () => { + const target = media?.id || id + navigate(target ? `/media/${target}` : '/', { replace: true }) + } - + return ( +
+
+ + + +
{media ? ( @@ -168,11 +192,17 @@ export function PlayerPage() { // 浏览器对
) @@ -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) } diff --git a/web/src/pages/SearchPage.tsx b/web/src/pages/SearchPage.tsx index 51c44b1..1a95581 100644 --- a/web/src/pages/SearchPage.tsx +++ b/web/src/pages/SearchPage.tsx @@ -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(null) const [hasSearched, setHasSearched] = useState(false) + const [externalItems, setExternalItems] = useState([]) + 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() {

搜索

@@ -156,12 +161,112 @@ export function SearchPage() { )} {items.length > 0 && ( + <> +
本地媒体库
{items.map((m) => ( ))}
+ + )} + + {externalItems.length > 0 && ( + { + 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('') + } + }} + /> )}
) } + +function ExternalResults({ + items, + busyKey, + onSubscribe, +}: { + items: ExternalMediaResult[] + busyKey: string + onSubscribe: (item: ExternalMediaResult) => Promise +}) { + return ( +
+
+

外部数据源

+

+ 来自 TMDb / 豆瓣 / Bangumi。订阅后会定期搜索已配置 PT 站点,并只入队最佳新资源。 +

+
+
+ {items.map((item) => { + const keyword = item.subscribe_keyword || item.title + const key = `${item.source}:${keyword}` + return ( +
+
+ {item.poster_url ? ( + {item.title} + ) : null} +
+
+
+ + {item.source} + + {item.media_type && {item.media_type}} + {item.year ? {item.year} : null} + {item.rating ? ★ {item.rating.toFixed(1)} : null} +
+

{item.title}

+

+ {item.overview || `订阅关键词:${keyword}`} +

+ +
+
+ ) + })} +
+
+ ) +} diff --git a/web/src/pages/SitesPage.tsx b/web/src/pages/SitesPage.tsx index 4fdb526..c1ebb1a 100644 --- a/web/src/pages/SitesPage.tsx +++ b/web/src/pages/SitesPage.tsx @@ -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 = { @@ -235,6 +236,17 @@ export function SitesPage() { return (
+ + {/* 页头 */}

站点管理

diff --git a/web/src/pages/StatsPage.tsx b/web/src/pages/StatsPage.tsx index c30d076..c3cdcc4 100644 --- a/web/src/pages/StatsPage.tsx +++ b/web/src/pages/StatsPage.tsx @@ -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 (
@@ -91,12 +93,17 @@ export function StatsPage() {
- {snap.recently_added.length > 0 && ( + {recentlyAddedCards.length > 0 && (

最近入库

- {snap.recently_added.map((m) => ( - + {recentlyAddedCards.map((s) => ( + 1 ? `/library/${s.rep.library_id}?series=${encodeURIComponent(s.key)}` : undefined} + /> ))}
diff --git a/web/src/pages/StoragePage.tsx b/web/src/pages/StoragePage.tsx index 7159945..65934ba 100644 --- a/web/src/pages/StoragePage.tsx +++ b/web/src/pages/StoragePage.tsx @@ -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() {
+ +
} label="总占用" value={fmtBytes(data.total_bytes)} /> } label="媒体库" value={`${data.by_library.length}`} /> diff --git a/web/src/pages/SubscriptionsPage.tsx b/web/src/pages/SubscriptionsPage.tsx index 9ac4477..26d43bd 100644 --- a/web/src/pages/SubscriptionsPage.tsx +++ b/web/src/pages/SubscriptionsPage.tsx @@ -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() {

RSS 订阅

- 定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列。 + 定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列;启用智能分类后会按媒体类型和二级分类写入下载目录。

-
+ setFilter(e.target.value)} /> -
@@ -82,6 +114,7 @@ export function SubscriptionsPage() { 名称 RSS 过滤器 + 分类 最近运行 操作 @@ -94,6 +127,9 @@ export function SubscriptionsPage() { {s.feed_url} {s.filter || '—'} + + {[s.media_type, s.media_category].filter(Boolean).join(' / ') || '自动'} + {s.last_run_at ? new Date(s.last_run_at).toLocaleString() : '—'} diff --git a/web/src/pages/ToolsPage.tsx b/web/src/pages/ToolsPage.tsx index 0ce40a5..3979043 100644 --- a/web/src/pages/ToolsPage.tsx +++ b/web/src/pages/ToolsPage.tsx @@ -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() {
+ +
diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 437bab7..e73c35b 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -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 diff --git a/web/src/utils/groupSeries.ts b/web/src/utils/groupSeries.ts index be6f08f..01c4e9c 100644 --- a/web/src/utils/groupSeries.ts +++ b/web/src/utils/groupSeries.ts @@ -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[] {