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
+1
View File
@@ -0,0 +1 @@
39f30642a462624f2aa03896465943891e2d28db9f9a03aca94fd9af58d0c108
+1
View File
@@ -0,0 +1 @@
28604
+5
View File
@@ -99,6 +99,7 @@ organizer:
smart_classify: false # 是否启用智能分类整理
categories:
chinese_movie: "华语电影"
animation_movie: "动画电影"
foreign_movie: "外语电影"
euus_movie: "欧美电影"
jk_movie: "日韩电影"
@@ -107,3 +108,7 @@ organizer:
jk_tv: "日韩剧"
jp_anime: "日番"
cn_anime: "国漫"
variety: "综艺"
documentary: "纪录片"
children: "儿童"
uncategorized_tv: "未分类"
+16 -11
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")
}
}
+4
View File
@@ -11,6 +11,9 @@ const HomePage = lazy(() => import('./pages/HomePage').then((m) => ({ default: m
const LibraryPage = lazy(() =>
import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage })),
)
const LibrariesPage = lazy(() =>
import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage })),
)
const SearchPage = lazy(() =>
import('./pages/SearchPage').then((m) => ({ default: m.SearchPage })),
)
@@ -116,6 +119,7 @@ export default function App() {
}
>
<Route index element={<HomePage />} />
<Route path="libraries" element={<LibrariesPage />} />
<Route path="library/:id" element={<LibraryPage />} />
<Route path="discover" element={<DiscoverPage />} />
<Route path="search" element={<SearchPage />} />
+19 -1
View File
@@ -10,6 +10,21 @@ export interface SearchIntent {
language?: string
}
export interface ExternalMediaResult {
source: string
media_type?: string
title: string
overview?: string
poster_url?: string
backdrop_url?: string
year?: number
rating?: number
tmdb_id?: number
bangumi_id?: number
douban_id?: string
subscribe_keyword: string
}
export const aiAPI = {
status: () =>
api
@@ -18,7 +33,10 @@ export const aiAPI = {
smartSearch: (query: string) =>
api
.post<{ intent: SearchIntent; items: Media[] }>('/ai/search', { query })
.post<{ intent: SearchIntent; items: Media[]; external_items: ExternalMediaResult[] }>(
'/ai/search',
{ query },
)
.then((r) => r.data),
recommend: () => api.get<{ titles: string[] }>('/ai/recommend').then((r) => r.data.titles),
+22 -3
View File
@@ -4,13 +4,24 @@ import type { Media } from '../types'
// TMDb-derived "Match" rows used by trending/popular rails. We re-use the
// Media interface — only TMDb id / poster / overview are populated.
export interface DiscoverItem extends Partial<Media> {
tmdb_id: number
source?: string
media_type?: string
tmdb_id?: number
douban_id?: string
bangumi_id?: number
title: string
poster_url?: string
backdrop_url?: string
overview?: string
year: number
rating: number
year?: number
rating?: number
subscribe_keyword?: string
}
export interface DiscoverSection {
key: string
label: string
provider?: string
}
// 后端在 TMDb 不可达 / API key 缺失时统一返回 { items: [], error: "..." }
@@ -32,4 +43,12 @@ export const discoverAPI = {
items: r.data.items ?? [],
error: r.data.error,
})),
sections: () =>
api.get<{ sections: DiscoverSection[] }>('/discover/sections').then((r) => r.data.sections),
feed: (sectionKeys: string[]) =>
api
.get<Record<string, DiscoverItem[] | null>>('/discover/feed', {
params: { sections: sectionKeys.join(',') },
})
.then((r) => r.data),
}
+8 -1
View File
@@ -11,16 +11,23 @@ export interface DuplicateReport {
total_scanned: number
groups_found: number
items_marked: number
missing_removed?: number
groups: DuplicateGroup[]
}
export const duplicatesAPI = {
list: (libraryID = '') =>
api
.get<DuplicateReport>('/duplicates', {
params: libraryID ? { library_id: libraryID } : undefined,
})
.then((r) => ({ ...r.data, groups: r.data.groups ?? [] })),
scan: (libraryID = '') =>
api
.post<DuplicateReport>('/duplicates/scan', null, {
params: libraryID ? { library_id: libraryID } : undefined,
})
.then((r) => r.data),
.then((r) => ({ ...r.data, groups: r.data.groups ?? [] })),
unmark: (libraryID = '') =>
api
.post<{ unmarked: number }>('/duplicates/unmark', null, {
+9 -1
View File
@@ -5,7 +5,15 @@ export const subscriptionsAPI = {
list: () =>
api.get<{ items: Subscription[] }>('/subscriptions').then((r) => r.data.items),
create: (input: { name: string; feed_url: string; filter?: string; enabled?: boolean }) =>
create: (input: {
name: string
feed_url: string
filter?: string
media_type?: string
media_category?: string
save_path?: string
enabled?: boolean
}) =>
api.post<Subscription>('/subscriptions', input).then((r) => r.data),
remove: (id: string) => api.delete(`/subscriptions/${id}`).then((r) => r.data),
+2 -2
View File
@@ -12,8 +12,8 @@ export interface SubtitleTrack {
export const subtitlesAPI = {
list: (mediaId: string) =>
api
.get<{ tracks: SubtitleTrack[] }>(`/media/${mediaId}/subtitles`)
.then((r) => r.data.tracks),
.get<{ tracks: SubtitleTrack[] | null }>(`/media/${mediaId}/subtitles`)
.then((r) => r.data.tracks ?? []),
url: (mediaId: string, path: string) => {
const token = useAuthStore.getState().token ?? ''
+32 -63
View File
@@ -2,35 +2,27 @@
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import {
Activity, Bell, Cast, Clock, CloudDownload, Compass, Copy, Film,
FolderTree, Globe, HardDrive, Heart, Home, GalleryHorizontalEnd,
Link2, ListChecks, ListMusic, LogOut, MessageSquare, Rss, Search,
Server, Settings, Sliders, Sparkles, Cloud, Trash2, UserCog, Wrench,
Activity, Bell, Clock, CloudDownload, Compass, Film,
Cast, Globe, HardDrive, Heart, Home, Image,
ListMusic, LogOut, Rss, Search,
Settings, Sliders, Sparkles, UserCog, Wrench,
Library as LibraryIcon, User as UserIcon, ChevronDown, Menu, X
} from 'lucide-react'
import clsx from 'clsx'
import { AppFooter } from './AppFooter'
import { libraryAPI } from '../api/library'
import { useAuthStore } from '../stores/auth'
import type { Library } from '../types'
export function Layout() {
const navigate = useNavigate()
const location = useLocation()
const user = useAuthStore((s) => s.user)
const logout = useAuthStore((s) => s.logout)
const [libraries, setLibraries] = useState<Library[]>([])
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
const [isProfileOpen, setIsProfileOpen] = useState(false)
const [searchFocused, setSearchFocused] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
// Automatically fetch libraries
useEffect(() => {
libraryAPI.list().then(setLibraries).catch(() => undefined)
}, [])
// Auto-collapse sidebar on smaller tablet screens, and auto-hide drawer on path change
useEffect(() => {
const handleResize = () => {
@@ -99,76 +91,48 @@ export function Layout() {
<SectionHeader label="影音中心" visible={isSidebarOpen || isMobileDrawerOpen} />
<div className="space-y-1">
<SidebarLink to="/" icon={<Home size={18} />} label="系统首页" end collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/libraries" icon={<LibraryIcon size={18} />} label="媒体库" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/poster-wall" icon={<Image size={18} />} label="海报墙" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/discover" icon={<Compass size={18} />} label="精彩发现" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/search" icon={<Search size={18} />} label="智能搜索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="我的收藏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/poster-wall" icon={<GalleryHorizontalEnd size={18} />} label="影音海报墙" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 影视助理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 投屏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 助理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
</div>
</div>
{/* Navigation Group: Libraries */}
{/* Navigation Group: Personal */}
<div>
<SectionHeader label="媒体片库" visible={isSidebarOpen || isMobileDrawerOpen} />
<SectionHeader label="个人空间" visible={isSidebarOpen || isMobileDrawerOpen} />
<div className="space-y-1">
{libraries.length === 0 && (isSidebarOpen || isMobileDrawerOpen) && (
<div className="px-4 py-2 text-xs text-gray-500 italic">暂无媒体片库</div>
)}
{libraries.map((lib) => (
<SidebarLink
key={lib.id}
to={`/library/${lib.id}`}
icon={<LibraryIcon size={18} />}
label={lib.name}
collapsed={!isSidebarOpen && !isMobileDrawerOpen}
/>
))}
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="我的收藏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/profile" icon={<UserIcon size={18} />} label="账号信息" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
</div>
</div>
{/* Navigation Group: Automation */}
<div>
<SectionHeader label="自动化运维" visible={isSidebarOpen || isMobileDrawerOpen} />
<SectionHeader label="下载订阅" visible={isSidebarOpen || isMobileDrawerOpen} />
<div className="space-y-1">
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载中心" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 串流" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/download-clients" icon={<Sliders size={18} />} label="下载器管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="订阅管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/site-search" icon={<Search size={18} />} label="站点检索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
</div>
</div>
{/* Navigation Group: Account Profile */}
<div>
<SectionHeader label="个人资料" visible={isSidebarOpen || isMobileDrawerOpen} />
<div className="space-y-1">
<SidebarLink to="/profile" icon={<UserIcon size={18} />} label="账号信息" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/play-profiles" icon={<UserCog size={18} />} label="观影 Profile" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
</div>
</div>
{/* Navigation Group: Admin Dashboard */}
{user?.role === 'admin' && (
<div>
<SectionHeader label="系统后台" visible={isSidebarOpen || isMobileDrawerOpen} />
<SectionHeader label="统一管理" visible={isSidebarOpen || isMobileDrawerOpen} />
<div className="space-y-1">
<SidebarLink to="/admin" icon={<Settings size={18} />} label="后台主页" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行监控" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/notify-channels" icon={<Bell size={18} />} label="通知配置" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/download-clients" icon={<Server size={18} />} label="下载客户端" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时机制" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储分析" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/storage-config" icon={<Cloud size={18} />} label="外部挂载" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件管家" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="排重清理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/strm" icon={<Link2 size={18} />} label="STRM 关联" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="运维工具" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/assistant" icon={<MessageSquare size={18} />} label="AI 智能助教" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统参数" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/admin" icon={<Settings size={18} />} label="媒体与用户" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点与下载器" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="整理与维护" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储与文件" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统设置" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
</div>
</div>
)}
@@ -289,10 +253,15 @@ export function Layout() {
</Link>
{/* Notification alert bubble */}
<button className="relative rounded-xl border border-gray-200 p-2.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-all">
<Link
to="/notify-channels"
title="通知配置"
aria-label="打开通知配置"
className="relative rounded-xl border border-gray-200 p-2.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-all"
>
<Bell size={18} />
<span className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-brand-500 ring-2 ring-white animate-pulse" />
</button>
</Link>
{/* Horizontal divider lines */}
<span className="h-6 w-px bg-gray-200" />
@@ -454,4 +423,4 @@ function SidebarLink({ to, icon, label, end, collapsed }: SidebarLinkProps) {
)}
</NavLink>
)
}
}
@@ -0,0 +1,57 @@
import { Link } from 'react-router-dom'
import { ArrowRight } from 'lucide-react'
type ShortcutItem = {
to: string
title: string
description: string
badge?: string
}
type ManagementShortcutsProps = {
title: string
description?: string
items: ShortcutItem[]
}
export function ManagementShortcuts({ title, description, items }: ManagementShortcutsProps) {
return (
<section className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="font-display text-lg font-bold text-ink-600">{title}</h2>
{description && <p className="mt-1 text-sm text-ink-50">{description}</p>}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{items.map((item) => (
<Link
key={item.to}
to={item.to}
className="group rounded-2xl border border-gray-200 bg-gray-50/70 p-4 transition hover:-translate-y-0.5 hover:border-primary-400/50 hover:bg-white hover:shadow-md"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-bold text-ink-600">{item.title}</h3>
{item.badge && (
<span className="shrink-0 rounded-full bg-primary-400/10 px-2 py-0.5 text-[10px] font-bold text-brand-500">
{item.badge}
</span>
)}
</div>
<p className="mt-2 line-clamp-2 text-xs leading-5 text-ink-50">
{item.description}
</p>
</div>
<ArrowRight
size={16}
className="mt-0.5 shrink-0 text-sand-500 transition group-hover:translate-x-0.5 group-hover:text-brand-500"
/>
</div>
</Link>
))}
</div>
</section>
)
}
+77 -3
View File
@@ -1,9 +1,11 @@
import { FormEvent, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loader2, Search, Sparkles, Wand2 } from 'lucide-react'
import { Loader2, Rss, Search, Sparkles, Wand2 } from 'lucide-react'
import toast from 'react-hot-toast'
import { aiAPI, type SearchIntent } from '../api/ai'
import { aiAPI, type ExternalMediaResult, type SearchIntent } from '../api/ai'
import { imageURL } from '../api/client'
import { subscriptionsAPI } from '../api/subscriptions'
import { MediaCard } from '../components/MediaCard'
import type { Media } from '../types'
@@ -26,6 +28,8 @@ export function AIAssistantPage() {
const [searching, setSearching] = useState(false)
const [intent, setIntent] = useState<SearchIntent | null>(null)
const [items, setItems] = useState<Media[]>([])
const [externalItems, setExternalItems] = useState<ExternalMediaResult[]>([])
const [subscribing, setSubscribing] = useState('')
const [recs, setRecs] = useState<string[] | null>(null)
const [recommending, setRecommending] = useState(false)
@@ -43,11 +47,13 @@ export function AIAssistantPage() {
setSearching(true)
setIntent(null)
setItems([])
setExternalItems([])
try {
const r = await aiAPI.smartSearch(query.trim())
setIntent(r.intent)
setItems(r.items)
if (r.items.length === 0) toast('未找到匹配项')
setExternalItems(r.external_items ?? [])
if (r.items.length === 0 && (r.external_items ?? []).length === 0) toast('未找到匹配项')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
@@ -187,6 +193,74 @@ export function AIAssistantPage() {
))}
</div>
)}
{externalItems.length > 0 && (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{externalItems.map((item) => {
const keyword = item.subscribe_keyword || item.title
const key = `${item.source}:${keyword}`
return (
<article key={key} className="rounded-2xl border border-gray-200 bg-gray-50 p-3">
<div className="flex gap-3">
<div className="h-24 w-16 shrink-0 overflow-hidden rounded-xl bg-white">
{item.poster_url ? (
<img
src={imageURL(item.poster_url)}
alt={item.title}
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap gap-2 text-[10px] uppercase text-brand-500">
<span>{item.source}</span>
{item.media_type && <span>{item.media_type}</span>}
{item.year ? <span>{item.year}</span> : null}
</div>
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
{item.overview || `订阅关键词:${keyword}`}
</p>
<button
disabled={subscribing === key}
onClick={async () => {
setSubscribing(key)
try {
const feed = `site-search://search?keyword=${encodeURIComponent(keyword)}&source=${encodeURIComponent(item.source)}`
const sub = await subscriptionsAPI.create({
name: `${item.title} 自动订阅`,
feed_url: feed,
filter: keyword,
media_type: item.media_type,
enabled: true,
})
const run = await subscriptionsAPI.runNow(sub.id)
toast.success(
run.queued > 0
? `已订阅并加入 ${run.queued} 个下载`
: '已订阅,暂未在 PT 站点找到可下载资源',
)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'订阅失败'
toast.error(msg)
} finally {
setSubscribing('')
}
}}
className="mt-2 rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10 disabled:opacity-50"
>
<Rss size={12} className="mr-1 inline" />
{subscribing === key ? '订阅中…' : '订阅并搜索 PT'}
</button>
</div>
</div>
</article>
)
})}
</div>
)}
</section>
{/* Recommendations */}
+13 -1
View File
@@ -6,6 +6,7 @@ import { adminAPI } from '../api/admin'
import { libraryAPI } from '../api/library'
import type { Library, User } from '../types'
import { APIConfigsPanel } from '../components/APIConfigsPanel'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
export function AdminPage() {
const [tab, setTab] = useState<'library' | 'users' | 'api'>('library')
@@ -17,6 +18,16 @@ export function AdminPage() {
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-ink-600">管理后台</h1>
<ManagementShortcuts
title="统一管理入口"
description="侧栏保持精简,完整管理能力统一从这里进入。"
items={[
{ to: '/sites', title: '站点管理', description: '维护 PT 站点、认证方式和检索配置' },
{ to: '/download-clients', title: '下载器管理', description: '配置 qBittorrent 等下载器连接', badge: '下载' },
{ to: '/tools', title: '整理与维护', description: '媒体整理、任务维护和辅助工具' },
{ to: '/storage', title: '存储与文件', description: '查看占用、清理重复项和管理文件' },
]}
/>
<div className="flex flex-wrap gap-2 border-b border-gray-200">
{tabs.map((k) => (
<button
@@ -88,6 +99,7 @@ function LibraryPanel() {
<select className="input-base" value={type} onChange={(e) => setType(e.target.value)}>
<option value="movie">电影</option>
<option value="tv">电视剧</option>
<option value="variety">综艺</option>
<option value="anime">动漫</option>
<option value="music">音乐</option>
</select>
@@ -117,7 +129,7 @@ function LibraryPanel() {
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
onClick={async () => {
const r = await libraryAPI.scan(l.id)
toast.success(`扫描完成,新增 ${r.added}`)
toast.success(`扫描完成,新增 ${r.added},更新 ${r.updated ?? 0}`)
}}
>
扫描
+169 -128
View File
@@ -1,140 +1,152 @@
import { useEffect, useState } from 'react'
import { Sparkles, AlertTriangle, ExternalLink, Wifi } from 'lucide-react'
import { Link } from 'react-router-dom'
import { useEffect, useMemo, useState } from 'react'
import { AlertTriangle, Sparkles } from 'lucide-react'
import { discoverAPI, type DiscoverItem } from '../api/discover'
import { discoverAPI, type DiscoverItem, type DiscoverSection } from '../api/discover'
import { imageURL } from '../api/client'
// 判断后端返回的错误是不是"未配置 API key"。其它(网络超时/被墙/上游 5xx)
// 都归为"网络/上游故障",避免误导用户去再配一次 key。
function isMissingKey(err?: string): boolean {
if (!err) return false
const low = err.toLowerCase()
return low.includes('api key') || low.includes('apikey') || low.includes('not configured')
}
const defaultSections = [
'tmdb_trending_day',
'douban_hot_movie',
'douban_hot_tv',
'bangumi_calendar',
]
function isNetworkError(err?: string): boolean {
if (!err) return false
const low = err.toLowerCase()
return (
low.includes('deadline exceeded') ||
low.includes('timeout') ||
low.includes('no such host') ||
low.includes('connection refused') ||
low.includes('eof') ||
low.includes('tls') ||
low.includes('reset')
)
}
const storageKey = 'mediastation.discover.sections'
export function DiscoverPage() {
const [trending, setTrending] = useState<DiscoverItem[]>([])
const [popular, setPopular] = useState<DiscoverItem[]>([])
const [trendingErr, setTrendingErr] = useState<string | undefined>()
const [popularErr, setPopularErr] = useState<string | undefined>()
const [sections, setSections] = useState<DiscoverSection[]>([])
const [selected, setSelected] = useState<string[]>(defaultSections)
const [rows, setRows] = useState<Record<string, DiscoverItem[]>>({})
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
Promise.all([
discoverAPI.trending().catch((err) => ({
items: [] as DiscoverItem[],
error: err instanceof Error ? err.message : String(err),
})),
discoverAPI.popular().catch((err) => ({
items: [] as DiscoverItem[],
error: err instanceof Error ? err.message : String(err),
})),
])
.then(([t, p]) => {
setTrending(t.items)
setTrendingErr(t.error)
setPopular(p.items)
setPopularErr(p.error)
discoverAPI
.sections()
.then((items) => {
setSections(items)
const saved = readSavedSections(items)
setSelected(saved.length > 0 ? saved : defaultSections)
})
.catch(() => {
setSections(defaultSectionDefs)
setSelected(defaultSections)
})
.finally(() => setLoading(false))
}, [])
const anyErr = trendingErr || popularErr
const missingKey = isMissingKey(anyErr)
const networkErr = !missingKey && isNetworkError(anyErr)
const otherErr = !missingKey && !networkErr && anyErr
useEffect(() => {
if (selected.length === 0) {
setRows({})
setLoading(false)
return
}
setLoading(true)
setError('')
window.localStorage.setItem(storageKey, JSON.stringify(selected))
discoverAPI
.feed(selected)
.then((feed) => {
const next: Record<string, DiscoverItem[]> = {}
for (const key of selected) {
next[key] = feed[key] ?? []
}
setRows(next)
})
.catch((err) => {
setRows({})
setError(err instanceof Error ? err.message : String(err))
})
.finally(() => setLoading(false))
}, [selected])
const sectionMap = useMemo(
() => new Map(sections.map((section) => [section.key, section])),
[sections],
)
const hasContent = selected.some((key) => (rows[key] ?? []).length > 0)
const toggleSection = (key: string) => {
setSelected((current) => {
if (current.includes(key)) {
return current.filter((item) => item !== key)
}
return [...current, key]
})
}
return (
<div className="space-y-8 px-4 py-6 max-w-7xl mx-auto">
{/* Header */}
<header className="flex items-center gap-4 mb-8">
<div className="p-3 rounded-2xl bg-gradient-to-br from-primary-500/20 to-primary-600/10 border border-primary-500/20">
<Sparkles className="h-8 w-8 text-brand-500" />
<div className="mx-auto max-w-7xl space-y-8 px-4 py-6">
<header className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="flex items-center gap-4">
<div className="rounded-2xl border border-primary-500/20 bg-gradient-to-br from-primary-500/20 to-primary-600/10 p-3">
<Sparkles className="h-8 w-8 text-brand-500" />
</div>
<div>
<h1 className="font-display text-4xl font-bold tracking-tight text-ink-600">
发现
</h1>
<p className="mt-1 text-base text-ink-50">
多源推荐:TMDb / 豆瓣 / Bangumi,可按需组合显示
</p>
</div>
</div>
<div>
<h1 className="font-display text-4xl font-bold text-ink-600 tracking-tight">发现</h1>
<p className="mt-1 text-base text-ink-50">
来自 TMDb 的当日热门与流行榜单
</p>
<div className="flex flex-wrap gap-2">
{sections.map((section) => {
const active = selected.includes(section.key)
return (
<button
key={section.key}
type="button"
onClick={() => toggleSection(section.key)}
className={
'rounded-full border px-3 py-1.5 text-xs font-semibold transition ' +
(active
? 'border-primary-400 bg-primary-400/15 text-brand-500'
: 'border-gray-200 bg-white text-gray-500 hover:border-primary-300 hover:text-ink-600')
}
>
{section.label}
</button>
)
})}
</div>
</header>
{loading && <DiscoverSkeleton />}
{/* TMDb API Key 未配置 */}
{!loading && missingKey && (
<div className="rounded-2xl bg-amber-500/10 border border-amber-500/20 p-6 text-center space-y-4">
<div className="mx-auto w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center">
<AlertTriangle className="h-8 w-8 text-amber-400" />
</div>
<h3 className="text-lg font-semibold text-ink-600">TMDb API Key 未配置</h3>
<p className="text-sm text-ink-50 max-w-md mx-auto">
您需要在管理后台填入 TMDb API Key 才能查看发现内容。
</p>
<Link
to="/admin"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-primary-500/20 text-brand-500 hover:bg-primary-500/30 transition-colors font-medium"
>
前往管理后台
<ExternalLink className="h-4 w-4" />
</Link>
{!loading && error && (
<div className="flex items-center gap-3 rounded-2xl border border-red-500/20 bg-red-500/10 p-4">
<AlertTriangle className="h-5 w-5 flex-shrink-0 text-red-400" />
<p className="text-red-300">{error}</p>
</div>
)}
{/* 网络无法访问 TMDb */}
{!loading && networkErr && (
<div className="rounded-2xl bg-orange-500/10 border border-orange-500/20 p-6 text-center space-y-4">
<div className="mx-auto w-16 h-16 rounded-full bg-orange-500/10 flex items-center justify-center">
<Wifi className="h-8 w-8 text-orange-400" />
</div>
<h3 className="text-lg font-semibold text-ink-600">无法连接到 TMDb</h3>
<p className="text-sm text-ink-50 max-w-lg mx-auto">
服务器到 <code className="font-mono text-orange-300">api.themoviedb.org</code> 的连接超时。
通常是因为部署机器没有走代理。可以在系统环境变量里设置
<code className="font-mono text-orange-300 mx-1">HTTPS_PROXY</code>,
或在「外部 API」配置里填写自建反代地址(tmdb_api_proxy / tmdb_image_proxy)。
</p>
<details className="text-xs text-sand-500 max-w-lg mx-auto text-left">
<summary className="cursor-pointer hover:text-ink-50">查看原始错误</summary>
<pre className="mt-2 p-2 rounded bg-black/40 overflow-x-auto whitespace-pre-wrap">{anyErr}</pre>
</details>
{!loading && selected.length === 0 && (
<div className="rounded-2xl border border-gray-200 bg-white p-10 text-center text-sand-500">
至少选择一个推荐源,小宇宙才会开始转动。
</div>
)}
{/* 其它错误 */}
{!loading && otherErr && (
<div className="rounded-2xl bg-red-500/10 border border-red-500/20 p-4 flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-red-400 flex-shrink-0" />
<p className="text-red-300">{otherErr}</p>
</div>
)}
{/* Content Rows */}
{!loading && !missingKey && (
{!loading && !error && selected.length > 0 && (
<div className="space-y-10">
{trending.length > 0 && <ContentRow title="今日趋势" items={trending} />}
{popular.length > 0 && <ContentRow title="热门电影" items={popular} />}
{selected.map((key) => {
const items = rows[key] ?? []
if (items.length === 0) return null
return (
<ContentRow
key={key}
title={sectionMap.get(key)?.label ?? key}
items={items}
/>
)
})}
{/* TMDB 配置 OK 但本次没拿到任何条目(极少见) */}
{!networkErr && trending.length === 0 && popular.length === 0 && !otherErr && (
<div className="text-center py-12">
<p className="text-sand-500">暂无发现内容</p>
{!hasContent && (
<div className="rounded-2xl border border-gray-200 bg-white p-10 text-center">
<p className="text-sand-500">
当前选择的推荐源暂未返回内容,可切换豆瓣 / Bangumi 或检查网络代理。
</p>
</div>
)}
</div>
@@ -146,10 +158,10 @@ export function DiscoverPage() {
function ContentRow({ title, items }: { title: string; items: DiscoverItem[] }) {
return (
<section className="space-y-4">
<h2 className="font-display text-2xl font-semibold text-ink-600 pl-1">{title}</h2>
<h2 className="pl-1 font-display text-2xl font-semibold text-ink-600">{title}</h2>
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{items.map((item) => (
<DiscoverCard key={item.tmdb_id} item={item} />
{items.map((item, index) => (
<DiscoverCard key={discoverKey(item, index)} item={item} />
))}
</div>
</section>
@@ -157,35 +169,39 @@ function ContentRow({ title, items }: { title: string; items: DiscoverItem[] })
}
function DiscoverCard({ item }: { item: DiscoverItem }) {
const source = item.source || (item.bangumi_id ? 'bangumi' : item.douban_id ? 'douban' : 'tmdb')
return (
<div className="group relative overflow-hidden rounded-xl border border-gray-200 bg-gray-50 hover:border-primary-500/30 transition-all duration-300">
<div className="aspect-[2/3] w-full bg-surface-900 relative overflow-hidden">
<div className="group relative overflow-hidden rounded-xl border border-gray-200 bg-gray-50 transition-all duration-300 hover:border-primary-500/30">
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-900">
{item.poster_url ? (
<img
src={imageURL(item.poster_url)}
alt={item.title}
loading="lazy"
referrerPolicy="no-referrer"
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-gray-500 text-xs">
<div className="flex h-full w-full items-center justify-center text-xs text-gray-500">
无海报
</div>
)}
{item.rating > 0 && (
<div className="absolute top-1.5 right-1.5 rounded-xl bg-black/70 backdrop-blur-sm px-1.5 py-0.5 text-[11px] font-semibold text-yellow-400 border border-yellow-400/30">
★ {item.rating.toFixed(1)}
<div className="absolute left-1.5 top-1.5 rounded-xl border border-white/20 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-white backdrop-blur-sm">
{source}
</div>
{(item.rating ?? 0) > 0 && (
<div className="absolute right-1.5 top-1.5 rounded-xl border border-yellow-400/30 bg-black/70 px-1.5 py-0.5 text-[11px] font-semibold text-yellow-400 backdrop-blur-sm">
★ {(item.rating ?? 0).toFixed(1)}
</div>
)}
</div>
<div className="px-2.5 py-2 space-y-0.5">
<p className="text-xs font-medium text-ink-600 truncate group-hover:text-brand-500 transition-colors">
<div className="space-y-0.5 px-2.5 py-2">
<p className="truncate text-xs font-medium text-ink-600 transition-colors group-hover:text-brand-500">
{item.title}
</p>
{item.year > 0 && (
<p className="text-[11px] text-sand-500">{item.year}</p>
)}
<p className="text-[11px] text-sand-500">
{[item.media_type, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' · ') || '推荐'}
</p>
</div>
</div>
)
@@ -194,12 +210,12 @@ function DiscoverCard({ item }: { item: DiscoverItem }) {
function DiscoverSkeleton() {
return (
<div className="space-y-8">
{[1, 2].map((section) => (
{[1, 2, 3].map((section) => (
<section key={section} className="space-y-4">
<div className="h-8 w-48 rounded-xl bg-gray-100 animate-pulse" />
<div className="h-8 w-48 animate-pulse rounded-xl bg-gray-100" />
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<div key={i} className="aspect-[2/3] rounded-xl bg-gray-100 animate-pulse" />
{[1, 2, 3, 4, 5, 6, 7, 8].map((item) => (
<div key={item} className="aspect-[2/3] animate-pulse rounded-xl bg-gray-100" />
))}
</div>
</section>
@@ -207,3 +223,28 @@ function DiscoverSkeleton() {
</div>
)
}
function discoverKey(item: DiscoverItem, index: number): string {
return `${item.source || 'source'}:${item.tmdb_id || item.douban_id || item.bangumi_id || item.title}:${index}`
}
function readSavedSections(sections: DiscoverSection[]): string[] {
try {
const raw = window.localStorage.getItem(storageKey)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
const allowed = new Set(sections.map((section) => section.key))
return parsed.filter((key) => typeof key === 'string' && allowed.has(key))
} catch {
return []
}
}
const defaultSectionDefs: DiscoverSection[] = [
{ key: 'tmdb_trending_day', label: 'TMDb 今日趋势', provider: 'tmdb' },
{ key: 'tmdb_popular_movie', label: 'TMDb 热门电影', provider: 'tmdb' },
{ key: 'douban_hot_movie', label: '豆瓣热门电影', provider: 'douban' },
{ key: 'douban_hot_tv', label: '豆瓣热门剧集', provider: 'douban' },
{ key: 'bangumi_calendar', label: 'Bangumi 每日放送', provider: 'bangumi' },
]
+13 -2
View File
@@ -28,12 +28,17 @@ export function DuplicatesPage() {
libraryAPI.list().then(setLibs)
}, [])
useEffect(() => {
duplicatesAPI.list(libID).then(setReport).catch(() => setReport(null))
}, [libID])
const scan = async () => {
setScanning(true)
try {
const r = await duplicatesAPI.scan(libID)
setReport(r)
toast.success(`扫描完成: ${r.groups_found} 组重复, ${r.items_marked} 项标记`)
const cleaned = r.missing_removed ? `, 清理 ${r.missing_removed} 条失效记录` : ''
toast.success(`扫描完成: ${r.groups_found} 组重复, ${r.items_marked} 项标记${cleaned}`)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
@@ -89,7 +94,13 @@ export function DuplicatesPage() {
<p className="text-ink-50">扫描了 {report.total_scanned} 项,未发现重复。</p>
)}
{report && report.groups.map((g) => (
{report && report.missing_removed ? (
<p className="rounded-2xl border border-amber-300/50 bg-amber-50 px-4 py-3 text-sm text-amber-700">
已清理 {report.missing_removed} 条文件不存在的媒体记录,统计容量会在刷新后恢复正常。
</p>
) : null}
{report && (report.groups ?? []).map((g) => (
<section key={g.hash} className="glass-panel space-y-2">
<div className="flex items-center justify-between">
<p className="font-mono text-xs text-sand-500">{g.hash}</p>
+104 -71
View File
@@ -15,9 +15,10 @@ import { groupSeries } from '../utils/groupSeries'
type LibraryRow = { library: Library; cards: ReturnType<typeof groupSeries> }
const TYPE_ICONS: Record<string, React.ReactNode> = {
movie: <Film size={18} />, tv: <Tv size={18} />, anime: <PlayCircle size={18} />, music: <Music size={18} />,
movie: <Film size={18} />, tv: <Tv size={18} />, variety: <Tv size={18} />, anime: <PlayCircle size={18} />, music: <Music size={18} />,
}
const TYPE_LABELS: Record<string, string> = { movie: '电影', tv: '电视剧', anime: '动漫', music: '音乐' }
const TYPE_LABELS: Record<string, string> = { movie: '电影', tv: '电视剧', variety: '综艺', anime: '动漫', music: '音乐' }
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
export function HomePage() {
const [libraries, setLibraries] = useState<Library[]>([])
@@ -58,19 +59,18 @@ export function HomePage() {
}, [loading, libraries.length])
const fallbackCards = useMemo(() => groupSeries(fallback).slice(0, 16), [fallback])
// Pick a featured movie/show for the premium editorial billboard
const featuredItem = useMemo(() => {
if (history.length > 0 && history[0].media) {
return history[0].media;
}
if (rows.length > 0 && rows[0].cards.length > 0) {
return rows[0].cards[0].rep;
}
if (fallback.length > 0) {
return fallback[0];
}
return null;
}, [history, rows, fallback]);
const candidates = [
...(history.map((h) => h.media).filter(Boolean) as Media[]),
...rows.flatMap((row) => row.cards.map((card) => card.rep)),
...fallbackCards.map((card) => card.rep),
...fallback,
]
return candidates.find(hasArtwork) ?? candidates[0] ?? null
}, [history, rows, fallbackCards, fallback])
const featuredVisual = featuredItem?.backdrop_url || featuredItem?.poster_url || ''
const featuredPoster = featuredItem?.poster_url || featuredItem?.backdrop_url || ''
const featuredMark = (featuredItem?.title || 'MS').trim().slice(0, 4).toUpperCase()
const empty = !loading && history.length === 0 && rows.every((r) => r.cards.length === 0) && fallbackCards.length === 0
@@ -109,71 +109,104 @@ export function HomePage() {
<div className="space-y-12">
{/* ─── Premium Swiss-Editorial Billboard Hero ─── */}
{featuredItem && (
<section className="relative overflow-hidden rounded-3xl bg-white border border-gray-200/90 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)]">
{/* Background Poster Cover & Soft Faded Backdrop */}
<section className="relative overflow-hidden rounded-[2rem] bg-white border border-gray-200/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)]">
{/* Background Artwork */}
<div className="absolute inset-0 z-0">
{featuredItem.poster_url ? (
<img
src={imageURL(featuredItem.poster_url)}
alt=""
className="w-full h-full object-cover object-top opacity-5 scale-105 blur-sm"
<div className="h-full w-full bg-[radial-gradient(circle_at_80%_20%,rgba(212,175,55,0.22),transparent_34%),linear-gradient(135deg,#fff7ed,#f8fafc_52%,#eef2ff)]" />
{featuredVisual && (
<img
src={imageURL(featuredVisual)}
alt=""
className="absolute inset-0 h-full w-full scale-105 object-cover object-center opacity-[0.34] blur-[1px]"
referrerPolicy="no-referrer"
onError={(event) => { event.currentTarget.style.display = 'none' }}
/>
) : (
<div className="w-full h-full bg-gray-50" />
)}
{/* Soft Bright Masking */}
<div className="absolute inset-0 bg-gradient-to-r from-white via-white/95 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-white via-transparent to-transparent" />
<div className="absolute inset-0 bg-[linear-gradient(90deg,#ffffff_0%,rgba(255,255,255,0.96)_37%,rgba(255,255,255,0.62)_68%,rgba(255,255,255,0.2)_100%)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_82%_50%,rgba(201,149,74,0.24),transparent_32%)]" />
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-white to-transparent" />
</div>
{/* Billboard Content */}
<div className="relative z-10 px-8 py-14 sm:px-12 md:py-16 lg:py-20 max-w-3xl space-y-5">
<div className="inline-flex items-center gap-2 rounded-full bg-brand-50 px-3.5 py-1.5 text-xs font-bold uppercase tracking-widest text-[#c9954a] border border-brand-100/40">
<Sparkles size={12} fill="currentColor" />
<span>本周力荐 / Featured</span>
<div className="relative z-10 grid gap-8 px-6 py-8 sm:px-8 md:grid-cols-[minmax(0,1fr)_280px] md:px-12 md:py-12 lg:grid-cols-[minmax(0,1fr)_340px] lg:px-14 lg:py-14">
<div className="flex min-w-0 flex-col justify-center space-y-5">
<div className="inline-flex w-fit items-center gap-2 rounded-full bg-white/82 px-3.5 py-1.5 text-xs font-bold uppercase tracking-widest text-[#a8732d] border border-[#ead6b6] shadow-sm backdrop-blur">
<Sparkles size={12} fill="currentColor" />
<span>本周力荐 / Featured</span>
</div>
<div className="space-y-3">
<div className="inline-flex max-w-full items-center gap-2 rounded-2xl bg-gray-950 px-3 py-2 text-white shadow-lg shadow-gray-950/10">
<span className="h-2 w-2 rounded-full bg-[#d4af37]" />
<span className="truncate text-xs font-black tracking-[0.26em]">{featuredMark}</span>
</div>
<h1 className="font-display text-3xl sm:text-4xl md:text-5xl font-extrabold tracking-tight leading-tight text-gray-950">
{featuredItem.title}
</h1>
</div>
{featuredItem.overview ? (
<p className="max-w-2xl text-gray-600 text-sm sm:text-base leading-relaxed line-clamp-3 font-semibold">
{featuredItem.overview}
</p>
) : (
<p className="max-w-2xl text-gray-500 text-sm italic">
家庭私人媒体中心收藏。极高视听品质,支持多端原生无损解码及HLS转码播放。
</p>
)}
{/* Metadata Badges */}
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 font-bold">
{featuredItem.year > 0 && (
<span className="bg-white/85 px-2.5 py-1 rounded-xl text-gray-900 border border-gray-200 shadow-sm">{featuredItem.year} 年</span>
)}
{featuredItem.video_codec && (
<span className="rounded-lg bg-[#fff8e7] px-2 py-1 text-[#9a6a1e] border border-[#ead6b6] uppercase font-bold text-[10px]">
{featuredItem.video_codec}
</span>
)}
{featuredItem.container && (
<span className="rounded-lg bg-white/80 px-2 py-1 text-gray-700 border border-gray-200 uppercase font-mono text-[10px]">
{featuredItem.container}
</span>
)}
</div>
{/* Buttons */}
<div className="flex flex-wrap items-center gap-4 pt-2">
<Link to={`/media/${featuredItem.id}`} className="inline-flex items-center justify-center gap-2 rounded-xl bg-[#111827] px-6 py-3.5 text-sm font-bold text-white shadow-lg shadow-gray-900/15 hover:bg-[#1f2937] hover:-translate-y-0.5 transition-all">
<Play size={16} fill="currentColor" />
<span>立即播放</span>
</Link>
<Link to="/discover" className="btn-outline bg-white/80 px-5 py-3.5 text-sm font-bold text-gray-700 border border-gray-200 hover:border-gray-300">
<span>发现更多精彩</span>
<ArrowRight size={16} />
</Link>
</div>
</div>
<h1 className="font-display text-3xl sm:text-4xl md:text-5xl font-extrabold tracking-tight leading-tight text-gray-900">
{featuredItem.title}
</h1>
{featuredItem.overview ? (
<p className="text-gray-500 text-sm sm:text-base leading-relaxed line-clamp-3 font-semibold">
{featuredItem.overview}
</p>
) : (
<p className="text-gray-500 text-sm italic">
家庭私人媒体中心收藏。极高视听品质,支持多端原生无损解码及HLS转码播放。
</p>
)}
{/* Metadata Badges */}
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 font-bold">
{featuredItem.year > 0 && (
<span className="bg-gray-100 px-2.5 py-1 rounded-xl text-gray-900 border border-gray-200">{featuredItem.year} 年</span>
)}
{featuredItem.video_codec && (
<span className="rounded-lg bg-brand-50 px-2 py-1 text-brand-700 border border-brand-100 uppercase font-bold text-[10px]">
{featuredItem.video_codec}
</span>
)}
{featuredItem.container && (
<span className="rounded-lg bg-gray-100 px-2 py-1 text-gray-700 border border-gray-200 uppercase font-mono text-[10px]">
{featuredItem.container}
</span>
)}
</div>
{/* Buttons */}
<div className="flex flex-wrap items-center gap-4 pt-4">
<Link to={`/media/${featuredItem.id}`} className="inline-flex items-center justify-center gap-2 rounded-xl bg-[#111827] px-6 py-3.5 text-sm font-bold text-white shadow-sm hover:bg-[#1f2937] hover:-translate-y-0.5 transition-all">
<Play size={16} fill="currentColor" />
<span>立即播放</span>
</Link>
<Link to="/discover" className="btn-outline px-5 py-3.5 text-sm font-bold text-gray-700 border border-gray-200 hover:border-gray-300">
<span>发现更多精彩</span>
<ArrowRight size={16} />
</Link>
<div className="relative order-first mx-auto flex w-full max-w-[220px] items-center md:order-none md:max-w-[260px] lg:max-w-[310px]">
<div className="absolute -right-6 top-5 h-32 w-32 rounded-full bg-[#d4af37]/20 blur-3xl" />
<div className="absolute -left-5 bottom-8 h-28 w-28 rounded-full bg-brand-200/30 blur-3xl" />
<div className="relative aspect-[2/3] w-full overflow-hidden rounded-[1.7rem] border border-white/70 bg-white p-2 shadow-[0_32px_80px_rgba(15,23,42,0.20)]">
<div className="flex h-full w-full flex-col items-center justify-center rounded-[1.25rem] bg-[linear-gradient(135deg,#f9fafb,#fff7ed)] text-center">
<Film className="mb-4 h-12 w-12 text-[#c9954a]" />
<span className="px-6 font-display text-3xl font-black tracking-tight text-gray-950">{featuredItem.title}</span>
</div>
{featuredPoster && (
<img
src={imageURL(featuredPoster)}
alt={featuredItem.title}
className="absolute inset-2 h-[calc(100%-1rem)] w-[calc(100%-1rem)] rounded-[1.25rem] object-cover"
referrerPolicy="no-referrer"
onError={(event) => { event.currentTarget.style.display = 'none' }}
/>
)}
<div className="absolute inset-x-4 bottom-4 rounded-2xl bg-white/88 px-4 py-3 shadow-lg backdrop-blur">
<p className="truncate text-xs font-black tracking-[0.22em] text-[#a8732d]">MEDIASTATION PICK</p>
<p className="truncate text-sm font-extrabold text-gray-950">{featuredItem.title}</p>
</div>
</div>
</div>
</div>
</section>
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Film, Library as LibraryIcon, Music, PlayCircle, Tv } from 'lucide-react'
import { libraryAPI } from '../api/library'
import type { Library } from '../types'
const TYPE_ICONS: Record<string, React.ReactNode> = {
movie: <Film size={20} />,
tv: <Tv size={20} />,
anime: <PlayCircle size={20} />,
variety: <Tv size={20} />,
music: <Music size={20} />,
}
const TYPE_LABELS: Record<string, string> = {
movie: '电影',
tv: '剧集',
anime: '动漫',
variety: '综艺',
music: '音乐',
}
export function LibrariesPage() {
const [libraries, setLibraries] = useState<Library[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
libraryAPI.list().then(setLibraries).finally(() => setLoading(false))
}, [])
if (loading) {
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
}
return (
<div className="space-y-6">
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">媒体库</h1>
<p className="mt-1 text-sm text-ink-50">所有电影、剧集、番剧、综艺分类统一在这里查看。</p>
</div>
{libraries.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-sand-200 bg-white py-24 text-center">
<LibraryIcon className="mb-4 h-12 w-12 text-gray-400" />
<p className="text-sm text-ink-50">暂无媒体库,请到管理后台添加目录。</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{libraries.map((lib, index) => (
<motion.div
key={lib.id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
>
<Link
to={`/library/${lib.id}`}
className="group block rounded-3xl border border-sand-200 bg-white p-5 shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
>
<div className="mb-6 flex items-start justify-between gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-50 text-brand-600">
{TYPE_ICONS[lib.type] ?? <LibraryIcon size={20} />}
</div>
<span className="rounded-full bg-sand-100 px-3 py-1 text-xs font-semibold text-sand-600">
{TYPE_LABELS[lib.type] ?? lib.type}
</span>
</div>
<h2 className="font-display text-xl font-bold text-ink-600 group-hover:text-brand-600">
{lib.name}
</h2>
<p className="mt-2 line-clamp-2 break-all text-xs text-ink-50">{lib.path}</p>
</Link>
</motion.div>
))}
</div>
)}
</div>
)
}
+77 -43
View File
@@ -9,7 +9,7 @@ import type { Library, Media } from '../types'
import { MediaCard } from '../components/MediaCard'
import { imageURL } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { getSeriesKey, groupSeries, type SeriesCard } from '../utils/groupSeries'
import { getSeriesKey, groupSeries, isEpisodeLike, seriesTitle, type SeriesCard } from '../utils/groupSeries'
export function LibraryPage() {
const { id = '' } = useParams()
@@ -25,8 +25,10 @@ export function LibraryPage() {
// 剧集模式:选中某个剧集后展开详情
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
const [selectedSeason, setSelectedSeason] = useState<number | null>(null)
const isSeries = library?.type === 'tv' || library?.type === 'anime'
const hasEpisodicItems = useMemo(() => items.some(isEpisodeLike), [items])
const isSeries = library?.type === 'tv' || library?.type === 'anime' || library?.type === 'variety' || hasEpisodicItems
// 折叠后的剧集卡片
const seriesCards = useMemo(() => {
@@ -53,6 +55,11 @@ export function LibraryPage() {
.map(([season, episodes]) => ({ season, episodes }))
}, [selectedSeries, items])
const visibleEpisodes = useMemo(() => {
if (selectedSeason == null) return selectedEpisodes[0]?.episodes ?? []
return selectedEpisodes.find((s) => s.season === selectedSeason)?.episodes ?? []
}, [selectedEpisodes, selectedSeason])
useEffect(() => {
if (!id) return
libraryAPI.list().then((all) => {
@@ -64,8 +71,8 @@ export function LibraryPage() {
useEffect(() => {
if (!id || !library) return
setLoading(true)
// tv/anime 拉取较多集数以支持前端分组
const limit = library.type === 'tv' || library.type === 'anime' ? 1000 : 200
// 所有库都可能包含季集型内容(综艺/纪录片/课程等),统一拉取较大页数用于前端分组。
const limit = 2000
libraryAPI
.listMedia(id, 1, limit)
.then((d) => {
@@ -76,8 +83,10 @@ export function LibraryPage() {
}, [id, library])
useEffect(() => {
if (loading) return
if (!isSeries) {
setSelectedSeries(null)
setSelectedSeason(null)
return
}
@@ -89,13 +98,23 @@ export function LibraryPage() {
const next = seriesCards.find((card) => card.key === key)
if (next) setSelectedSeries(next)
}, [isSeries, searchParams, seriesCards])
}, [isSeries, loading, searchParams, seriesCards])
useEffect(() => {
if (!selectedSeries || selectedEpisodes.length === 0) {
setSelectedSeason(null)
return
}
if (selectedSeason == null || !selectedEpisodes.some((s) => s.season === selectedSeason)) {
setSelectedSeason(selectedEpisodes[0].season)
}
}, [selectedSeries, selectedEpisodes, selectedSeason])
const handleScan = async () => {
setScanning(true)
try {
const r = await libraryAPI.scan(id)
toast.success(`扫描完成:新增 ${r.added} 项`)
toast.success(`扫描完成:新增 ${r.added} 项,更新 ${r.updated ?? 0} 项`)
setLibrary((l) => (l ? { ...l } : l))
} catch {
toast.error('扫描失败')
@@ -121,6 +140,7 @@ export function LibraryPage() {
const clearSelectedSeries = () => {
setSelectedSeries(null)
setSelectedSeason(null)
const next = new URLSearchParams(searchParams)
next.delete('series')
setSearchParams(next)
@@ -144,7 +164,7 @@ export function LibraryPage() {
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">
{library?.name ?? '媒体库'}
{!isSeries && <span className="text-sand-500"> ({total})</span>}
<span className="text-sand-500"> ({isSeries ? seriesCards.length : total})</span>
</h1>
{library && <p className="text-sm text-ink-50">{library.type} · {library.path}</p>}
</div>
@@ -200,7 +220,7 @@ export function LibraryPage() {
返回列表
</button>
<h2 className="font-display text-2xl font-bold text-ink-600 truncate">
{selectedSeries.rep.title}
{seriesTitle(selectedSeries.rep)}
</h2>
<span className="text-sm text-sand-500">共 {selectedSeries.count} 集</span>
</div>
@@ -221,12 +241,11 @@ export function LibraryPage() {
{/* 从第一集开始 */}
{(() => {
const firstEps = selectedEpisodes
.flatMap((s) => s.episodes)
.sort((a, b) =>
(a.season_num || 0) - (b.season_num || 0)
|| (a.episode_num || 0) - (b.episode_num || 0),
)
const firstEps = [...(visibleEpisodes.length > 0 ? visibleEpisodes : selectedEpisodes.flatMap((s) => s.episodes))]
firstEps.sort((a, b) =>
(a.season_num || 0) - (b.season_num || 0)
|| (a.episode_num || 0) - (b.episode_num || 0),
)
const first = firstEps.length > 0 ? firstEps[0] : null
return first ? (
<Link to={`/play/${first.id}`} className="btn-primary inline-flex">
@@ -240,37 +259,52 @@ export function LibraryPage() {
{/* 季 / 集列表 */}
<div className="space-y-6">
{selectedEpisodes.map(({ season, episodes }) => (
<div key={season}>
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
<div className="flex flex-wrap items-center gap-2">
{selectedEpisodes.map(({ season, episodes }) => (
<button
key={season}
onClick={() => setSelectedSeason(season)}
className={
'rounded-xl border px-4 py-2 text-sm font-semibold transition ' +
(selectedSeason === season
? 'border-brand-300 bg-brand-50 text-brand-700'
: 'border-sand-200 bg-white text-ink-100 hover:border-brand-200 hover:text-brand-600')
}
>
第 {season} 季 · {episodes.length} 集
</h3>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{episodes.map((ep) => (
<Link
key={ep.id}
to={`/play/${ep.id}`}
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
{ep.episode_num || '—'}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-ink-600">
{ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : ep.title}
</p>
<p className="text-xs text-sand-500">
{ep.duration_sec > 0
? `${Math.floor(ep.duration_sec / 60)} 分钟`
: formatSize(ep.size_bytes)}
</p>
</div>
<Play size={14} className="shrink-0 text-gray-500 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
</Link>
))}
</div>
</button>
))}
</div>
<div>
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
第 {selectedSeason ?? selectedEpisodes[0]?.season ?? 1} 季
</h3>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{visibleEpisodes.map((ep) => (
<Link
key={ep.id}
to={`/play/${ep.id}`}
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
{ep.episode_num || '—'}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-ink-600">
{ep.original_name || (ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : ep.title)}
</p>
<p className="text-xs text-sand-500">
{ep.duration_sec > 0
? `${Math.floor(ep.duration_sec / 60)} 分钟`
: formatSize(ep.size_bytes)}
</p>
</div>
<Play size={14} className="shrink-0 text-gray-500 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
</Link>
))}
</div>
))}
</div>
</div>
</motion.div>
)}
+78 -39
View File
@@ -35,6 +35,8 @@ export function PlayerPage() {
const [media, setMedia] = useState<Media | null>(null)
const [mode, setMode] = useState<Mode>('direct')
const [subs, setSubs] = useState<SubtitleTrack[]>([])
const [hlsUnavailable, setHlsUnavailable] = useState(false)
const [playerError, setPlayerError] = useState('')
// Load metadata and pick a default mode.
useEffect(() => {
@@ -44,10 +46,11 @@ export function PlayerPage() {
const forced = params.get('mode') as Mode | null
const auto = pickMode(m)
setMode(forced ?? auto)
setPlayerError('')
})
subtitlesAPI
.list(id)
.then(setSubs)
.then((tracks) => setSubs(tracks ?? []))
.catch(() => setSubs([]))
}, [id, params])
@@ -66,7 +69,9 @@ export function PlayerPage() {
hls.attachMedia(video)
hls.on(HlsCtor.Events.ERROR, (_, data) => {
if (data.fatal) {
toast.error('HLS 播放失败,尝试切换到直接播放')
setHlsUnavailable(true)
setPlayerError('HLS 转码不可用,正在尝试直接播放原始文件。若出现有画面无声音,通常是 MKV/AC3/EAC3 音轨需要配置本机 ffmpeg 转码为 AAC。')
toast.error('HLS 转码失败,尝试切换到直接播放')
setMode('direct')
params.set('mode', 'direct')
setParams(params, { replace: true })
@@ -76,17 +81,26 @@ export function PlayerPage() {
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url
} else {
toast.error('当前浏览器不支持 HLS,降级到直接播放')
setHlsUnavailable(true)
setPlayerError('当前浏览器不支持 HLS,正在尝试直接播放。')
toast.error('当前浏览器不支持 HLS,降级到直接播放')
setMode('direct')
}
void video.play().catch(() => undefined)
}).catch(() => {
setHlsUnavailable(true)
setPlayerError('HLS 播放组件加载失败,正在尝试直接播放。')
setMode('direct')
})
} else {
video.src = streamURL(media.id)
if (hlsUnavailable && needsTranscodeForBrowser(media)) {
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
}
void video.play().catch(() => undefined)
}
return teardownHls
}, [media, mode, params, setParams])
}, [hlsUnavailable, media, mode, params, setParams])
// Persist resume position every 10 seconds while playing.
useEffect(() => {
@@ -113,11 +127,14 @@ export function PlayerPage() {
// ESC = back.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') navigate(-1)
if (e.key === 'Escape') {
const target = media?.id || id
navigate(target ? `/media/${target}` : '/', { replace: true })
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [navigate])
}, [id, media?.id, navigate])
const teardownHls = () => {
if (hlsRef.current) {
@@ -126,35 +143,42 @@ export function PlayerPage() {
}
}
return (
<div className="-m-6 flex min-h-screen flex-col bg-black md:-m-8">
<button
onClick={() => navigate(-1)}
className="absolute left-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-ink-600 backdrop-blur transition hover:bg-black/80"
>
<ArrowLeft size={16} /> 返回
</button>
const goBack = () => {
const target = media?.id || id
navigate(target ? `/media/${target}` : '/', { replace: true })
}
<button
onClick={() => {
const next = mode === 'hls' ? 'direct' : 'hls'
setMode(next)
params.set('mode', next)
setParams(params, { replace: true })
}}
className="absolute right-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-ink-600 backdrop-blur transition hover:bg-black/80"
title="切换播放模式"
>
{mode === 'hls' ? (
<>
<RefreshCw size={14} /> HLS 转码中
</>
) : (
<>
<Sparkles size={14} /> 直接播放
</>
)}
</button>
return (
<div className="relative -m-6 flex min-h-screen flex-col overflow-hidden bg-black md:-m-8">
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between p-4 sm:p-6">
<button
onClick={goBack}
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
>
<ArrowLeft size={16} /> 返回
</button>
<button
onClick={() => {
const next = mode === 'hls' ? 'direct' : 'hls'
setMode(next)
params.set('mode', next)
setParams(params, { replace: true })
}}
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
title="切换播放模式"
>
{mode === 'hls' ? (
<>
<RefreshCw size={14} /> HLS 转码中
</>
) : (
<>
<Sparkles size={14} /> 直接播放
</>
)}
</button>
</div>
<div className="flex flex-1 items-center justify-center">
{media ? (
@@ -168,11 +192,17 @@ export function PlayerPage() {
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
if (mode === 'direct') {
toast.error('直接播放失败,切换到 HLS 转码')
setMode('hls')
params.set('mode', 'hls')
setParams(params, { replace: true })
if (hlsUnavailable) {
setPlayerError('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
toast.error('直接播放失败,HLS 转码不可用')
} else {
toast.error('直接播放失败,切换到 HLS 转码')
setMode('hls')
params.set('mode', 'hls')
setParams(params, { replace: true })
}
} else {
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
toast.error('视频播放失败,请检查文件是否存在')
}
}}
@@ -191,6 +221,11 @@ export function PlayerPage() {
) : (
<p className="text-sand-500">加载中…</p>
)}
{playerError ? (
<div className="absolute bottom-20 left-1/2 w-[min(92vw,720px)] -translate-x-1/2 rounded-2xl border border-white/15 bg-black/75 px-5 py-4 text-sm text-white shadow-2xl backdrop-blur">
{playerError}
</div>
) : null}
</div>
</div>
)
@@ -201,11 +236,15 @@ const directVideoCodecs = ['h264', 'avc', 'avc1']
const directAudioCodecs = ['aac', 'mp3', 'opus']
function pickMode(m: Media): Mode {
return needsTranscodeForBrowser(m) ? 'hls' : 'direct'
}
function needsTranscodeForBrowser(m: Media): boolean {
const c = (m.container ?? '').toLowerCase()
const v = (m.video_codec ?? '').toLowerCase()
const a = (m.audio_codec ?? '').toLowerCase()
const containerOK = directContainers.some((x) => c.includes(x))
const videoOK = !v || directVideoCodecs.some((x) => v.includes(x))
const audioOK = !a || directAudioCodecs.some((x) => a.includes(x))
return containerOK && videoOK && audioOK ? 'direct' : 'hls'
return !(containerOK && videoOK && audioOK)
}
+110 -5
View File
@@ -1,9 +1,11 @@
import { ChangeEvent, FormEvent, useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Sparkles } from 'lucide-react'
import { Rss, Sparkles } from 'lucide-react'
import { aiAPI, type SearchIntent } from '../api/ai'
import { aiAPI, type ExternalMediaResult, type SearchIntent } from '../api/ai'
import { imageURL } from '../api/client'
import { mediaAPI } from '../api/library'
import { subscriptionsAPI } from '../api/subscriptions'
import { MediaCard } from '../components/MediaCard'
import type { Media } from '../types'
@@ -16,6 +18,8 @@ export function SearchPage() {
const [aiAvailable, setAiAvailable] = useState(false)
const [intent, setIntent] = useState<SearchIntent | null>(null)
const [hasSearched, setHasSearched] = useState(false)
const [externalItems, setExternalItems] = useState<ExternalMediaResult[]>([])
const [subscribing, setSubscribing] = useState('')
useEffect(() => {
aiAPI
@@ -37,6 +41,7 @@ export function SearchPage() {
.search(query, 60)
.then((d) => {
setItems(d.items ?? [])
setExternalItems([])
setIntent(null)
})
.catch((err) => {
@@ -66,6 +71,7 @@ export function SearchPage() {
try {
const data = await aiAPI.smartSearch(q)
setItems(data.items ?? [])
setExternalItems(data.external_items ?? [])
setIntent(data.intent)
} catch (err) {
const msg =
@@ -86,15 +92,14 @@ export function SearchPage() {
<header className="flex items-center justify-between">
<h1 className="font-display text-3xl font-bold text-ink-600">搜索</h1>
<button
disabled={!aiAvailable}
className={
'neon-button !px-3 !py-1 !text-xs ' +
(aiOn ? '!border-accent-400 !bg-accent-400/20 !text-accent-400' : '')
}
onClick={() => setAiOn((on) => !on)}
title={aiAvailable ? '启用 AI 智能搜索' : '请先在 ai.* 中配置 API Key'}
title={aiAvailable ? '启用 AI 智能搜索' : '使用本地规则 + 外部数据源搜索'}
>
<Sparkles size={12} /> {aiOn ? 'AI 已开启' : 'AI 智能搜索'}
<Sparkles size={12} /> {aiOn ? '智能搜索已开启' : '智能搜索'}
</button>
</header>
@@ -156,12 +161,112 @@ export function SearchPage() {
)}
{items.length > 0 && (
<>
<div className="text-sm font-semibold text-ink-100">本地媒体库</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{items.map((m) => (
<MediaCard key={m.id} media={m} />
))}
</div>
</>
)}
{externalItems.length > 0 && (
<ExternalResults
items={externalItems}
busyKey={subscribing}
onSubscribe={async (item) => {
const keyword = item.subscribe_keyword || item.title
const key = `${item.source}:${keyword}`
setSubscribing(key)
try {
const feed = `site-search://search?keyword=${encodeURIComponent(keyword)}&source=${encodeURIComponent(item.source)}`
const sub = await subscriptionsAPI.create({
name: `${item.title} 自动订阅`,
feed_url: feed,
filter: keyword,
media_type: item.media_type,
enabled: true,
})
const run = await subscriptionsAPI.runNow(sub.id)
toast.success(
run.queued > 0
? `已订阅并加入 ${run.queued} 个下载`
: '已订阅,暂未在 PT 站点找到可下载资源',
)
} catch (err) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'订阅失败'
toast.error(msg)
} finally {
setSubscribing('')
}
}}
/>
)}
</div>
)
}
function ExternalResults({
items,
busyKey,
onSubscribe,
}: {
items: ExternalMediaResult[]
busyKey: string
onSubscribe: (item: ExternalMediaResult) => Promise<void>
}) {
return (
<section className="space-y-3">
<div>
<h2 className="font-display text-xl font-semibold text-ink-600">外部数据源</h2>
<p className="text-xs text-ink-50">
来自 TMDb / 豆瓣 / Bangumi。订阅后会定期搜索已配置 PT 站点,并只入队最佳新资源。
</p>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => {
const keyword = item.subscribe_keyword || item.title
const key = `${item.source}:${keyword}`
return (
<article key={key} className="glass-panel flex gap-3 !p-3">
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-xl bg-gray-100">
{item.poster_url ? (
<img
src={imageURL(item.poster_url)}
alt={item.title}
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2">
<span className="rounded-full bg-primary-400/10 px-2 py-0.5 text-[10px] uppercase text-brand-500">
{item.source}
</span>
{item.media_type && <span className="text-xs text-sand-500">{item.media_type}</span>}
{item.year ? <span className="text-xs text-sand-500">{item.year}</span> : null}
{item.rating ? <span className="text-xs text-amber-500">★ {item.rating.toFixed(1)}</span> : null}
</div>
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
{item.overview || `订阅关键词:${keyword}`}
</p>
<button
onClick={() => onSubscribe(item)}
disabled={busyKey === key}
className="mt-3 rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10 disabled:opacity-50"
>
<Rss size={12} className="mr-1 inline" />
{busyKey === key ? '订阅中…' : '订阅并搜索 PT'}
</button>
</div>
</article>
)
})}
</div>
</section>
)
}
+12
View File
@@ -4,6 +4,7 @@ import { Globe, Plus, Trash2, Wifi, RefreshCw, X, Edit3, CheckCircle, XCircle, H
import { sitesAPI } from '../api/sites'
import type { Site } from '../types'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
// ── 站点类型映射 ──
const SITE_TYPE_LABELS: Record<string, string> = {
@@ -235,6 +236,17 @@ export function SitesPage() {
return (
<div className="space-y-6">
<ManagementShortcuts
title="站点与下载链路"
description="把站点、搜索、订阅和下载器放在同一工作流里,避免功能入口被隐藏。"
items={[
{ to: '/download-clients', title: '下载器管理', description: '添加、测试和维护下载器连接', badge: '必需' },
{ to: '/site-search', title: '站点检索', description: '跨 PT 站点搜索资源并创建下载任务' },
{ to: '/subscriptions', title: '订阅管理', description: '管理追剧追番和自动下载规则' },
{ to: '/downloads', title: '下载中心', description: '查看下载任务状态和历史记录' },
]}
/>
{/* 页头 */}
<div className="flex items-center justify-between">
<h1 className="font-display text-3xl font-bold text-ink-600">站点管理</h1>
+10 -3
View File
@@ -4,6 +4,7 @@ import { Activity, Cpu, Database, Film, HardDrive, Users } from 'lucide-react'
import { statsAPI } from '../api/stats'
import { MediaCard } from '../components/MediaCard'
import type { StatsSnapshot } from '../types'
import { groupSeries } from '../utils/groupSeries'
// fmtBytes is a tiny helper shared by the dashboard cards.
function fmtBytes(n: number): string {
@@ -54,6 +55,7 @@ export function StatsPage() {
snap.hardware.disk_total > 0
? (snap.hardware.disk_used / snap.hardware.disk_total) * 100
: 0
const recentlyAddedCards = groupSeries(snap.recently_added)
return (
<div className="space-y-8">
@@ -91,12 +93,17 @@ export function StatsPage() {
</div>
</section>
{snap.recently_added.length > 0 && (
{recentlyAddedCards.length > 0 && (
<section className="space-y-3">
<h2 className="font-display text-xl font-semibold text-ink-600">最近入库</h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{snap.recently_added.map((m) => (
<MediaCard key={m.id} media={m} />
{recentlyAddedCards.map((s) => (
<MediaCard
key={s.key}
media={s.rep}
count={s.count}
linkTo={s.count > 1 ? `/library/${s.rep.library_id}?series=${encodeURIComponent(s.key)}` : undefined}
/>
))}
</div>
</section>
+12
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Database, HardDrive, PieChart } from 'lucide-react'
import { storageAPI, type StorageBreakdown } from '../api/storage'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
function fmtBytes(n: number): string {
if (!n) return '0 B'
@@ -50,6 +51,17 @@ export function StoragePage() {
</div>
</header>
<ManagementShortcuts
title="存储与文件入口"
description="存储统计只负责看数据,文件管理、排重、回收与配置入口集中放在这里。"
items={[
{ to: '/files', title: '文件管理', description: '浏览服务器文件并执行基础文件操作' },
{ to: '/storage-config', title: '存储配置', description: '维护媒体存储路径和容量策略' },
{ to: '/duplicates', title: '重复清理', description: '扫描重复媒体并进行安全清理', badge: '清理' },
{ to: '/recycle', title: '回收站', description: '查看已删除资源并执行恢复或释放空间' },
]}
/>
<section className="grid gap-4 sm:grid-cols-3">
<Tile icon={<Database size={20} />} label="总占用" value={fmtBytes(data.total_bytes)} />
<Tile icon={<PieChart size={20} />} label="媒体库" value={`${data.by_library.length}`} />
+40 -4
View File
@@ -10,6 +10,9 @@ export function SubscriptionsPage() {
const [name, setName] = useState('')
const [feed, setFeed] = useState('')
const [filter, setFilter] = useState('')
const [mediaType, setMediaType] = useState('')
const [mediaCategory, setMediaCategory] = useState('')
const [savePath, setSavePath] = useState('')
const [loading, setLoading] = useState(true)
const refresh = () =>
@@ -25,11 +28,21 @@ export function SubscriptionsPage() {
const onCreate = async (e: FormEvent) => {
e.preventDefault()
try {
await subscriptionsAPI.create({ name, feed_url: feed, filter })
await subscriptionsAPI.create({
name,
feed_url: feed,
filter,
media_type: mediaType || undefined,
media_category: mediaCategory || undefined,
save_path: savePath || undefined,
})
toast.success('已创建订阅')
setName('')
setFeed('')
setFilter('')
setMediaType('')
setMediaCategory('')
setSavePath('')
await refresh()
} catch (err: unknown) {
const msg =
@@ -42,10 +55,10 @@ export function SubscriptionsPage() {
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-ink-600">RSS 订阅</h1>
<p className="text-sm text-ink-50">
定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列。
定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列;启用智能分类后会按媒体类型和二级分类写入下载目录。
</p>
<form onSubmit={onCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_1fr_auto]">
<form onSubmit={onCreate} className="glass-panel grid gap-3 md:grid-cols-4">
<input
required
className="input-base"
@@ -66,7 +79,26 @@ export function SubscriptionsPage() {
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<button type="submit" className="neon-button">
<select className="input-base" value={mediaType} onChange={(e) => setMediaType(e.target.value)}>
<option value="">自动识别类型</option>
<option value="movie">电影</option>
<option value="tv">电视剧</option>
<option value="anime">动漫</option>
<option value="variety">综艺</option>
</select>
<input
className="input-base"
placeholder="二级分类覆盖(如 综艺/日番,可选)"
value={mediaCategory}
onChange={(e) => setMediaCategory(e.target.value)}
/>
<input
className="input-base md:col-span-2"
placeholder="下载根目录覆盖(可选,默认使用下载器保存路径)"
value={savePath}
onChange={(e) => setSavePath(e.target.value)}
/>
<button type="submit" className="neon-button md:col-span-1">
<Plus size={16} /> 添加
</button>
</form>
@@ -82,6 +114,7 @@ export function SubscriptionsPage() {
<th className="py-2">名称</th>
<th>RSS</th>
<th>过滤器</th>
<th>分类</th>
<th>最近运行</th>
<th className="text-right">操作</th>
</tr>
@@ -94,6 +127,9 @@ export function SubscriptionsPage() {
{s.feed_url}
</td>
<td className="text-ink-100">{s.filter || '—'}</td>
<td className="text-ink-100">
{[s.media_type, s.media_category].filter(Boolean).join(' / ') || '自动'}
</td>
<td className="text-sand-500">
{s.last_run_at ? new Date(s.last_run_at).toLocaleString() : '—'}
</td>
+13
View File
@@ -6,6 +6,7 @@ import { adminAPI } from '../api/admin'
import { libraryAPI, mediaAPI } from '../api/library'
import { toolsAPI } from '../api/tools'
import type { Library, Media, Setting } from '../types'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
// ToolsPage gathers admin-only one-off operations that don't belong on a
// dedicated screen of their own:
@@ -29,6 +30,18 @@ export function ToolsPage() {
</div>
</div>
<ManagementShortcuts
title="运维与自动化入口"
description="把整理、任务、通知和高级辅助功能统一放回工具台。"
items={[
{ to: '/strm', title: 'STRM 生成', description: '生成 STRM 文件供外部播放器或媒体服务使用' },
{ to: '/scheduler', title: '定时任务', description: '查看和维护自动扫描、刮削与订阅任务' },
{ to: '/tasks', title: '任务队列', description: '跟踪后台任务执行状态和失败原因' },
{ to: '/notify-channels', title: '通知渠道', description: '配置并测试消息通知渠道' },
{ to: '/assistant', title: 'AI 对话台', description: '进入管理员辅助诊断和问答界面' },
]}
/>
<OrganizePanel />
<NotifyPanel />
</div>
+6
View File
@@ -145,7 +145,10 @@ export interface ScanResult {
library_id: string
visited: number
added: number
updated?: number
probed: number
local_metadata?: number
removed?: number
}
export interface Setting {
@@ -170,6 +173,9 @@ export interface Subscription {
name: string
feed_url: string
filter: string
media_type?: string
media_category?: string
save_path?: string
enabled: boolean
last_run_at?: string
created_at: string
+38 -5
View File
@@ -9,9 +9,9 @@ import type { Media } from '../types'
*
* 折叠键优先级(命中第一个就分组):
*
* 1. tmdb_id (刮削匹配后最稳定)
* 2. bangumi_id (番剧)
* 3. series_id (后端某些场景下会预先聚合)
* 1. series_id (后端某些场景下会预先聚合)
* 2. 有季/集信息时用 library_id + 节目名(综艺每集常有不同 TMDB episode id)
* 3. tmdb_id / bangumi_id(电影或无季集条目)
* 4. library_id + title (fallback:同库同名视为同一剧)
*
* 同一组内取最早 created_at 的那条作为代表卡片,并带 count 表示集数。
@@ -19,10 +19,43 @@ import type { Media } from '../types'
export type SeriesCard = { key: string; rep: Media; count: number }
export function getSeriesKey(media: Media): string {
if (media.series_id) return `series:${media.series_id}`
if (isEpisodeLike(media)) {
return `lib:${media.library_id}|show:${normalizeTitle(seriesTitle(media))}`
}
if (media.tmdb_id && media.tmdb_id > 0) return `tmdb:${media.tmdb_id}`
if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}`
if (media.series_id) return `series:${media.series_id}`
return `lib:${media.library_id}|${(media.title ?? '').toLowerCase().trim()}`
return `lib:${media.library_id}|${normalizeTitle(media.title)}`
}
export function isEpisodeLike(media: Media): boolean {
return (media.season_num ?? 0) > 0 || (media.episode_num ?? 0) > 0
}
export function seriesTitle(media: Media): string {
const fromPath = seriesTitleFromPath(media.path)
return fromPath || media.title || media.original_name || '未命名节目'
}
function normalizeTitle(value?: string): string {
return (value ?? '')
.toLowerCase()
.replace(/\s*\((?:19|20)\d{2}\)\s*/g, ' ')
.replace(/\s*\[(?:tmdb|tmdbid)[=-]\d+\]\s*/g, ' ')
.replace(/[\s._-]+/g, ' ')
.trim()
}
function seriesTitleFromPath(path?: string): string {
if (!path) return ''
const parts = path.split(/[\\/]+/).filter(Boolean)
if (parts.length < 2) return ''
let dirIndex = parts.length - 2
if (/^(?:s\d{1,2}|season\s*\d{1,2}|第\s*\d{1,2}\s*季)$/i.test(parts[dirIndex])) {
dirIndex -= 1
}
if (dirIndex < 0) return ''
return normalizeTitle(parts[dirIndex])
}
export function groupSeries(items: Media[]): SeriesCard[] {