Fix local adult metadata and watch history removal

This commit is contained in:
ShukeBta
2026-05-28 14:52:20 +08:00
parent 352edd86ed
commit 32ccb33fed
95 changed files with 6589 additions and 1307 deletions
+16 -11
View File
@@ -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")
+1
View File
@@ -57,6 +57,7 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
service.ApplyRuntimeSetting(svc.Cfg, req.Key, req.Value)
c.Status(http.StatusNoContent)
}
}
+13 -3
View File
@@ -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,
})
}
}
+31 -31
View File
@@ -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
}
}
+2 -67
View File
@@ -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
+12
View File
@@ -9,6 +9,18 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func listDuplicatesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libraryID := c.Query("library_id")
report, err := svc.Duplicate.Current(c.Request.Context(), libraryID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, report)
}
}
func detectDuplicatesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libraryID := c.Query("library_id")
+153 -24
View File
@@ -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))
+73
View File
@@ -0,0 +1,73 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestParseEmbyAuthByNameReqAcceptsLowercaseJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/Users/AuthenticateByName", strings.NewReader(`{"username":"alice","password":"secret"}`))
c.Request.Header.Set("Content-Type", "application/json")
req, err := parseEmbyAuthByNameReq(c)
if err != nil {
t.Fatalf("parseEmbyAuthByNameReq returned error: %v", err)
}
if req.Username != "alice" || req.Password != "secret" {
t.Fatalf("unexpected request: %#v", req)
}
}
func TestParseEmbyAuthByNameReqAcceptsFormBody(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/Users/AuthenticateByName", strings.NewReader("Username=bob&Pw=secret"))
c.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req, err := parseEmbyAuthByNameReq(c)
if err != nil {
t.Fatalf("parseEmbyAuthByNameReq returned error: %v", err)
}
if req.Username != "bob" || req.Pw != "secret" {
t.Fatalf("unexpected request: %#v", req)
}
}
func TestEmbyWithRequestAddressUsesHost(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "http://192.168.1.4:18080/System/Info/Public", nil)
payload := embyWithRequestAddress(c, map[string]any{"Id": "mediastation-go-001"})
if payload["LocalAddress"] != "http://192.168.1.4:18080" {
t.Fatalf("unexpected LocalAddress: %#v", payload["LocalAddress"])
}
if payload["WanAddress"] != "http://192.168.1.4:18080" {
t.Fatalf("unexpected WanAddress: %#v", payload["WanAddress"])
}
}
func TestEmbyWithRequestAddressHonorsForwardedHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "http://127.0.0.1/System/Info/Public", nil)
c.Request.Header.Set("X-Forwarded-Proto", "https")
c.Request.Header.Set("X-Forwarded-Host", "media.example.test")
payload := embyWithRequestAddress(c, map[string]any{"Id": "mediastation-go-001"})
if payload["LocalAddress"] != "https://media.example.test" {
t.Fatalf("unexpected LocalAddress: %#v", payload["LocalAddress"])
}
}
+106 -105
View File
@@ -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.
+3 -11
View File
@@ -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"})
}
+7 -4
View File
@@ -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
+15 -9
View File
@@ -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()})
+3
View File
@@ -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})
}
}
+54 -34
View File
@@ -1,10 +1,11 @@
// Package middleware — Emby API 兼容层认证中间件。
// 支持 X-Emby-Token / Bearer / URL token / Username+Password 四种认证方式。
// 支持 X-Emby-Token / X-MediaBrowser-Token / Bearer / MediaBrowser / URL token。
package middleware
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
@@ -15,41 +16,13 @@ const EmbyCtxUserID = "emby_user_id"
// EmbyAuthRequired Emby 认证中间件。
// 按优先级尝试以下认证方式:
// 1. X-Emby-Token 请求头
// 2. Authorization: Bearer <token> 请求头
// 3. ?token=<token> URL 参数
// 4. (仅 AuthenticateByName 端点)POST body 中的 Username+Password
// 1. X-Emby-Token / X-MediaBrowser-Token 请求头
// 2. Authorization: Bearer <token> / MediaBrowser Token="<token>" 请求头
// 3. X-Emby-Authorization: MediaBrowser Token="<token>"
// 4. ?token= / ?api_key= / ?apiKey= URL 参数
func EmbyAuthRequired(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
token := ""
// 1. X-Emby-Token 头
if t := c.GetHeader("X-Emby-Token"); t != "" {
token = t
}
// 2. Authorization: Bearer <token> 或 Emby <token>
if token == "" {
if authHeader := c.GetHeader("Authorization"); authHeader != "" {
// Strip "Bearer " or "Emby " prefix
for _, prefix := range []string{"Bearer ", "Emby "} {
if len(authHeader) > len(prefix) && authHeader[:len(prefix)] == prefix {
token = authHeader[len(prefix):]
break
}
}
if token == "" {
token = authHeader
}
}
}
// 3. URL 参数 token
if token == "" {
if t := c.Query("token"); t != "" {
token = t
}
}
token := extractEmbyToken(c)
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{
@@ -86,6 +59,53 @@ func EmbyAuthRequired(secret string) gin.HandlerFunc {
}
}
func extractEmbyToken(c *gin.Context) string {
for _, header := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} {
if value := strings.TrimSpace(c.GetHeader(header)); value != "" {
return value
}
}
for _, header := range []string{"Authorization", "X-Emby-Authorization"} {
if value := strings.TrimSpace(c.GetHeader(header)); value != "" {
if token := tokenFromAuthHeader(value); token != "" {
return token
}
}
}
for _, key := range []string{"token", "api_key", "apiKey", "ApiKey"} {
if value := strings.TrimSpace(c.Query(key)); value != "" {
return value
}
}
return ""
}
func tokenFromAuthHeader(value string) string {
for _, prefix := range []string{"Bearer ", "Emby "} {
if strings.HasPrefix(value, prefix) {
return strings.TrimSpace(strings.TrimPrefix(value, prefix))
}
}
if strings.HasPrefix(value, "MediaBrowser ") || strings.Contains(value, "Token=") {
return tokenFromMediaBrowserAuth(value)
}
return value
}
func tokenFromMediaBrowserAuth(value string) string {
for _, part := range strings.Split(value, ",") {
part = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(part), "MediaBrowser "))
if !strings.HasPrefix(part, "Token=") {
continue
}
token := strings.TrimSpace(strings.TrimPrefix(part, "Token="))
return strings.Trim(token, `"`)
}
return ""
}
// GetEmbyUserID 从上下文中获取 Emby 用户 ID。
func GetEmbyUserID(c *gin.Context) string {
if uid, exists := c.Get(EmbyCtxUserID); exists {
+67
View File
@@ -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
}
+1 -1
View File
@@ -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
+9 -27
View File
@@ -3,38 +3,20 @@ package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ApiConfig 存储第三方 API 密钥和配置信息。
// APIKey 字段在 JSON 序列化时隐藏(json:"-"),通过加密存储。
type ApiConfig struct {
ID string `gorm:"primaryKey;size:36" json:"id"`
Provider string `gorm:"size:64;uniqueIndex;not null" json:"provider"`
APIKey string `gorm:"size:512" json:"-"`
BaseURL string `gorm:"size:512" json:"base_url,omitempty"`
Extra string `gorm:"type:text" json:"extra,omitempty"`
Enabled bool `gorm:"default:true" json:"enabled"`
Description string `gorm:"size:255" json:"description,omitempty"`
Base
Provider string `gorm:"size:64;uniqueIndex;not null" json:"provider"`
APIKey string `gorm:"size:512" json:"-"`
BaseURL string `gorm:"size:512" json:"base_url,omitempty"`
Extra string `gorm:"type:text" json:"extra,omitempty"`
Enabled bool `gorm:"default:true" json:"enabled"`
Description string `gorm:"size:255" json:"description,omitempty"`
LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
TestResult string `gorm:"size:32" json:"test_result,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// BeforeCreate 生成 UUID。
func (c *ApiConfig) BeforeCreate(_ *gorm.DB) error {
if c.ID == "" {
c.ID = uuid.NewString()
}
return nil
}
// BeforeUpdate 更新时间戳。
func (c *ApiConfig) BeforeUpdate(_ *gorm.DB) error {
c.UpdatedAt = time.Now()
return nil
TestResult string `gorm:"size:32" json:"test_result,omitempty"`
}
// ApiProvider 定义支持的 API 提供者列表。
@@ -57,6 +39,6 @@ func PredefinedProviders() []ApiProvider {
{ID: "openai", Name: "OpenAI", Description: "GPT 系列模型", HasAPIKey: true, HasBaseURL: true},
{ID: "deepseek", Name: "DeepSeek", Description: "DeepSeek 大模型", HasAPIKey: true, HasBaseURL: true},
{ID: "siliconflow", Name: "SiliconFlow", Description: "AI 模型聚合 API", HasAPIKey: true, HasBaseURL: true},
{ID: "adult", Name: "Adult API", Description: "Adult 内容元数据(需额外权限)", HasAPIKey: true, HasBaseURL: false},
{ID: "adult", Name: "Adult / 番号", Description: "JavDB/JavBus 成人内容元数据", HasAPIKey: false, HasBaseURL: true},
}
}
+26 -23
View File
@@ -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"`
}
+100 -60
View File
@@ -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
}
+355
View File
@@ -0,0 +1,355 @@
package service
import (
"context"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/url"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"go.uber.org/zap"
)
var (
adultFC2Pattern = regexp.MustCompile(`(?i)\bFC2[-_\s]?(?:PPV[-_\s]?)?(\d{5,8})\b`)
adultHEYZOPattern = regexp.MustCompile(`(?i)\bHEYZO[-_\s]?(\d{3,6})\b`)
adultUncensoredPattern = regexp.MustCompile(`(?i)\b(\d{6})[-_](\d{3,5})\b`)
adultStandardPattern = regexp.MustCompile(`(?i)(?:^|[^A-Z0-9])([A-Z]{2,10})[-_\s]?(\d{2,8})(?:[^A-Z0-9]|$)`)
adultTitlePattern = regexp.MustCompile(`(?is)<h[123][^>]*>(.*?)</h[123]>`)
adultTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
adultAnchorPattern = regexp.MustCompile(`(?is)<a\b([^>]*)>(.*?)</a>`)
adultImagePattern = regexp.MustCompile(`(?is)<img\b([^>]*)>`)
adultJavBusCoverPattern = regexp.MustCompile(`(?is)class="bigImage"[^>]*href="([^"]+)"`)
adultSamplePattern = regexp.MustCompile(`(?is)<a[^>]+class="[^"]*\bsample-box\b[^"]*"[^>]+href="([^"]+)"`)
adultAttrPattern = regexp.MustCompile(`(?is)([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*["']([^"']*)["']`)
)
var adultExcludedPrefixes = map[string]struct{}{
"AC": {}, "AAC": {}, "AVC": {}, "BD": {}, "CD": {}, "DDP": {}, "DTS": {},
"FHD": {}, "HD": {}, "HEVC": {}, "HDR": {}, "MP": {}, "SD": {}, "UHD": {},
"WEB": {}, "X264": {}, "X265": {},
}
type AdultProvider struct {
log *zap.Logger
client *http.Client
apiConfig *APIConfigService
}
func NewAdultProvider(log *zap.Logger, apiConfig *APIConfigService) *AdultProvider {
return &AdultProvider{
log: log,
apiConfig: apiConfig,
client: NewExternalHTTPClient(12 * time.Second),
}
}
func (p *AdultProvider) Enabled() bool {
return p != nil
}
func (p *AdultProvider) Search(ctx context.Context, code string) (*Match, error) {
code = normalizeAdultCode(code)
if code == "" {
return nil, errors.New("empty adult code")
}
bases := p.resolveBases(ctx)
if len(bases) == 0 {
return nil, nil
}
var lastErr error
for _, base := range bases {
base = strings.TrimRight(base, "/")
var match *Match
var err error
if strings.Contains(base, "javbus") {
match, err = p.scrapeJavBus(ctx, base, code)
} else {
match, err = p.scrapeJavDB(ctx, base, code)
}
if err != nil {
lastErr = err
if p.log != nil {
p.log.Debug("adult scrape source failed", zap.String("base", base), zap.String("code", code), zap.Error(err))
}
continue
}
if match != nil {
match.OriginalName = code
match.NSFW = true
return match, nil
}
}
return nil, lastErr
}
func (p *AdultProvider) resolveBases(ctx context.Context) []string {
out := []string{"https://javdb.com", "https://www.javbus.com"}
if p.apiConfig == nil {
return out
}
resolved, err := p.apiConfig.Resolve(ctx, "adult")
if err != nil {
return out
}
if !resolved.Enabled && (resolved.BaseURL != "" || resolved.Extra != "" || resolved.APIKey != "") {
return nil
}
if resolved.BaseURL != "" {
out = []string{resolved.BaseURL}
}
if resolved.Extra != "" {
for _, part := range strings.Split(resolved.Extra, ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "http://") || strings.HasPrefix(part, "https://") {
out = append(out, part)
}
}
}
return dedupeStrings(out)
}
func (p *AdultProvider) scrapeJavDB(ctx context.Context, base, code string) (*Match, error) {
searchURL := base + "/search?q=" + url.QueryEscape(code) + "&f=all"
body, err := p.fetchText(ctx, searchURL, base)
if err != nil {
return nil, err
}
detail := ""
for _, found := range adultAnchorPattern.FindAllStringSubmatch(body, -1) {
if len(found) < 3 {
continue
}
attrs := adultAttrs(found[1])
if !strings.Contains(" "+attrs["class"]+" ", " box ") || attrs["href"] == "" {
continue
}
if strings.Contains(strings.ToUpper(stripAdultHTML(found[2])), code) {
detail = absolutizeURL(base, attrs["href"])
break
}
}
if detail == "" {
return nil, nil
}
body, err = p.fetchText(ctx, detail, base)
if err != nil {
return nil, err
}
return parseAdultDetailHTML(body, code, "javdb", detail), nil
}
func (p *AdultProvider) scrapeJavBus(ctx context.Context, base, code string) (*Match, error) {
body, err := p.fetchText(ctx, base+"/"+url.PathEscape(code), base)
if err != nil {
return nil, err
}
return parseAdultDetailHTML(body, code, "javbus", base+"/"+url.PathEscape(code)), nil
}
func (p *AdultProvider) fetchText(ctx context.Context, targetURL, referer string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil {
return "", err
}
applyAdultHeaders(req, referer)
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return "", nil
}
if resp.StatusCode >= 400 {
return "", fmt.Errorf("adult source %s returned %d", targetURL, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return "", err
}
return string(body), nil
}
func parseAdultDetailHTML(body, code, source, detailURL string) *Match {
match := &Match{
OriginalName: code,
NSFW: true,
Genres: []string{"Adult", source},
}
if title := firstAdultTitle(body, code); title != "" {
match.Title = title
}
if match.Title == "" {
return nil
}
if source == "javbus" {
if m := adultJavBusCoverPattern.FindStringSubmatch(body); len(m) > 1 {
match.PosterURL = absolutizeURL(detailURL, m[1])
}
} else if cover := firstAdultImage(body, "video-cover", "cover", "column-video-cover"); cover != "" {
match.PosterURL = absolutizeURL(detailURL, cover)
}
if m := adultSamplePattern.FindStringSubmatch(body); len(m) > 1 {
match.BackdropURL = absolutizeURL(detailURL, m[1])
}
match.Year = firstYearInText(body)
match.Rating = firstRatingInText(body)
return match
}
func firstAdultTitle(body, code string) string {
for _, found := range adultTitlePattern.FindAllStringSubmatch(body, -1) {
if len(found) < 2 {
continue
}
title := strings.TrimSpace(stripAdultHTML(found[1]))
if title == "" {
continue
}
title = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(title, code), strings.ToUpper(code)))
if title != "" {
return title
}
}
return ""
}
func applyAdultHeaders(req *http.Request, referer string) {
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,ja;q=0.8,en;q=0.7")
if referer != "" {
req.Header.Set("Referer", referer)
}
}
func AdultCodeFromMediaPath(path string) string {
if code := normalizeAdultCode(filepath.Base(path)); code != "" {
return code
}
return normalizeAdultCode(path)
}
func normalizeAdultCode(input string) string {
input = strings.ToUpper(strings.TrimSpace(input))
if input == "" {
return ""
}
input = strings.ReplaceAll(input, "_", "-")
if m := adultFC2Pattern.FindStringSubmatch(input); len(m) > 1 {
return "FC2-PPV-" + m[1]
}
if m := adultHEYZOPattern.FindStringSubmatch(input); len(m) > 1 {
return "HEYZO-" + m[1]
}
if m := adultUncensoredPattern.FindStringSubmatch(input); len(m) > 2 {
return m[1] + "-" + m[2]
}
for _, m := range adultStandardPattern.FindAllStringSubmatch(input, -1) {
if len(m) < 3 {
continue
}
prefix := strings.TrimSpace(m[1])
if _, excluded := adultExcludedPrefixes[prefix]; excluded {
continue
}
return prefix + "-" + m[2]
}
return ""
}
func stripAdultHTML(value string) string {
value = adultTagPattern.ReplaceAllString(value, " ")
return strings.Join(strings.Fields(html.UnescapeString(value)), " ")
}
func firstAdultImage(body string, classNeedles ...string) string {
for _, found := range adultImagePattern.FindAllStringSubmatch(body, -1) {
if len(found) < 2 {
continue
}
attrs := adultAttrs(found[1])
class := strings.ToLower(attrs["class"])
for _, needle := range classNeedles {
if strings.Contains(class, strings.ToLower(needle)) {
if attrs["src"] != "" {
return attrs["src"]
}
if attrs["data-src"] != "" {
return attrs["data-src"]
}
}
}
}
return ""
}
func adultAttrs(raw string) map[string]string {
out := map[string]string{}
for _, found := range adultAttrPattern.FindAllStringSubmatch(raw, -1) {
if len(found) >= 3 {
out[strings.ToLower(found[1])] = html.UnescapeString(found[2])
}
}
return out
}
func absolutizeURL(base, raw string) string {
raw = strings.TrimSpace(html.UnescapeString(raw))
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err == nil && u.IsAbs() {
return raw
}
b, err := url.Parse(base)
if err != nil {
return raw
}
return b.ResolveReference(u).String()
}
func firstYearInText(body string) int {
m := regexp.MustCompile(`(?:19|20)\d{2}[-/.]\d{1,2}[-/.]\d{1,2}`).FindString(body)
if len(m) >= 4 {
year, _ := strconv.Atoi(m[:4])
return year
}
return 0
}
func firstRatingInText(body string) float32 {
m := regexp.MustCompile(`(?i)(?:score|rating|評分|评分)[^0-9]{0,20}([0-9](?:\.[0-9])?)`).FindStringSubmatch(body)
if len(m) > 1 {
v, _ := strconv.ParseFloat(m[1], 32)
return float32(v)
}
return 0
}
func dedupeStrings(values []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
key := strings.ToLower(value)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, value)
}
return out
}
+42
View File
@@ -0,0 +1,42 @@
package service
import "testing"
func TestNormalizeAdultCode(t *testing.T) {
cases := map[string]string{
"SSIS001.mp4": "SSIS-001",
"fc2-ppv-1234567.mkv": "FC2-PPV-1234567",
"heyzo_1234.mp4": "HEYZO-1234",
"120118_001-carib.mp4": "120118-001",
"movie.1080p.x264.mkv": "",
}
for in, want := range cases {
if got := normalizeAdultCode(in); got != want {
t.Fatalf("normalizeAdultCode(%q) = %q, want %q", in, got, want)
}
}
}
func TestParseAdultDetailHTML(t *testing.T) {
html := `<html>
<h2 class="title"><strong>SSIS-001 测试标题</strong></h2>
<img class="video-cover" src="/covers/ssis001.jpg">
<a class="sample-box" href="/samples/1.jpg"></a>
<span class="score"><span class="value">4.7</span></span>
<div>日期 2024-05-01</div>
</html>`
got := parseAdultDetailHTML(html, "SSIS-001", "javdb", "https://javdb.com/v/abc")
if got == nil {
t.Fatal("parseAdultDetailHTML returned nil")
}
if got.Title != "测试标题" || got.OriginalName != "SSIS-001" || !got.NSFW {
t.Fatalf("unexpected metadata: %+v", got)
}
if got.PosterURL != "https://javdb.com/covers/ssis001.jpg" || got.BackdropURL != "https://javdb.com/samples/1.jpg" {
t.Fatalf("unexpected artwork: %+v", got)
}
if got.Year != 2024 {
t.Fatalf("year = %d, want 2024", got.Year)
}
}
+15 -14
View File
@@ -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
+14 -15
View File
@@ -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()
+1 -1
View File
@@ -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),
}
}
+194 -1
View File
@@ -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
}
+12 -5
View File
@@ -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
}
+80 -20
View File
@@ -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) {
+70 -5
View File
@@ -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.
+43 -10
View File
@@ -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
+7 -2
View File
@@ -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 {
+137
View File
@@ -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
}
+24
View File
@@ -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))
}
}
+5 -5
View File
@@ -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)
+15 -17
View File
@@ -20,24 +20,22 @@ import (
// AutoInstallFFmpeg 在启动时检测并自动安装 ffmpeg/ffprobe
func AutoInstallFFmpeg(log *zap.Logger, cfg *config.Config) (ffprobePath, ffmpegPath string) {
// 1. 检查配置中是否已指定路径
if cfg.App.FFprobePath != "" {
if _, err := os.Stat(cfg.App.FFprobePath); err == nil {
log.Info("使用配置的 ffprobe", zap.String("path", cfg.App.FFprobePath))
return cfg.App.FFprobePath, cfg.App.FFmpegPath
}
// 1. 优先使用配置 / PATH / 本机常见软件目录中的现有工具。
if path, err := resolveLocalExecutable(cfg.App.FFprobePath, "ffprobe"); err == nil {
ffprobePath = path
cfg.App.FFprobePath = path
log.Info("found local ffprobe", zap.String("path", path))
}
if path, err := resolveLocalExecutable(cfg.App.FFmpegPath, "ffmpeg"); err == nil {
ffmpegPath = path
cfg.App.FFmpegPath = path
log.Info("found local ffmpeg", zap.String("path", path))
}
if ffprobePath != "" || ffmpegPath != "" {
return ffprobePath, ffmpegPath
}
// 2. 检查系统 PATH
if path, err := exec.LookPath("ffprobe"); err == nil {
log.Info("在 PATH 中找到 ffprobe", zap.String("path", path))
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
return path, ffmpegPath
}
return path, ""
}
// 3. 检查默认安装位置
// 2. 检查默认安装位置。
defaultDir := getDefaultInstallDir()
ffprobeDefault := filepath.Join(defaultDir, "bin", "ffprobe.exe")
ffmpegDefault := filepath.Join(defaultDir, "bin", "ffmpeg.exe")
@@ -47,7 +45,7 @@ func AutoInstallFFmpeg(log *zap.Logger, cfg *config.Config) (ffprobePath, ffmpeg
return ffprobeDefault, ffmpegDefault
}
// 4. 尝试自动安装
// 3. 尝试自动安装。
log.Warn("未找到 ffmpeg/ffprobe,尝试自动安装...")
installed, err := tryAutoInstall(log, defaultDir)
if err != nil {
+70 -17
View File
@@ -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
}
+24
View File
@@ -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)
}
}
+44 -10
View File
@@ -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 {
+38
View File
@@ -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))
}
}
+553
View File
@@ -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))
}
+362
View File
@@ -0,0 +1,362 @@
package service
import (
"os"
"path/filepath"
"testing"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func TestReadLocalMovieMetadata(t *testing.T) {
dir := t.TempDir()
mediaPath := filepath.Join(dir, "Inception.2010.mkv")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
nfo := `<?xml version="1.0" encoding="UTF-8"?>
<movie>
<title>盗梦空间</title>
<originaltitle>Inception</originaltitle>
<year>2010</year>
<plot>梦境盗窃。</plot>
<rating>8.8</rating>
<uniqueid type="tmdb">27205</uniqueid>
<genre>科幻</genre>
<genre>动作</genre>
</movie>`
if err := os.WriteFile(nfoPath(mediaPath), []byte(nfo), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadLocalMetadata(mediaPath, dir, false)
if err != nil {
t.Fatal(err)
}
if got == nil || got.Title != "盗梦空间" || got.OriginalName != "Inception" || got.Year != 2010 || got.TMDbID != 27205 {
t.Fatalf("unexpected metadata: %+v", got)
}
if got.Genres != "科幻,动作" {
t.Fatalf("genres = %q", got.Genres)
}
}
func TestReadLocalEpisodeMetadataMergesShowAndEpisode(t *testing.T) {
root := t.TempDir()
showDir := filepath.Join(root, "Show")
seasonDir := filepath.Join(showDir, "Season 02")
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(seasonDir, "Show - EP03.mkv")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(`<tvshow><title>正确剧名</title><year>2024</year><tmdbid>123</tmdbid></tvshow>`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<episodedetails><title>第三集</title><season>2</season><episode>3</episode><plot>本集简介</plot></episodedetails>`), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadLocalMetadata(mediaPath, root, true)
if err != nil {
t.Fatal(err)
}
if got == nil || got.Title != "正确剧名" || got.OriginalName != "第三集" || got.SeasonNum != 2 || got.EpisodeNum != 3 {
t.Fatalf("unexpected metadata: %+v", got)
}
if got.Overview != "本集简介" || got.TMDbID != 123 {
t.Fatalf("episode/show merge failed: %+v", got)
}
}
func TestReadLocalVarietyMetadataUsesLocalArtwork(t *testing.T) {
root := t.TempDir()
showDir := filepath.Join(root, "哈哈哈哈哈")
seasonDir := filepath.Join(showDir, "Season 06")
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(seasonDir, "哈哈哈哈哈 - S06E17.mkv")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(showDir, "哈哈哈哈哈.nfo"), []byte(`<tvshow><title>哈哈哈哈哈</title><genre>综艺</genre></tvshow>`), 0o644); err != nil {
t.Fatal(err)
}
episodeThumb := filepath.Join(seasonDir, "哈哈哈哈哈 - S06E17-thumb.jpg")
if err := os.WriteFile(episodeThumb, []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
backdrop := filepath.Join(showDir, "fanart.jpg")
if err := os.WriteFile(backdrop, []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadLocalMetadata(mediaPath, root, true)
if err != nil {
t.Fatal(err)
}
if got == nil {
t.Fatal("metadata is nil")
}
if got.Title != "哈哈哈哈哈" || got.Genres != "综艺" {
t.Fatalf("unexpected metadata: %+v", got)
}
if got.PosterURL != episodeThumb {
t.Fatalf("PosterURL = %q, want %q", got.PosterURL, episodeThumb)
}
if got.BackdropURL != backdrop {
t.Fatalf("BackdropURL = %q, want %q", got.BackdropURL, backdrop)
}
}
func TestReadLocalMetadataWithoutNFOStillFindsArtwork(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "Movie.mkv")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
poster := filepath.Join(root, "Movie-poster.jpg")
if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadLocalMetadata(mediaPath, root, false)
if err != nil {
t.Fatal(err)
}
if got == nil || got.PosterURL != poster {
t.Fatalf("unexpected artwork metadata: %+v", got)
}
}
func TestReadAdultLocalMetadataAndArtwork(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "SSIS-001")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(dir, "SSIS-001.mp4")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
nfo := `<?xml version="1.0" encoding="UTF-8"?>
<movie>
<title>成人影片标题</title>
<originaltitle>SSIS-001</originaltitle>
<num>SSIS-001</num>
<releasedate>2024-05-01</releasedate>
<plot>本地简介</plot>
<poster>SSIS-001-poster.jpg</poster>
<fanart><thumb>SSIS-001-fanart.jpg</thumb></fanart>
<studio>测试片商</studio>
<genre>剧情</genre>
<tag>中文字幕</tag>
<actor><name>演员A</name></actor>
</movie>`
if err := os.WriteFile(nfoPath(mediaPath), []byte(nfo), 0o644); err != nil {
t.Fatal(err)
}
poster := filepath.Join(dir, "SSIS-001-poster.jpg")
if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
fanart := filepath.Join(dir, "SSIS-001-fanart.jpg")
if err := os.WriteFile(fanart, []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadLocalMetadata(mediaPath, root, false)
if err != nil {
t.Fatal(err)
}
if got == nil || got.Title != "成人影片标题" || got.AdultCode != "SSIS-001" || !got.NSFW {
t.Fatalf("unexpected adult metadata: %+v", got)
}
if got.OriginalName != "SSIS-001" || got.Year != 2024 || got.Overview != "本地简介" {
t.Fatalf("unexpected adult fields: %+v", got)
}
if got.PosterURL != poster || got.BackdropURL != fanart {
t.Fatalf("artwork poster=%q fanart=%q", got.PosterURL, got.BackdropURL)
}
if got.Genres != "剧情,中文字幕,测试片商,演员A" {
t.Fatalf("genres = %q", got.Genres)
}
}
func TestReadAdultMovieNFOFallbackInSingleMovieFolder(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "MIDV-123")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(dir, "video.mp4")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "movie.nfo"), []byte(`<movie><title>本地番号电影</title><num>MIDV-123</num></movie>`), 0o644); err != nil {
t.Fatal(err)
}
poster := filepath.Join(dir, "video-cover.jpg")
if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadLocalMetadata(mediaPath, root, false)
if err != nil {
t.Fatal(err)
}
if got == nil || got.Title != "本地番号电影" || got.AdultCode != "MIDV-123" || got.PosterURL != poster {
t.Fatalf("unexpected fallback metadata: %+v", got)
}
}
func TestScanLibraryUsesLocalMetadata(t *testing.T) {
root := t.TempDir()
seasonDir := filepath.Join(root, "Show", "Season 02")
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(seasonDir, "Show - EP03.mkv")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Show", "tvshow.nfo"), []byte(`<tvshow><title>本地剧名</title><year>2025</year></tvshow>`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<episodedetails><title>本地第三集</title><season>2</season><episode>3</episode></episodedetails>`), 0o644); err != nil {
t.Fatal(err)
}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
lib := model.Library{Name: "TV", Path: root, Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
res, err := scanner.ScanLibrary(t.Context(), lib.ID)
if err != nil {
t.Fatal(err)
}
if res.LocalMetadata != 1 {
t.Fatalf("LocalMetadata = %d, want 1", res.LocalMetadata)
}
if res.Added != 1 || res.Updated != 0 {
t.Fatalf("scan counts added=%d updated=%d, want 1/0", res.Added, res.Updated)
}
var media model.Media
if err := db.First(&media, "path = ?", mediaPath).Error; err != nil {
t.Fatal(err)
}
if media.Title != "本地剧名" || media.OriginalName != "本地第三集" || media.SeasonNum != 2 || media.EpisodeNum != 3 || media.ScrapeStatus != "matched" {
t.Fatalf("unexpected scanned media: %+v", media)
}
res, err = scanner.ScanLibrary(t.Context(), lib.ID)
if err != nil {
t.Fatal(err)
}
if res.Added != 0 || res.Updated != 1 {
t.Fatalf("repeat scan counts added=%d updated=%d, want 0/1", res.Added, res.Updated)
}
}
func TestScanLibraryParsesEpisodesForMovieTypedLibrary(t *testing.T) {
root := t.TempDir()
seasonDir := filepath.Join(root, "哈哈哈哈哈 (2020)", "Season 06")
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(seasonDir, "哈哈哈哈哈 - S06E17 - 第 17 集.mkv")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
lib := model.Library{Name: "综艺", Path: root, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
if _, err := scanner.ScanLibrary(t.Context(), lib.ID); err != nil {
t.Fatal(err)
}
var media model.Media
if err := db.First(&media, "path = ?", mediaPath).Error; err != nil {
t.Fatal(err)
}
if media.SeasonNum != 6 || media.EpisodeNum != 17 {
t.Fatalf("season/episode = %d/%d, want 6/17", media.SeasonNum, media.EpisodeNum)
}
}
func TestScanLibraryPrunesMissingMedia(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "Show.S02E03.mkv")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
lib := model.Library{Name: "TV", Path: root, Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
stale := model.Media{
LibraryID: lib.ID,
Title: "Show",
Path: filepath.Join(root, "old", "Show.S02E03.mkv"),
SizeBytes: 123,
ScrapeStatus: "pending",
}
if err := db.Create(&stale).Error; err != nil {
t.Fatal(err)
}
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
res, err := scanner.ScanLibrary(t.Context(), lib.ID)
if err != nil {
t.Fatal(err)
}
if res.Removed != 1 {
t.Fatalf("Removed = %d, want 1", res.Removed)
}
var count int64
if err := db.Model(&model.Media{}).Where("library_id = ?", lib.ID).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("media count = %d, want 1", count)
}
}
+4 -1
View File
@@ -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
}
+236
View File
@@ -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
}
+98
View File
@@ -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)
}
}
+119
View File
@@ -0,0 +1,119 @@
package service
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
func executableCandidates(configured, name string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, 12)
add := func(path string) {
path = strings.Trim(path, `" `)
if path == "" {
return
}
if !strings.EqualFold(filepath.Ext(path), ".exe") && runtime.GOOS == "windows" && !strings.ContainsAny(path, `\/`) {
path += ".exe"
}
if resolved, err := exec.LookPath(path); err == nil {
path = resolved
}
if stat, err := os.Stat(path); err != nil || stat.IsDir() {
return
}
clean, err := filepath.Abs(path)
if err == nil {
path = clean
}
key := strings.ToLower(path)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
out = append(out, path)
}
if configured != "" {
add(configured)
}
add(name)
for _, path := range localExecutableCandidates(name) {
add(path)
}
return out
}
func resolveLocalExecutable(configured, name string) (string, error) {
for _, path := range executableCandidates(configured, name) {
return path, nil
}
return "", fmt.Errorf("%s not found in PATH or common local app directories", name)
}
func localExecutableCandidates(name string) []string {
exe := name
if runtime.GOOS == "windows" && !strings.HasSuffix(strings.ToLower(exe), ".exe") {
exe += ".exe"
}
out := make([]string, 0, 24)
add := func(path string) {
if path != "" {
out = append(out, path)
}
}
addGlob := func(pattern string) {
matches, _ := filepath.Glob(pattern)
out = append(out, matches...)
}
if wd, err := os.Getwd(); err == nil {
add(filepath.Join(wd, "tools", "ffmpeg", "bin", exe))
add(filepath.Join(wd, "bin", exe))
}
if exePath, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exePath)
add(filepath.Join(exeDir, "tools", "ffmpeg", "bin", exe))
add(filepath.Join(exeDir, exe))
}
appData := os.Getenv("APPDATA")
localAppData := os.Getenv("LOCALAPPDATA")
userProfile := os.Getenv("USERPROFILE")
programData := os.Getenv("ProgramData")
programFiles := os.Getenv("ProgramFiles")
programFilesX86 := os.Getenv("ProgramFiles(x86)")
add(filepath.Join(appData, "bilibili", "ffmpeg", exe))
add(filepath.Join(userProfile, "scoop", "shims", exe))
add(filepath.Join(userProfile, "scoop", "apps", "ffmpeg", "current", "bin", exe))
add(filepath.Join(programData, "chocolatey", "bin", exe))
add(filepath.Join("C:\\", "ffmpeg", "bin", exe))
add(filepath.Join("C:\\", "tools", "ffmpeg", "bin", exe))
add(filepath.Join(programFiles, "ffmpeg", "bin", exe))
add(filepath.Join(programFilesX86, "ffmpeg", "bin", exe))
addGlob(filepath.Join(localAppData, "JianyingPro", "Apps", "*", exe))
addGlob(filepath.Join(localAppData, "Programs", "ffmpeg", "bin", exe))
addGlob(filepath.Join(localAppData, "*", "ffmpeg", "bin", exe))
return out
}
func commandOutput(ctx context.Context, timeout time.Duration, name string, args ...string) ([]byte, error) {
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(cmdCtx, name, args...)
out, err := cmd.CombinedOutput()
if cmdCtx.Err() != nil {
return out, cmdCtx.Err()
}
return out, err
}
+89 -17
View File
@@ -22,6 +22,7 @@ import (
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
@@ -47,6 +48,26 @@ type movieNFO struct {
Poster string `xml:"thumb,omitempty"`
Fanart string `xml:"fanart,omitempty"`
TMDb int `xml:"tmdbid,omitempty"`
Genre []string `xml:"genre,omitempty"`
Country []string `xml:"country,omitempty"`
Language []string `xml:"language,omitempty"`
}
type episodeNFO struct {
XMLName xml.Name `xml:"episodedetails"`
Title string `xml:"title"`
ShowTitle string `xml:"showtitle,omitempty"`
Season int `xml:"season,omitempty"`
Episode int `xml:"episode,omitempty"`
Year int `xml:"year,omitempty"`
Plot string `xml:"plot,omitempty"`
Rating float32 `xml:"rating,omitempty"`
Poster string `xml:"thumb,omitempty"`
Fanart string `xml:"fanart,omitempty"`
TMDb int `xml:"tmdbid,omitempty"`
Genre []string `xml:"genre,omitempty"`
Country []string `xml:"country,omitempty"`
Language []string `xml:"language,omitempty"`
}
// ExportOne writes a movie.nfo file next to the media file. Existing files
@@ -63,26 +84,10 @@ func (s *NFOService) ExportOne(ctx context.Context, mediaID string) (string, err
return "", errors.New("media has empty path")
}
doc := movieNFO{
Title: m.Title,
Original: m.OriginalName,
Year: m.Year,
Plot: m.Overview,
Rating: m.Rating,
Poster: m.PosterURL,
Fanart: m.BackdropURL,
TMDb: m.TMDbID,
}
out, err := xml.MarshalIndent(doc, "", " ")
dst, err := WriteMediaNFO(m)
if err != nil {
return "", err
}
body := []byte(xml.Header + string(out) + "\n")
dst := nfoPath(m.Path)
if err := os.WriteFile(dst, body, 0o644); err != nil {
return "", err
}
s.log.Info("nfo exported", zap.String("media_id", m.ID), zap.String("path", dst))
return dst, nil
}
@@ -113,3 +118,70 @@ func nfoPath(media string) string {
base := strings.TrimSuffix(filepath.Base(media), filepath.Ext(media))
return filepath.Join(dir, fmt.Sprintf("%s.nfo", base))
}
func WriteMediaNFO(m *model.Media) (string, error) {
if m == nil {
return "", errors.New("media not found")
}
if m.Path == "" {
return "", errors.New("media has empty path")
}
var doc any
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
title := m.OriginalName
if title == "" {
title = m.Title
}
doc = episodeNFO{
Title: title,
ShowTitle: m.Title,
Season: m.SeasonNum,
Episode: m.EpisodeNum,
Year: m.Year,
Plot: m.Overview,
Rating: m.Rating,
Poster: m.PosterURL,
Fanart: m.BackdropURL,
TMDb: m.TMDbID,
Genre: splitNFOList(m.Genres),
Country: splitNFOList(m.Countries),
Language: splitNFOList(m.Languages),
}
} else {
doc = movieNFO{
Title: m.Title,
Original: m.OriginalName,
Year: m.Year,
Plot: m.Overview,
Rating: m.Rating,
Poster: m.PosterURL,
Fanart: m.BackdropURL,
TMDb: m.TMDbID,
Genre: splitNFOList(m.Genres),
Country: splitNFOList(m.Countries),
Language: splitNFOList(m.Languages),
}
}
out, err := xml.MarshalIndent(doc, "", " ")
if err != nil {
return "", err
}
dst := nfoPath(m.Path)
if err := os.WriteFile(dst, []byte(xml.Header+string(out)+"\n"), 0o644); err != nil {
return "", err
}
return dst, nil
}
func splitNFOList(value string) []string {
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
+72 -145
View File
@@ -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.
+123
View File
@@ -0,0 +1,123 @@
package service
import (
"os"
"path/filepath"
"testing"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func TestOrganizeMediaReDetectsSeasonFromPath(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "Incoming", "Some Show", "Season 02")
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
t.Fatal(err)
}
source := filepath.Join(sourceDir, "Some Show - EP03.mkv")
if err := os.WriteFile(source, []byte("episode"), 0o644); err != nil {
t.Fatal(err)
}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
lib := model.Library{Name: "TV", Path: root, Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "Some Show",
Path: source,
Container: "mkv",
SeasonNum: 1,
EpisodeNum: 3,
ScrapeStatus: "matched",
}
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
t.Fatal(err)
}
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
dst, err := organizer.OrganizeMedia(t.Context(), media.ID)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(root, "Some Show", "Season 02", "Some Show - S02E03.mkv")
if dst != want {
t.Fatalf("dst = %q, want %q", dst, want)
}
if _, err := os.Stat(want); err != nil {
t.Fatalf("organized file missing: %v", err)
}
var refreshed model.Media
if err := db.First(&refreshed, "id = ?", media.ID).Error; err != nil {
t.Fatal(err)
}
if refreshed.SeasonNum != 2 || refreshed.EpisodeNum != 3 || refreshed.Path != want {
t.Fatalf("unexpected refreshed media: %+v", refreshed)
}
}
func TestOrganizeMediaUsesEpisodeNFOSeason(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "Incoming", "Some Show")
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
t.Fatal(err)
}
source := filepath.Join(sourceDir, "Some Show - EP03.mkv")
if err := os.WriteFile(source, []byte("episode"), 0o644); err != nil {
t.Fatal(err)
}
nfo := `<episodedetails><title>第三集</title><season>2</season><episode>3</episode></episodedetails>`
if err := os.WriteFile(nfoPath(source), []byte(nfo), 0o644); err != nil {
t.Fatal(err)
}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
lib := model.Library{Name: "TV", Path: root, Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "Some Show",
Path: source,
Container: "mkv",
SeasonNum: 1,
EpisodeNum: 3,
ScrapeStatus: "matched",
}
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
t.Fatal(err)
}
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
dst, err := organizer.OrganizeMedia(t.Context(), media.ID)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(root, "Some Show", "Season 02", "Some Show - S02E03.mkv")
if dst != want {
t.Fatalf("dst = %q, want %q", dst, want)
}
}
+97
View File
@@ -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)
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
package service
import (
"net/http"
"net/url"
)
func systemProxyForRequest(_ *http.Request) (*url.URL, error) {
return nil, nil
}
+29
View File
@@ -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)
}
})
}
}
+73
View File
@@ -0,0 +1,73 @@
//go:build windows
package service
import (
"net"
"net/http"
"net/url"
"path/filepath"
"strings"
"golang.org/x/sys/windows/registry"
)
const windowsInternetSettingsKey = `Software\Microsoft\Windows\CurrentVersion\Internet Settings`
func systemProxyForRequest(req *http.Request) (*url.URL, error) {
if req == nil || req.URL == nil {
return nil, nil
}
key, err := registry.OpenKey(registry.CURRENT_USER, windowsInternetSettingsKey, registry.QUERY_VALUE)
if err != nil {
return nil, nil
}
defer key.Close()
enabled, _, err := key.GetIntegerValue("ProxyEnable")
if err != nil || enabled == 0 {
return nil, nil
}
proxyServer, _, err := key.GetStringValue("ProxyServer")
if err != nil || strings.TrimSpace(proxyServer) == "" {
return nil, nil
}
if proxyOverride, _, err := key.GetStringValue("ProxyOverride"); err == nil {
if windowsProxyBypass(req.URL.Hostname(), proxyOverride) {
return nil, nil
}
}
return proxyURLFromProxyServer(proxyServer, req.URL.Scheme)
}
func windowsProxyBypass(host, override string) bool {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return false
}
if parsed := net.ParseIP(host); parsed != nil && parsed.IsLoopback() {
return true
}
for _, rule := range strings.Split(override, ";") {
rule = strings.ToLower(strings.TrimSpace(rule))
if rule == "" {
continue
}
if rule == "<local>" && !strings.Contains(host, ".") {
return true
}
if ok, _ := filepath.Match(rule, host); ok {
return true
}
if strings.HasPrefix(rule, "*.") && strings.HasSuffix(host, strings.TrimPrefix(rule, "*")) {
return true
}
if host == rule {
return true
}
}
return false
}
+234 -4
View File
@@ -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)
+151
View File
@@ -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
}
}
}
+54
View File
@@ -0,0 +1,54 @@
package service
import (
"context"
"strconv"
"strings"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func ApplyRuntimeSettings(ctx context.Context, cfg *config.Config, repos *repository.Container, log *zap.Logger) {
if cfg == nil || repos == nil || repos.Setting == nil {
return
}
rows, err := repos.Setting.All(ctx)
if err != nil {
if log != nil {
log.Warn("load runtime settings failed", zap.Error(err))
}
return
}
for _, row := range rows {
ApplyRuntimeSetting(cfg, row.Key, row.Value)
}
}
func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
if cfg == nil {
return
}
value = strings.TrimSpace(value)
switch key {
case "ffmpeg.path", "app.ffmpeg_path":
cfg.App.FFmpegPath = value
case "ffprobe.path", "app.ffprobe_path":
cfg.App.FFprobePath = value
case "transcode.hw_accel", "transcoder.encoder":
switch value {
case "", "auto", "none", "software":
cfg.Transcoder.Encoder = ""
case "nvenc", "qsv", "vaapi":
cfg.Transcoder.Encoder = value
}
case "transcode.max_height", "transcoder.max_height":
if n, err := strconv.Atoi(value); err == nil {
cfg.Transcoder.MaxHeight = n
}
case "transcode.video_bitrate", "transcoder.video_bitrate":
cfg.Transcoder.VideoBitrate = value
}
}
+164 -17
View File
@@ -5,14 +5,14 @@
// ffprobe (when available) and queues a metadata lookup for newly added
// rows.
//
// For TV / anime libraries we extract season + episode numbers from the
// filename via ParseEpisode and store them on the Media row. A future
// pass groups episodes into Series rows; the current scaffold lets the
// frontend group by `series_id`.
// When a filename exposes season + episode numbers we store them on the
// Media row for every library type, so variety shows and other episodic
// collections get the same grouping experience as TV/anime.
package service
import (
"context"
"os"
"path/filepath"
"strings"
@@ -68,10 +68,13 @@ func NewScannerService(
// ScanResult summarises a scan run.
type ScanResult struct {
LibraryID string `json:"library_id"`
Visited int `json:"visited"`
Added int `json:"added"`
Probed int `json:"probed"`
LibraryID string `json:"library_id"`
Visited int `json:"visited"`
Added int `json:"added"`
Updated int `json:"updated"`
Probed int `json:"probed"`
LocalMetadata int `json:"local_metadata"`
Removed int64 `json:"removed"`
}
// ScanLibrary walks the library root and persists discovered media files.
@@ -81,6 +84,7 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
return nil, err
}
res := &ScanResult{LibraryID: lib.ID}
seen := make(map[string]struct{})
walkFn := func(path string, info walkInfo) error {
if info.isDir {
@@ -91,6 +95,8 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
return nil
}
res.Visited++
seen[filepath.Clean(path)] = struct{}{}
isNewMedia := !s.mediaPathExists(ctx, path)
title, year := CleanQuery(path)
if title == "" {
@@ -106,11 +112,15 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
Container: strings.TrimPrefix(ext, "."),
}
// Detect season/episode for TV / anime libraries.
if lib.Type == "tv" || lib.Type == "anime" {
s, e := ParseEpisode(path)
m.SeasonNum = s
m.EpisodeNum = e
parsedSeason, parsedEpisode := ParseEpisode(path)
m.SeasonNum = parsedSeason
m.EpisodeNum = parsedEpisode
if local, err := ReadLocalMetadata(path, lib.Path, librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0); err == nil && local != nil {
applyLocalMetadata(m, local)
res.LocalMetadata++
} else if err != nil {
s.log.Warn("read local metadata failed", zap.String("path", path), zap.Error(err))
}
// Best-effort ffprobe; failure does not abort the file.
@@ -134,13 +144,19 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
s.log.Warn("upsert media failed", zap.String("path", path), zap.Error(err))
return nil
}
res.Added++
if isNewMedia {
res.Added++
} else {
res.Updated++
}
s.hub.Publish("scan", map[string]any{
"library_id": lib.ID,
"path": path,
"visited": res.Visited,
"added": res.Added,
"updated": res.Updated,
"probed": res.Probed,
"local_meta": res.LocalMetadata,
})
return nil
}
@@ -148,18 +164,27 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
if err := walk(lib.Path, walkFn); err != nil {
return res, err
}
removed, err := s.pruneMissingMedia(ctx, lib.ID, seen)
if err != nil {
s.log.Warn("prune missing media failed", zap.String("library_id", lib.ID), zap.Error(err))
} else {
res.Removed = removed
}
s.hub.Publish("scan", map[string]any{
"library_id": lib.ID,
"finished": true,
"visited": res.Visited,
"added": res.Added,
"updated": res.Updated,
"probed": res.Probed,
"local_meta": res.LocalMetadata,
"removed": res.Removed,
})
// Fire-and-forget metadata enrichment when at least one provider is
// configured.
if s.scraper != nil && s.scraper.AnyEnabled() {
// Online enrichment is opt-in. Local NFO is always consumed first during
// the scan, and matched rows are excluded from EnrichLibrary's pending set.
if s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) {
go func(libID string) {
if _, err := s.scraper.EnrichLibrary(context.Background(), libID); err != nil {
s.log.Warn("scraper enrich failed", zap.Error(err))
@@ -168,3 +193,125 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
}
return res, nil
}
func (s *ScannerService) mediaPathExists(ctx context.Context, path string) bool {
var count int64
err := s.repo.DB.WithContext(ctx).Unscoped().Model(&model.Media{}).
Where("path = ?", path).Count(&count).Error
return err == nil && count > 0
}
func (s *ScannerService) pruneMissingMedia(ctx context.Context, libraryID string, seen map[string]struct{}) (int64, error) {
var rows []model.Media
if err := s.repo.DB.WithContext(ctx).
Where("library_id = ?", libraryID).
Find(&rows).Error; err != nil {
return 0, err
}
var removed int64
for _, row := range rows {
if row.Path == "" {
continue
}
if _, ok := seen[filepath.Clean(row.Path)]; ok {
continue
}
if _, err := os.Stat(row.Path); err == nil {
continue
} else if !os.IsNotExist(err) {
continue
}
res := s.repo.DB.WithContext(ctx).
Where("id = ?", row.ID).
Delete(&model.Media{})
if res.Error != nil {
return removed, res.Error
}
removed += res.RowsAffected
}
return removed, nil
}
func applyLocalMetadata(m *model.Media, local *LocalMetadata) {
if local.Title != "" {
m.Title = local.Title
}
if local.OriginalName != "" {
m.OriginalName = local.OriginalName
}
if local.AdultCode != "" {
m.OriginalName = local.AdultCode
}
if local.Year > 0 {
m.Year = local.Year
}
if local.Overview != "" {
m.Overview = local.Overview
}
if local.Rating > 0 {
m.Rating = local.Rating
}
if local.PosterURL != "" {
m.PosterURL = local.PosterURL
}
if local.BackdropURL != "" {
m.BackdropURL = local.BackdropURL
}
if local.TMDbID > 0 {
m.TMDbID = local.TMDbID
}
if local.SeasonNum > 0 {
m.SeasonNum = local.SeasonNum
}
if local.EpisodeNum > 0 {
m.EpisodeNum = local.EpisodeNum
}
if local.Genres != "" {
m.Genres = local.Genres
}
if local.Countries != "" {
m.Countries = local.Countries
}
if local.Languages != "" {
m.Languages = local.Languages
}
if local.NSFW {
m.NSFW = true
}
if local.HasNFO || localHasDescriptiveMetadata(local) {
m.ScrapeStatus = "matched"
}
}
func localHasDescriptiveMetadata(local *LocalMetadata) bool {
if local == nil {
return false
}
return local.Title != "" ||
local.OriginalName != "" ||
local.AdultCode != "" ||
local.Year > 0 ||
local.Overview != "" ||
local.Rating > 0 ||
local.TMDbID > 0 ||
local.Genres != "" ||
local.Countries != "" ||
local.Languages != ""
}
func (s *ScannerService) autoScrapeEnabled(ctx context.Context) bool {
if s.repo == nil || s.repo.Setting == nil {
return false
}
value, err := s.repo.Setting.Get(ctx, "scrape.auto_on_scan")
if err != nil {
s.log.Warn("read scrape.auto_on_scan failed", zap.Error(err))
return false
}
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on", "enabled":
return true
default:
return false
}
}
+360 -21
View File
@@ -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"
}
}
+230 -1
View File
@@ -1,6 +1,22 @@
package service
import "testing"
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func TestCleanQuery(t *testing.T) {
cases := []struct {
@@ -13,6 +29,7 @@ func TestCleanQuery(t *testing.T) {
{"interstellar.2014.4k.hdr.dts.atmos.mkv", "interstellar", 2014},
{"My Movie 2022 [HDR] (1080p) [TGx].mp4", "my movie", 2022},
{"NoYearOrTags.mkv", "noyearortags", 0},
{"亏成首富从游戏开始 The Richest in Game - S01E11 - 4K.mp4", "亏成首富从游戏开始 the richest in game", 0},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
@@ -24,3 +41,215 @@ func TestCleanQuery(t *testing.T) {
})
}
}
func TestScrapeQueryCandidatesPreferSeriesFolderAndCJKTitle(t *testing.T) {
lib := &model.Library{
Path: `F:\downloads\国产剧`,
Type: "movie",
}
media := &model.Media{
Title: "亏成首富从游戏开始 the ri est in game",
Path: `F:\downloads\国产剧\亏成首富从游戏开始 The Richest in Game\Season 01\亏成首富从游戏开始 The Richest in Game - S01E11 - 4K.mp4`,
SeasonNum: 1,
EpisodeNum: 11,
}
got := scrapeQueryCandidates(media, lib)
if len(got) == 0 {
t.Fatal("scrapeQueryCandidates returned no candidates")
}
if got[0] != "亏成首富从游戏开始" {
t.Fatalf("first query candidate = %q, want Chinese series title", got[0])
}
for _, candidate := range got {
if strings.Contains(candidate, "ri est") {
t.Fatalf("query candidate kept substring-stripped title: %#v", got)
}
}
}
func TestEnrichOneWritesTMDbIDColumn(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
lib := model.Library{Name: "番剧", Path: t.TempDir(), Type: "tv", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(lib.Path, "间谍过家家 - S02E01.mkv")
if err := repos.DB.Create(&model.Media{
LibraryID: lib.ID,
Title: "间谍过家家",
Path: mediaPath,
SeasonNum: 2,
EpisodeNum: 1,
ScrapeStatus: "pending",
}).Error; err != nil {
t.Fatal(err)
}
var media model.Media
if err := repos.DB.First(&media, "path = ?", mediaPath).Error; err != nil {
t.Fatal(err)
}
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
t.Fatal(err)
}
var got model.Media
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
t.Fatal(err)
}
if got.ScrapeStatus != "matched" || got.TMDbID != 12345 {
t.Fatalf("unexpected scraped media: status=%q tmdb=%d", got.ScrapeStatus, got.TMDbID)
}
}
func TestEnrichOnePrefersLocalMetadataWithoutProvider(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
log := zap.NewNop()
scraper := NewScraperService(&config.Config{}, log, repos, nil, nil, nil, nil, NewHub(log))
root := t.TempDir()
showDir := filepath.Join(root, "间谍过家家")
seasonDir := filepath.Join(showDir, "Season 02")
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(`<tvshow>
<title>间谍过家家</title>
<year>2022</year>
<tmdbid>120089</tmdbid>
<genre>Animation</genre>
</tvshow>`), 0o644); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(seasonDir, "间谍过家家 - S02E12.mkv")
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<episodedetails>
<title>企鹅公园</title>
<showtitle>间谍过家家</showtitle>
<season>2</season>
<episode>12</episode>
<plot>本地剧情</plot>
</episodedetails>`), 0o644); err != nil {
t.Fatal(err)
}
lib := model.Library{Name: "番剧", Path: root, Type: "tv", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "bad title",
Path: mediaPath,
ScrapeStatus: "pending",
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
t.Fatal(err)
}
var got model.Media
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
t.Fatal(err)
}
if got.ScrapeStatus != "matched" || got.Title != "间谍过家家" || got.TMDbID != 120089 {
t.Fatalf("unexpected local scrape: status=%q title=%q tmdb=%d", got.ScrapeStatus, got.Title, got.TMDbID)
}
if got.SeasonNum != 2 || got.EpisodeNum != 12 || got.Overview != "本地剧情" {
t.Fatalf("unexpected local episode data: s=%d e=%d overview=%q", got.SeasonNum, got.EpisodeNum, got.Overview)
}
}
func TestManualEnrichLibraryRetriesNoMatchAndCountsRealMatches(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
lib := model.Library{Name: "番剧", Path: t.TempDir(), Type: "tv", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(lib.Path, "间谍过家家 - S02E02.mkv")
if err := repos.DB.Create(&model.Media{
LibraryID: lib.ID,
Title: "间谍过家家",
Path: mediaPath,
SeasonNum: 2,
EpisodeNum: 2,
ScrapeStatus: "no_match",
}).Error; err != nil {
t.Fatal(err)
}
if matched, err := scraper.EnrichLibrary(t.Context(), lib.ID); err != nil || matched != 0 {
t.Fatalf("default EnrichLibrary matched=%d err=%v, want skipped no_match", matched, err)
}
if matched, err := scraper.EnrichLibrary(t.Context(), lib.ID, true); err != nil || matched != 1 {
t.Fatalf("manual EnrichLibrary matched=%d err=%v, want one real match", matched, err)
}
}
func newTestScraper(t *testing.T) (*ScraperService, *repository.Container, func()) {
t.Helper()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasPrefix(r.URL.Path, "/search/tv"):
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 12345,
"name": "间谍过家家",
"overview": "测试简介",
"poster_path": "/poster.jpg",
"backdrop_path": "/backdrop.jpg",
"first_air_date": "2022-04-09",
"vote_average": 8.6,
}},
})
case strings.HasPrefix(r.URL.Path, "/tv/12345"):
_ = json.NewEncoder(w).Encode(map[string]any{
"origin_country": []string{"JP"},
"spoken_languages": []map[string]any{{
"iso_639_1": "ja",
}},
"genres": []map[string]any{{
"name": "Animation",
}},
})
default:
http.NotFound(w, r)
}
}))
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
upstream.Close()
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
upstream.Close()
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.TMDbAPIKey = "test-key"
cfg.Secrets.TMDbAPIProxy = upstream.URL
cfg.Secrets.TMDbImageProxy = upstream.URL + "/images"
log := zap.NewNop()
tmdb := NewTMDbProvider(cfg, log, nil)
scraper := NewScraperService(cfg, log, repos, tmdb, nil, nil, nil, NewHub(log))
return scraper, repos, upstream.Close
}
+111 -108
View File
@@ -15,58 +15,58 @@ import (
// Container 持有在启动时初始化的每个服务。Handler 接收指向它的指针并选择相关字段。
type Container struct {
Cfg *config.Config
Log *zap.Logger
Repo *repository.Container
WSHub *Hub
SSEHub *SSEHub
Auth *AuthService
Media *MediaService
Scan *ScannerService
Stream *StreamService
Transcoder *TranscoderService
FFprobe *FFprobeService
TMDb *TMDbProvider
Bangumi *BangumiProvider
TheTVDB *TheTVDBProvider
Fanart *FanartProvider
Scraper *ScraperService
Discover *DiscoverService
Playback *PlaybackService
ImageProxy *ImageProxy
Watcher *WatcherService
Downloads *DownloadService
Subscription *SubscriptionService
Subtitle *SubtitleService
Stats *StatsService
Profile *ProfileService
Audit *AuditService
NFO *NFOService
AI *AIService
APIConfig *APIConfigService
Crypto *CryptoService
Duplicate *DuplicateService
FileManager *FileManagerService
DLNA *DLNAService
Scheduler *SchedulerService
Storage *StorageService
Emby *EmbyService
Backup *BackupService
Notifier *NotifierService
NotifyChannels *NotifyChannelService
TelegramBot *TelegramBotService
PlayProfiles *PlayProfileService
Permissions *PermissionService
StorageCfg *StorageConfigService
Cfg *config.Config
Log *zap.Logger
Repo *repository.Container
WSHub *Hub
SSEHub *SSEHub
Auth *AuthService
Media *MediaService
Scan *ScannerService
Stream *StreamService
Transcoder *TranscoderService
FFprobe *FFprobeService
TMDb *TMDbProvider
Bangumi *BangumiProvider
TheTVDB *TheTVDBProvider
Fanart *FanartProvider
Scraper *ScraperService
Discover *DiscoverService
Playback *PlaybackService
ImageProxy *ImageProxy
Watcher *WatcherService
Downloads *DownloadService
Subscription *SubscriptionService
Subtitle *SubtitleService
Stats *StatsService
Profile *ProfileService
Audit *AuditService
NFO *NFOService
AI *AIService
APIConfig *APIConfigService
Crypto *CryptoService
Duplicate *DuplicateService
FileManager *FileManagerService
DLNA *DLNAService
Scheduler *SchedulerService
Storage *StorageService
Emby *EmbyService
Backup *BackupService
Notifier *NotifierService
NotifyChannels *NotifyChannelService
TelegramBot *TelegramBotService
PlayProfiles *PlayProfileService
Permissions *PermissionService
StorageCfg *StorageConfigService
DownloadClients *DownloadClientService
Assistant *AssistantService
Organizer *OrganizerService
Douban *DoubanProvider
Token *TokenService
ApiConfig *ApiConfigService
DownloadMgr *DownloadManager
Notify *NotifyService
Site *SiteService
Assistant *AssistantService
Organizer *OrganizerService
Douban *DoubanProvider
Token *TokenService
ApiConfig *ApiConfigService
DownloadMgr *DownloadManager
Notify *NotifyService
Site *SiteService
stopCtx context.Context
stopCancel context.CancelFunc
@@ -74,6 +74,8 @@ type Container struct {
// New 构建服务容器。
func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Container {
ApplyRuntimeSettings(context.Background(), cfg, repos, log)
hub := NewHub(log)
go hub.Run()
@@ -88,13 +90,12 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
bangumi := NewBangumiProvider(cfg, log)
thetvdb := NewTheTVDBProvider(cfg, log)
fanart := NewFanartProvider(cfg, log)
scraper := NewScraperService(cfg, log, repos, tmdb, bangumi, thetvdb, fanart, hub)
adult := NewAdultProvider(log, apiConfig)
scraper := NewScraperService(cfg, log, repos, tmdb, bangumi, thetvdb, fanart, hub, adult)
organizer := NewOrganizerService(cfg, log, repos)
downloads := NewDownloadService(log, repos, hub, organizer)
discover := NewDiscoverService(log, tmdb)
transcoder := NewTranscoderService(cfg, log, repos, hub)
scanner := NewScannerService(cfg, log, repos, hub, probe, scraper)
subscription := NewSubscriptionService(log, repos, downloads, hub)
watcher := NewWatcherService(log, repos, scanner)
nfo := NewNFOService(log, repos)
ai := NewAIService(cfg, log)
@@ -120,71 +121,73 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
apiConfigSvc := NewApiConfigService(cfg, log, repos, crypto)
downloadMgr := NewDownloadManager(log, repos, crypto)
notifySvc := NewNotifyService(log, repos, crypto)
// 构建 FlareSolverr URL(如果启用)
flareSolverrURL := ""
if cfg.FlareSolverr.Enabled && cfg.FlareSolverr.URL != "" {
flareSolverrURL = cfg.FlareSolverr.URL
}
siteSvc := NewSiteService(log, repos, flareSolverrURL)
downloads := NewDownloadService(log, repos, hub, organizer, siteSvc)
subscription := NewSubscriptionService(cfg, log, repos, downloads, siteSvc, hub)
ctx, cancel := context.WithCancel(context.Background())
return &Container{
Cfg: cfg,
Log: log,
Repo: repos,
WSHub: hub,
SSEHub: sseHub,
Auth: NewAuthService(cfg, log, repos, tokenSvc, permissions),
Media: NewMediaService(cfg, log, repos),
Scan: scanner,
Stream: NewStreamService(cfg, log, repos, transcoder),
Transcoder: transcoder,
FFprobe: probe,
TMDb: tmdb,
Bangumi: bangumi,
TheTVDB: thetvdb,
Fanart: fanart,
Scraper: scraper,
Discover: discover,
Playback: NewPlaybackService(log, repos),
ImageProxy: NewImageProxy(cfg, log),
Watcher: watcher,
Downloads: downloads,
Subscription: subscription,
Subtitle: NewSubtitleService(log, repos),
Stats: NewStatsService(log, repos),
Profile: NewProfileService(log, repos),
Audit: NewAuditService(log, repos),
NFO: nfo,
AI: ai,
APIConfig: apiConfig,
Crypto: crypto,
Duplicate: duplicate,
FileManager: filemanager,
DLNA: dlna,
Scheduler: scheduler,
Storage: storage,
Emby: emby,
Backup: backup,
Notifier: notifier,
NotifyChannels: notifyChannels,
TelegramBot: telegramBot,
PlayProfiles: playProfiles,
Permissions: permissions,
StorageCfg: storageCfg,
Cfg: cfg,
Log: log,
Repo: repos,
WSHub: hub,
SSEHub: sseHub,
Auth: NewAuthService(cfg, log, repos, tokenSvc, permissions),
Media: NewMediaService(cfg, log, repos),
Scan: scanner,
Stream: NewStreamService(cfg, log, repos, transcoder),
Transcoder: transcoder,
FFprobe: probe,
TMDb: tmdb,
Bangumi: bangumi,
TheTVDB: thetvdb,
Fanart: fanart,
Scraper: scraper,
Discover: discover,
Playback: NewPlaybackService(log, repos),
ImageProxy: NewImageProxy(cfg, log),
Watcher: watcher,
Downloads: downloads,
Subscription: subscription,
Subtitle: NewSubtitleService(log, repos),
Stats: NewStatsService(log, repos),
Profile: NewProfileService(log, repos),
Audit: NewAuditService(log, repos),
NFO: nfo,
AI: ai,
APIConfig: apiConfig,
Crypto: crypto,
Duplicate: duplicate,
FileManager: filemanager,
DLNA: dlna,
Scheduler: scheduler,
Storage: storage,
Emby: emby,
Backup: backup,
Notifier: notifier,
NotifyChannels: notifyChannels,
TelegramBot: telegramBot,
PlayProfiles: playProfiles,
Permissions: permissions,
StorageCfg: storageCfg,
DownloadClients: downloadClients,
Assistant: assistant,
Organizer: organizer,
Douban: douban,
Token: tokenSvc,
ApiConfig: apiConfigSvc,
DownloadMgr: downloadMgr,
Notify: notifySvc,
Site: siteSvc,
stopCtx: ctx,
stopCancel: cancel,
Assistant: assistant,
Organizer: organizer,
Douban: douban,
Token: tokenSvc,
ApiConfig: apiConfigSvc,
DownloadMgr: downloadMgr,
Notify: notifySvc,
Site: siteSvc,
stopCtx: ctx,
stopCancel: cancel,
}
}
+148 -2
View File
@@ -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,
+33
View File
@@ -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
+17
View File
@@ -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)
}
}
+148 -6
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+32 -24
View File
@@ -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))
}
}
+9 -9
View File
@@ -32,9 +32,9 @@ type TheTVDBProvider struct {
log *zap.Logger
client *http.Client
mu sync.Mutex
token string
tokenExp time.Time
mu sync.Mutex
token string
tokenExp time.Time
}
// NewTheTVDBProvider is the constructor.
@@ -42,7 +42,7 @@ func NewTheTVDBProvider(cfg *config.Config, log *zap.Logger) *TheTVDBProvider {
return &TheTVDBProvider{
cfg: cfg,
log: log,
client: &http.Client{Timeout: 15 * time.Second},
client: NewExternalHTTPClient(15 * time.Second),
}
}
@@ -110,11 +110,11 @@ func (t *TheTVDBProvider) SearchSeries(ctx context.Context, query string) (*Matc
}
type entry struct {
ID string `json:"id"`
Name string `json:"name"`
Overview string `json:"overview"`
Image string `json:"image_url"`
Year string `json:"year"`
ID string `json:"id"`
Name string `json:"name"`
Overview string `json:"overview"`
Image string `json:"image_url"`
Year string `json:"year"`
}
type page struct {
Data []entry `json:"data"`
+19 -27
View File
@@ -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"`
+88 -13
View File
@@ -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,
)
+28 -4
View File
@@ -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")
}
}